Skip to content

Commit 0270dcb

Browse files
author
bndct-devops
committed
feat: VAPID web push for lock-screen rest timer notifications
1 parent 7e6807c commit 0270dcb

6 files changed

Lines changed: 247 additions & 10 deletions

File tree

backend/main.py

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
from fastapi.responses import StreamingResponse, JSONResponse
33
from sqlmodel import Session, select, text, or_
44
from backend.db import engine, create_db_and_tables
5-
from backend.models import Exercise, Workout, SetEntry, Profile, BodyweightEntry
5+
from backend.models import Exercise, Workout, SetEntry, Profile, BodyweightEntry, PushSubscription
66
from backend import schemas
77
from backend.seed_exercises import seed as seed_exercises
88
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Counter
99
from typing import List, Optional
1010
from datetime import datetime, timedelta
1111
import io, csv, re, hashlib, os as _os, time as _time, secrets as _secrets
12+
import asyncio, json, base64
1213
import uvicorn
1314
from backend import auth as _auth
1415

@@ -17,6 +18,10 @@
1718
_PW_MAX_ATTEMPTS = 5
1819
_PW_LOCKOUT_SECS = 30
1920

21+
# ── Web Push state ──
22+
_vapid_state: dict = {"private_b64url": None, "public_b64url": None}
23+
_push_tasks: dict = {} # key -> asyncio.Task
24+
2025
app = FastAPI(title="lifty API")
2126

2227
REQUEST_COUNTER = Counter("lifty_requests_total", "Total HTTP requests", ["method", "endpoint", "status"])
@@ -110,6 +115,39 @@ def on_startup():
110115
# ── Seed built-in global exercises (idempotent) ──
111116
seed_exercises()
112117

118+
# ── VAPID key pair: generate once, persist in app_config ──
119+
from cryptography.hazmat.primitives.asymmetric.ec import generate_private_key, SECP256R1
120+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
121+
with Session(engine) as session:
122+
session.exec(text("""
123+
CREATE TABLE IF NOT EXISTS pushsubscription (
124+
id INTEGER PRIMARY KEY AUTOINCREMENT,
125+
profile_id INTEGER NOT NULL REFERENCES profile(id),
126+
endpoint TEXT NOT NULL,
127+
p256dh TEXT NOT NULL,
128+
auth TEXT NOT NULL,
129+
created_at DATETIME
130+
)
131+
"""))
132+
session.commit()
133+
134+
row = session.exec(text("SELECT value FROM app_config WHERE key='vapid_private'")).first()
135+
if row:
136+
_vapid_state["private_b64url"] = row[0]
137+
pub_row = session.exec(text("SELECT value FROM app_config WHERE key='vapid_public'")).first()
138+
_vapid_state["public_b64url"] = pub_row[0] if pub_row else None
139+
else:
140+
sk = generate_private_key(SECP256R1())
141+
raw_priv = sk.private_numbers().private_value.to_bytes(32, 'big')
142+
priv_b64 = base64.urlsafe_b64encode(raw_priv).decode().rstrip('=')
143+
pub_bytes = sk.public_key().public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
144+
pub_b64 = base64.urlsafe_b64encode(pub_bytes).decode().rstrip('=')
145+
session.execute(text("INSERT INTO app_config (key, value) VALUES (:k, :v)"), {"k": "vapid_private", "v": priv_b64})
146+
session.execute(text("INSERT INTO app_config (key, value) VALUES (:k, :v)"), {"k": "vapid_public", "v": pub_b64})
147+
session.commit()
148+
_vapid_state["private_b64url"] = priv_b64
149+
_vapid_state["public_b64url"] = pub_b64
150+
113151

114152
# ─────────────────────────────────────────────
115153
# Utility
@@ -161,6 +199,100 @@ async def auth_change_password(request: Request):
161199
return {"token": _auth.create_access_token(_auth_state["jwt_secret"])}
162200

163201

202+
# ─────────────────────────────────────────────
203+
# Web Push (VAPID)
204+
# ─────────────────────────────────────────────
205+
206+
@app.get("/api/push/vapid-public-key")
207+
def push_vapid_key():
208+
"""Return the VAPID public key so the client can create a push subscription."""
209+
return {"publicKey": _vapid_state.get("public_b64url")}
210+
211+
212+
@app.post("/api/push/subscribe")
213+
async def push_subscribe(request: Request):
214+
"""Store or update a push subscription for a profile."""
215+
body = await request.json()
216+
profile_id = body.get("profileId")
217+
endpoint = body.get("endpoint")
218+
p256dh = body.get("p256dh")
219+
auth = body.get("auth")
220+
if not all([profile_id, endpoint, p256dh, auth]):
221+
return JSONResponse({"detail": "Missing fields"}, status_code=422)
222+
with Session(engine) as session:
223+
existing = session.exec(
224+
select(PushSubscription).where(PushSubscription.profile_id == profile_id)
225+
).first()
226+
if existing:
227+
existing.endpoint = endpoint
228+
existing.p256dh = p256dh
229+
existing.auth = auth
230+
session.add(existing)
231+
else:
232+
session.add(PushSubscription(
233+
profile_id=profile_id, endpoint=endpoint, p256dh=p256dh, auth=auth
234+
))
235+
session.commit()
236+
return {"ok": True}
237+
238+
239+
@app.post("/api/push/schedule")
240+
async def push_schedule(request: Request):
241+
"""Schedule a Web Push notification after delay_ms milliseconds."""
242+
body = await request.json()
243+
profile_id = body.get("profileId")
244+
delay_ms = int(body.get("delayMs", 0))
245+
title = body.get("title", "lifty")
246+
msg_body = body.get("body", "Rest done — time to lift!")
247+
248+
key = f"rest-{profile_id}"
249+
250+
# Cancel any previous task for this slot
251+
if key in _push_tasks and not _push_tasks[key].done():
252+
_push_tasks[key].cancel()
253+
254+
async def _send():
255+
try:
256+
await asyncio.sleep(delay_ms / 1000)
257+
with Session(engine) as session:
258+
sub = session.exec(
259+
select(PushSubscription).where(PushSubscription.profile_id == profile_id)
260+
).first()
261+
if not sub:
262+
return
263+
from pywebpush import webpush, WebPushException
264+
webpush(
265+
subscription_info={
266+
"endpoint": sub.endpoint,
267+
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
268+
},
269+
data=json.dumps({"title": title, "body": msg_body}),
270+
vapid_private_key=_vapid_state["private_b64url"],
271+
vapid_claims={"sub": "mailto:lifty@lifty.app"},
272+
)
273+
except asyncio.CancelledError:
274+
pass
275+
except Exception as exc:
276+
print(f"[push] send error: {exc}")
277+
finally:
278+
_push_tasks.pop(key, None)
279+
280+
_push_tasks[key] = asyncio.create_task(_send())
281+
return {"ok": True}
282+
283+
284+
@app.post("/api/push/cancel")
285+
async def push_cancel(request: Request):
286+
"""Cancel a pending push notification for a profile."""
287+
body = await request.json()
288+
profile_id = body.get("profileId")
289+
key = f"rest-{profile_id}"
290+
if key in _push_tasks and not _push_tasks[key].done():
291+
_push_tasks[key].cancel()
292+
_push_tasks.pop(key, None)
293+
return {"ok": True}
294+
295+
164296
# ─────────────────────────────────────────────
165297
# Utility
166298
# ─────────────────────────────────────────────

backend/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,13 @@ class BodyweightEntry(SQLModel, table=True):
5858
profile_id: int = Field(foreign_key="profile.id")
5959
weight_kg: float
6060
date: datetime = Field(default_factory=datetime.utcnow)
61+
62+
63+
class PushSubscription(SQLModel, table=True):
64+
id: Optional[int] = Field(default=None, primary_key=True)
65+
profile_id: int = Field(foreign_key="profile.id")
66+
endpoint: str
67+
p256dh: str
68+
auth: str
69+
created_at: datetime = Field(default_factory=datetime.utcnow)
6170
order: Optional[int] = Field(default=0)

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ sqlmodel==0.0.8
44
prometheus-client==0.16.0
55
python-multipart==0.0.6
66
python-jose[cryptography]==3.3.0
7+
pywebpush==1.14.1

frontend/public/sw.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,27 @@ self.addEventListener('message', e => {
118118
}
119119
})
120120

121+
// ── Web Push: fired by the server when the screen is locked ───────────────
122+
self.addEventListener('push', e => {
123+
let title = 'lifty'
124+
let body = 'Rest done — time to lift!'
125+
try {
126+
const data = e.data.json()
127+
if (data.title) title = data.title
128+
if (data.body) body = data.body
129+
} catch (_) {}
130+
e.waitUntil(
131+
self.registration.showNotification(title, {
132+
body,
133+
icon: '/apple-touch-icon.png',
134+
badge: '/apple-touch-icon.png',
135+
tag: 'rest-timer',
136+
renotify: true,
137+
silent: false,
138+
})
139+
)
140+
})
141+
121142
// ── Notification tap → focus / open app ───────────────────────────────────
122143
self.addEventListener('notificationclick', e => {
123144
e.notification.close()

frontend/src/ActiveWorkoutView.jsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react"
22
import { Check, CheckCircle2, Dumbbell, Flag, Lock, Timer, TrendingUp, Trophy } from "lucide-react"
33
import { fmtWeight, parseWeight, playDing, IconX, MiniMarkdown } from "./utils"
4-
import { getExerciseLastSets, getExerciseHistory } from "./api"
4+
import { getExerciseLastSets, getExerciseHistory, subscribePush, schedulePush, cancelPush } from "./api"
55

66
export default function ActiveWorkoutView({ workout, exercises, sessionSets, onFinish, onCancel, onExit, onAddSet, onDeleteSet, onRename, onSaveNotes, unit = 'kg', restDuration: propRestDuration = 90, dingEnabled = true, onRestDurationChange, overloadHints = true, plateCalc = true, prs = [], liquidGlass = false, animationsEnabled = true }) {
77
const BODY_PARTS = ['Chest', 'Back', 'Legs', 'Shoulders', 'Arms', 'Core', 'Cardio', 'Full Body', 'Other']
@@ -72,6 +72,13 @@ export default function ActiveWorkoutView({ workout, exercises, sessionSets, onF
7272
typeof Notification !== 'undefined' ? Notification.permission : 'unavailable'
7373
)
7474

75+
// Subscribe to web push if permission already granted (handles app updates)
76+
React.useEffect(() => {
77+
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
78+
subscribePush(workout?.profile_id).catch(() => {})
79+
}
80+
}, [workout?.profile_id])
81+
7582
async function openHistory(exId, exName) {
7683
setHistorySheet({ exId, name: exName, data: null })
7784
setHistoryLoading(true)
@@ -106,11 +113,20 @@ export default function ActiveWorkoutView({ workout, exercises, sessionSets, onF
106113
setRestLeft(dur)
107114
setRestRunning(true)
108115
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
109-
Notification.requestPermission().then(p => setNotifPerm(p)).catch(() => {})
116+
Notification.requestPermission().then(p => {
117+
setNotifPerm(p)
118+
if (p === 'granted') subscribePush(workout?.profile_id).catch(() => {})
119+
}).catch(() => {})
110120
}
111121
scheduleSwNotif(dur * 1000)
122+
schedulePush(workout?.profile_id, dur * 1000, 'lifty', 'Rest done — time to lift!')
123+
}
124+
function stopRestTimer() {
125+
setRestLeft(null)
126+
setRestRunning(false)
127+
cancelSwNotif()
128+
cancelPush(workout?.profile_id)
112129
}
113-
function stopRestTimer() { setRestLeft(null); setRestRunning(false); cancelSwNotif() }
114130

115131
const exerciseIds = [...new Set(sessionSets.map(s => s.exercise_id))]
116132
if (selectedExId && !exerciseIds.includes(selectedExId)) exerciseIds.push(selectedExId)
@@ -156,15 +172,13 @@ export default function ActiveWorkoutView({ workout, exercises, sessionSets, onF
156172
if (!restRunning || !restEndRef.current) return
157173

158174
function finish() {
159-
// Stop the timer display immediately — don't gate on async audio
175+
// Page is alive so cancel both server push and SW timeout — ding handles it
176+
cancelPush(workout?.profile_id)
177+
cancelSwNotif()
160178
setRestLeft(null)
161179
setRestRunning(false)
162180
if (navigator.vibrate) navigator.vibrate([300, 100, 300])
163-
// Play audio async; only cancel SW notification if page audio succeeds
164-
// (otherwise the SW notification fires as fallback alarm)
165-
if (dingEnabled) {
166-
playDing().then(played => { if (played) cancelSwNotif() }).catch(() => {})
167-
}
181+
if (dingEnabled) playDing().catch(() => {})
168182
}
169183

170184
// Tick every 250ms — recomputes from absolute end time, handles finish itself

frontend/src/api.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,63 @@ export async function getExerciseHistory(exerciseId, profileId, limit = 30) {
266266
const res = await authFetch(base + `/api/exercises/${exerciseId}/history?${q}`)
267267
return res.json()
268268
}
269+
270+
// ── Web Push ──
271+
272+
export async function getPushVapidKey() {
273+
const res = await authFetch(base + '/api/push/vapid-public-key')
274+
const data = await res.json()
275+
return data.publicKey
276+
}
277+
278+
export async function subscribePush(profileId) {
279+
if (!('PushManager' in window) || !navigator.serviceWorker) return false
280+
try {
281+
const publicKey = await getPushVapidKey()
282+
if (!publicKey) return false
283+
const reg = await navigator.serviceWorker.ready
284+
// Convert base64url to Uint8Array for applicationServerKey
285+
const padding = '='.repeat((4 - publicKey.length % 4) % 4)
286+
const base64 = (publicKey + padding).replace(/-/g, '+').replace(/_/g, '/')
287+
const rawKey = Uint8Array.from(atob(base64), c => c.charCodeAt(0))
288+
const sub = await reg.pushManager.subscribe({
289+
userVisibleOnly: true,
290+
applicationServerKey: rawKey,
291+
})
292+
const json = sub.toJSON()
293+
await authFetch(base + '/api/push/subscribe', {
294+
method: 'POST',
295+
headers: { 'Content-Type': 'application/json' },
296+
body: JSON.stringify({
297+
profileId,
298+
endpoint: json.endpoint,
299+
p256dh: json.keys.p256dh,
300+
auth: json.keys.auth,
301+
}),
302+
})
303+
return true
304+
} catch (e) {
305+
console.warn('[push] subscribe failed', e)
306+
return false
307+
}
308+
}
309+
310+
export async function schedulePush(profileId, delayMs, title, body) {
311+
try {
312+
await authFetch(base + '/api/push/schedule', {
313+
method: 'POST',
314+
headers: { 'Content-Type': 'application/json' },
315+
body: JSON.stringify({ profileId, delayMs, title, body }),
316+
})
317+
} catch (_) {}
318+
}
319+
320+
export async function cancelPush(profileId) {
321+
try {
322+
await authFetch(base + '/api/push/cancel', {
323+
method: 'POST',
324+
headers: { 'Content-Type': 'application/json' },
325+
body: JSON.stringify({ profileId }),
326+
})
327+
} catch (_) {}
328+
}

0 commit comments

Comments
 (0)