feat(push): smart push queueing with page-visibility fast path and app-ping/pong fallback

This commit is contained in:
2026-06-04 22:10:48 -04:00
parent fc0527e9e7
commit 4e6dfb4726
16 changed files with 192 additions and 60 deletions
+2 -15
View File
@@ -249,22 +249,9 @@ ensure_server() {
ensure_server ensure_server
# Authenticate with the ClawTap server API # Localhost requests are trusted by the server — no token needed
get_auth_token() {
local BODY
BODY=$(printf '%s' "$CLAWTAP_PASSWORD" | python3 -c 'import sys,json; print(json.dumps({"password": sys.stdin.read()}))' 2>/dev/null)
curl -sk -X POST "${PROTOCOL}://localhost:${PORT}/api/auth/login" \
-H "Content-Type: application/json" \
-d "$BODY" 2>/dev/null | \
python3 -c 'import sys,json; print(json.load(sys.stdin).get("token",""))' 2>/dev/null
}
require_auth() { require_auth() {
AUTH_TOKEN=$(get_auth_token) AUTH_TOKEN=""
if [ -z "$AUTH_TOKEN" ]; then
echo "Error: Failed to authenticate with ClawTap server"
exit 1
fi
} }
# No args → just start server, print URLs, exit # No args → just start server, print URLs, exit
+2
View File
@@ -123,6 +123,8 @@ export class ClaudeAdapter extends IAdapter {
hookRoute(`${prefix}/stop`, (body) => { hookRoute(`${prefix}/stop`, (body) => {
this._tmux.handleStop(body); this._tmux.handleStop(body);
}); });
// SubagentStop (subagent finishes mid-turn) — no-op, main Stop handles turn end
hookRoute(`${prefix}/subagent-stop`, (_body) => {});
hookRoute(`${prefix}/permission-request`, (body) => { hookRoute(`${prefix}/permission-request`, (body) => {
this._tmux.handlePermissionRequest(body); this._tmux.handlePermissionRequest(body);
}); });
+6
View File
@@ -68,6 +68,12 @@ export function verifyToken(token: string): Record<string, unknown> | null {
} }
export function authMiddleware(req: Request, res: Response, next: NextFunction): void { export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const remoteAddr = req.socket.remoteAddress;
if (remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1') {
next();
return;
}
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
+22 -5
View File
@@ -21,6 +21,7 @@ export function initDB(config: AppConfig): void {
endpoint TEXT PRIMARY KEY, endpoint TEXT PRIMARY KEY,
p256dh TEXT NOT NULL, p256dh TEXT NOT NULL,
auth TEXT NOT NULL, auth TEXT NOT NULL,
device_id TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
last_used TEXT last_used TEXT
); );
@@ -80,6 +81,13 @@ export function initDB(config: AppConfig): void {
db.exec("ALTER TABLE session_reviews ADD COLUMN parent_adapter TEXT NOT NULL DEFAULT 'claude'"); db.exec("ALTER TABLE session_reviews ADD COLUMN parent_adapter TEXT NOT NULL DEFAULT 'claude'");
} }
// Migration: add device_id column to push_subscriptions if missing
const pushInfo = db.pragma('table_info(push_subscriptions)') as { name: string }[];
if (!pushInfo.some(c => c.name === 'device_id')) {
db.exec("ALTER TABLE push_subscriptions ADD COLUMN device_id TEXT DEFAULT NULL");
}
db.exec("CREATE INDEX IF NOT EXISTS idx_push_device ON push_subscriptions(device_id)");
// Migration: add end_anchor_message_id column to session_reviews if missing // Migration: add end_anchor_message_id column to session_reviews if missing
if (!reviewInfo.some(c => c.name === 'end_anchor_message_id')) { if (!reviewInfo.some(c => c.name === 'end_anchor_message_id')) {
db.exec("ALTER TABLE session_reviews ADD COLUMN end_anchor_message_id TEXT DEFAULT NULL"); db.exec("ALTER TABLE session_reviews ADD COLUMN end_anchor_message_id TEXT DEFAULT NULL");
@@ -109,6 +117,7 @@ function getDB(): BetterSqlite3.Database {
interface PreparedStatements { interface PreparedStatements {
pushSubsSave: BetterSqlite3.Statement; pushSubsSave: BetterSqlite3.Statement;
pushSubsRemoveByDevice: BetterSqlite3.Statement;
pushSubsRemove: BetterSqlite3.Statement; pushSubsRemove: BetterSqlite3.Statement;
pushSubsGetAll: BetterSqlite3.Statement; pushSubsGetAll: BetterSqlite3.Statement;
pushSubsMarkUsed: BetterSqlite3.Statement; pushSubsMarkUsed: BetterSqlite3.Statement;
@@ -139,12 +148,16 @@ function stmts(): PreparedStatements {
_stmts = { _stmts = {
// push_subscriptions // push_subscriptions
pushSubsSave: d.prepare(` pushSubsSave: d.prepare(`
INSERT INTO push_subscriptions (endpoint, p256dh, auth) INSERT INTO push_subscriptions (endpoint, p256dh, auth, device_id)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET ON CONFLICT(endpoint) DO UPDATE SET
p256dh = excluded.p256dh, p256dh = excluded.p256dh,
auth = excluded.auth auth = excluded.auth,
device_id = excluded.device_id
`), `),
pushSubsRemoveByDevice: d.prepare(
`DELETE FROM push_subscriptions WHERE device_id = ? AND endpoint != ?`
),
pushSubsRemove: d.prepare( pushSubsRemove: d.prepare(
`DELETE FROM push_subscriptions WHERE endpoint = ?` `DELETE FROM push_subscriptions WHERE endpoint = ?`
), ),
@@ -243,13 +256,17 @@ export interface PushSubRow {
endpoint: string; endpoint: string;
p256dh: string; p256dh: string;
auth: string; auth: string;
device_id: string | null;
created_at: string; created_at: string;
last_used: string | null; last_used: string | null;
} }
export const pushSubs = { export const pushSubs = {
save(endpoint: string, p256dh: string, auth: string): void { save(endpoint: string, p256dh: string, auth: string, deviceId: string | null): void {
stmts().pushSubsSave.run(endpoint, p256dh, auth); stmts().pushSubsSave.run(endpoint, p256dh, auth, deviceId ?? null);
if (deviceId) {
stmts().pushSubsRemoveByDevice.run(deviceId, endpoint);
}
}, },
remove(endpoint: string): void { remove(endpoint: string): void {
+2 -2
View File
@@ -394,9 +394,9 @@ async function start(): Promise<void> {
}); });
app.post('/api/push/subscribe', authMiddleware, (req: Request, res: Response) => { app.post('/api/push/subscribe', authMiddleware, (req: Request, res: Response) => {
const { subscription } = req.body as { subscription?: { endpoint?: string } }; const { subscription, deviceId } = req.body as { subscription?: { endpoint?: string }; deviceId?: string };
if (!subscription?.endpoint) return res.status(400).json({ error: 'Missing subscription' }); if (!subscription?.endpoint) return res.status(400).json({ error: 'Missing subscription' });
saveSubscription(subscription as any); saveSubscription(subscription as any, deviceId);
res.json({ ok: true }); res.json({ ok: true });
}); });
+26 -11
View File
@@ -5,6 +5,7 @@ import { pushSubs as dbPushSubs, type PushSubRow } from './db.js';
interface PushSubscriptionEntry { interface PushSubscriptionEntry {
endpoint: string; endpoint: string;
deviceId?: string;
subscription: { subscription: {
endpoint: string; endpoint: string;
keys: { p256dh: string; auth: string }; keys: { p256dh: string; auth: string };
@@ -33,7 +34,7 @@ export function initPush(config: AppConfig): void {
console.log('[push] Generated new VAPID keys'); console.log('[push] Generated new VAPID keys');
} }
const email = process.env.VAPID_EMAIL || 'noreply@clawtap.local'; const email = process.env.VAPID_EMAIL || 'izackp@gmail.com';
cachedVapidPublicKey = vapidKeys.publicKey; cachedVapidPublicKey = vapidKeys.publicKey;
webpush.setVapidDetails(`mailto:${email}`, vapidKeys.publicKey, vapidKeys.privateKey); webpush.setVapidDetails(`mailto:${email}`, vapidKeys.publicKey, vapidKeys.privateKey);
@@ -41,6 +42,7 @@ export function initPush(config: AppConfig): void {
const rows = dbPushSubs.getAll(); const rows = dbPushSubs.getAll();
subscriptions = rows.map(row => ({ subscriptions = rows.map(row => ({
endpoint: row.endpoint, endpoint: row.endpoint,
deviceId: row.device_id ?? undefined,
subscription: { subscription: {
endpoint: row.endpoint, endpoint: row.endpoint,
keys: { p256dh: row.p256dh, auth: row.auth }, keys: { p256dh: row.p256dh, auth: row.auth },
@@ -54,12 +56,16 @@ export function getVapidPublicKey(): string | null {
return cachedVapidPublicKey; return cachedVapidPublicKey;
} }
export function saveSubscription(subscription: PushSubscriptionEntry['subscription']): void { export function saveSubscription(subscription: PushSubscriptionEntry['subscription'], deviceId?: string): void {
// Save to SQLite // Save to SQLite — removes old endpoints for same device_id
dbPushSubs.save(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth); dbPushSubs.save(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, deviceId ?? null);
// Update in-memory cache // Update in-memory cache: remove old entries for same device, add new
if (deviceId) {
subscriptions = subscriptions.filter(s => s.endpoint === subscription.endpoint || !s.deviceId || s.deviceId !== deviceId);
} else {
subscriptions = subscriptions.filter(s => s.endpoint !== subscription.endpoint); subscriptions = subscriptions.filter(s => s.endpoint !== subscription.endpoint);
subscriptions.push({ endpoint: subscription.endpoint, subscription }); }
subscriptions.push({ endpoint: subscription.endpoint, subscription, deviceId });
} }
export function removeSubscription(endpoint: string): void { export function removeSubscription(endpoint: string): void {
@@ -89,27 +95,36 @@ export function getPendingSessions(): Record<string, number> {
} }
export async function sendPush(payload: unknown): Promise<void> { export async function sendPush(payload: unknown): Promise<void> {
if (subscriptions.length === 0) return; if (subscriptions.length === 0) {
console.log('[push] sendPush: no subscriptions registered, skipping');
return;
}
console.log(`[push] sendPush: sending to ${subscriptions.length} subscription(s)`);
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
const expired: string[] = []; const expired: string[] = [];
let errorCount = 0;
await Promise.allSettled( await Promise.allSettled(
subscriptions.map(async ({ endpoint, subscription }) => { subscriptions.map(async ({ endpoint, subscription }) => {
try { try {
await webpush.sendNotification(subscription, body); await webpush.sendNotification(subscription, body);
} catch (err) { } catch (err) {
const e = err as { statusCode?: number; message?: string }; const e = err as { statusCode?: number; message?: string; body?: unknown };
if (e.statusCode === 410 || e.statusCode === 404) { const bodyReason = (() => { try { return JSON.parse(e.body as string)?.reason; } catch { return null; } })();
// Subscription expired — mark for removal if (e.statusCode === 410 || e.statusCode === 404 || bodyReason === 'BadJwtToken') {
expired.push(endpoint); expired.push(endpoint);
} else { } else {
console.error(`[push] Failed to send to ${endpoint.slice(0, 50)}:`, e.message); errorCount++;
console.error(`[push] Failed to send to ${endpoint.slice(0, 50)}: status=${e.statusCode} msg=${e.message} body=${JSON.stringify(e.body)}`);
} }
} }
}) })
); );
const ok = subscriptions.length - expired.length - errorCount;
console.log(`[push] sendPush: done (${ok} ok, ${expired.length} expired, ${errorCount} failed)`);
// Clean up expired subscriptions // Clean up expired subscriptions
if (expired.length > 0) { if (expired.length > 0) {
for (const ep of expired) { for (const ep of expired) {
+68 -12
View File
@@ -16,14 +16,12 @@ interface PushOptions {
tagPrefix: string; tagPrefix: string;
} }
/** Send a push notification for a session event — only if nobody is viewing this session. */ /** Pending push timers: sessionId → timeout handle. Cancelled if client pongs within 2s. */
function triggerPush(adapter: IAdapter, sessionId: string, { title, body, tagPrefix }: PushOptions): void { const pendingPushes = new Map<string, ReturnType<typeof setTimeout>>();
const clients = sessionClients.get(sessionId);
if (clients && clients.size > 0) return;
// Skip push for child review sessions
if (sessionReviews.getAllChildIds().has(sessionId)) return;
/** Actually send the push notification. */
function firePush(adapter: IAdapter, sessionId: string, { title, body, tagPrefix }: PushOptions): void {
console.log(`[push] firePush: "${title}" for session ${sessionId.slice(0, 8)}`);
const session = adapter.getSession(sessionId) as { cwd?: string } | null; const session = adapter.getSession(sessionId) as { cwd?: string } | null;
const projectName = basename(session?.cwd || '') || 'Unknown'; const projectName = basename(session?.cwd || '') || 'Unknown';
const badge = incrementPending(sessionId); const badge = incrementPending(sessionId);
@@ -35,6 +33,49 @@ function triggerPush(adapter: IAdapter, sessionId: string, { title, body, tagPre
}).catch((err: Error) => console.error('[push]', err.message)); }).catch((err: Error) => console.error('[push]', err.message));
} }
/**
* Queue a push notification for a session event.
*
* Fast path — no clients, or all clients reported hidden via page-visibility:
* send immediately.
* Fallback — some client is visible or visibility unknown:
* broadcast an app-ping; if any client JS responds with app-pong within 2s
* the notification is dropped (user is watching). Otherwise send after 2s.
*/
function queuePush(adapter: IAdapter, sessionId: string, opts: PushOptions): void {
// Skip push for child review sessions
if (sessionReviews.getAllChildIds().has(sessionId)) return;
// Cancel any pre-existing pending push for this session
const existing = pendingPushes.get(sessionId);
if (existing) clearTimeout(existing);
const clients = sessionClients.get(sessionId);
// No clients connected at all — send immediately
if (!clients || clients.size === 0) {
console.log(`[push] queuePush: no clients → immediate push`);
firePush(adapter, sessionId, opts);
return;
}
// All clients already reported page hidden — send immediately (fast path)
if ([...clients].every(c => !c.pageVisible)) {
console.log(`[push] queuePush: all clients hidden → immediate push`);
firePush(adapter, sessionId, opts);
return;
}
// Some client may be visible — ping JS and wait up to 2s for a pong response
console.log(`[push] queuePush: client(s) may be visible (pageVisible=${[...clients].map(c => c.pageVisible).join(',')}) → pinging, push in 2s if no pong`);
broadcast(sessionId, { type: WS.APP_PING, sessionId });
const timer = setTimeout(() => {
pendingPushes.delete(sessionId);
firePush(adapter, sessionId, opts);
}, 2000);
pendingPushes.set(sessionId, timer);
}
/** /**
* SessionManager — bridges adapter events to connected clients. * SessionManager — bridges adapter events to connected clients.
* *
@@ -122,7 +163,7 @@ export function setupSessionManager(): void {
setTimeout(() => { setTimeout(() => {
broadcast(sessionId, { type: WS.TURN_COMPLETE, sessionId }); broadcast(sessionId, { type: WS.TURN_COMPLETE, sessionId });
}, 100); }, 100);
triggerPush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' }); queuePush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' });
}); });
adapter.on('permission-request', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => { adapter.on('permission-request', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
@@ -141,7 +182,7 @@ export function setupSessionManager(): void {
{ value: 'deny', label: 'Deny' }, { value: 'deny', label: 'Deny' },
], ],
}); });
triggerPush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' }); queuePush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' });
}); });
adapter.on('ask-question', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => { adapter.on('ask-question', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
@@ -157,7 +198,7 @@ export function setupSessionManager(): void {
options: parsed.options, options: parsed.options,
textInput: parsed.options ? undefined : { placeholder: 'Enter your response...' }, textInput: parsed.options ? undefined : { placeholder: 'Enter your response...' },
}); });
triggerPush(adapter, sessionId, { title: 'Question', body: questionText.substring(0, 50) || 'Waiting for answer', tagPrefix: 'ask' }); queuePush(adapter, sessionId, { title: 'Question', body: (parsed.question || '').substring(0, 50) || 'Waiting for answer', tagPrefix: 'ask' });
}); });
adapter.on('interactive-prompt', (sessionId: string, prompt: any) => { adapter.on('interactive-prompt', (sessionId: string, prompt: any) => {
@@ -166,7 +207,7 @@ export function setupSessionManager(): void {
: prompt.promptType === 'question' ? 'Question' : prompt.promptType === 'question' ? 'Question'
: prompt.promptType === 'plan' ? 'Plan approval' : prompt.promptType === 'plan' ? 'Plan approval'
: 'Action needed'; : 'Action needed';
triggerPush(adapter, sessionId, { title: pushTitle, body: prompt.title || '', tagPrefix: 'prompt' }); queuePush(adapter, sessionId, { title: pushTitle, body: prompt.title || '', tagPrefix: 'prompt' });
}); });
adapter.on('status-update', (sessionId: string, status: Record<string, unknown>) => { adapter.on('status-update', (sessionId: string, status: Record<string, unknown>) => {
@@ -205,7 +246,7 @@ export function setupSessionManager(): void {
adapter.on('session-error', (sessionId: string, data: { errorType?: string; errorDetails?: string; [key: string]: unknown }) => { adapter.on('session-error', (sessionId: string, data: { errorType?: string; errorDetails?: string; [key: string]: unknown }) => {
broadcast(sessionId, { type: WS.SESSION_ERROR, ...data }); broadcast(sessionId, { type: WS.SESSION_ERROR, ...data });
triggerPush(adapter, sessionId, { queuePush(adapter, sessionId, {
title: 'Session Error', title: 'Session Error',
body: data.errorType === 'rate_limit' ? 'Rate limited' : (data.errorDetails || data.errorType || 'Unknown error'), body: data.errorType === 'rate_limit' ? 'Rate limited' : (data.errorDetails || data.errorType || 'Unknown error'),
tagPrefix: 'error', tagPrefix: 'error',
@@ -359,6 +400,21 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM
selectedOption: msg.selectedOption as string | undefined, selectedOption: msg.selectedOption as string | undefined,
textValue: msg.textValue as string | undefined, textValue: msg.textValue as string | undefined,
}); });
case WS.PAGE_VISIBILITY: {
conn.pageVisible = !!(msg as any).visible;
return;
}
case WS.APP_PONG: {
const sid = (msg as any).sessionId as string | undefined;
if (sid) {
const timer = pendingPushes.get(sid);
if (timer) {
clearTimeout(timer);
pendingPushes.delete(sid);
}
}
return;
}
default: default:
conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` }); conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` });
} }
+2
View File
@@ -12,6 +12,8 @@ export abstract class ClientConnection {
readonly transportName: string; readonly transportName: string;
sessionId: string | null = null; sessionId: string | null = null;
onDisconnect: ((conn: ClientConnection) => void) | null = null; onDisconnect: ((conn: ClientConnection) => void) | null = null;
/** True while the client tab/app is in the foreground. Starts true (assume visible until told otherwise). */
pageVisible: boolean = true;
constructor(transportName: string) { constructor(transportName: string) {
this.transportName = transportName; this.transportName = transportName;
+12 -3
View File
@@ -18,6 +18,9 @@ import type { ClientMessage } from '../types/messages.js';
export class WebSocketTransport extends EventEmitter { export class WebSocketTransport extends EventEmitter {
private wss: WebSocketServer | null = null; private wss: WebSocketServer | null = null;
private pingInterval: ReturnType<typeof setInterval> | null = null; private pingInterval: ReturnType<typeof setInterval> | null = null;
// Tracks whether each WS responded to the last ping. Initialized true so a
// connection is never terminated before it has a chance to respond.
private alive = new WeakMap<WebSocket, boolean>();
/** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */ /** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */
setup(server: HttpServer | HttpsServer): void { setup(server: HttpServer | HttpsServer): void {
@@ -36,6 +39,9 @@ export class WebSocketTransport extends EventEmitter {
}); });
this.wss.on('connection', (ws: WebSocket) => { this.wss.on('connection', (ws: WebSocket) => {
this.alive.set(ws, true);
ws.on('pong', () => this.alive.set(ws, true));
const conn = new WebSocketConnection(ws); const conn = new WebSocketConnection(ws);
this.emit('connection', conn); this.emit('connection', conn);
@@ -52,13 +58,16 @@ export class WebSocketTransport extends EventEmitter {
}); });
}); });
// Ping/pong keepalive every 30s // Ping/pong keepalive every 30s — terminate connections that miss a pong.
this.pingInterval = setInterval(() => { this.pingInterval = setInterval(() => {
if (!this.wss) return; if (!this.wss) return;
for (const ws of this.wss.clients) { for (const ws of this.wss.clients) {
if (ws.readyState === 1) { if (!this.alive.get(ws)) {
ws.ping(); ws.terminate(); // no pong since last ping — dead connection
continue;
} }
this.alive.set(ws, false);
if (ws.readyState === 1) ws.ping();
} }
}, 30_000); }, 30_000);
this.pingInterval.unref(); this.pingInterval.unref();
+2 -1
View File
@@ -14,7 +14,8 @@ export interface ServerMessage {
export type ClientMessageType = export type ClientMessageType =
| 'query' | 'permission-response' | 'ask-response' | 'abort' | 'query' | 'permission-response' | 'ask-response' | 'abort'
| 'reconnect' | 'set-permission-mode' | 'plan-response'; | 'reconnect' | 'set-permission-mode' | 'plan-response'
| 'page-visibility' | 'app-pong';
export interface ClientMessage { export interface ClientMessage {
type: ClientMessageType; type: ClientMessageType;
+4
View File
@@ -8,6 +8,8 @@ export const WS = {
SET_PERMISSION_MODE: 'set-permission-mode', SET_PERMISSION_MODE: 'set-permission-mode',
SET_MODEL: 'set-model', SET_MODEL: 'set-model',
PLAN_RESPONSE: 'plan-response', PLAN_RESPONSE: 'plan-response',
PAGE_VISIBILITY: 'page-visibility',
APP_PONG: 'app-pong',
// Server → Client // Server → Client
SESSION_STATE: 'session-state', SESSION_STATE: 'session-state',
SESSION_CREATED: 'session-created', SESSION_CREATED: 'session-created',
@@ -39,6 +41,8 @@ export const WS = {
REVIEW_ENDED: 'review-ended', REVIEW_ENDED: 'review-ended',
// Task Progress // Task Progress
TASK_STATE: 'task-state', TASK_STATE: 'task-state',
// Push notification coordination
APP_PING: 'app-ping',
} as const; } as const;
export type WsType = typeof WS[keyof typeof WS]; export type WsType = typeof WS[keyof typeof WS];
+11 -3
View File
@@ -116,10 +116,18 @@ export function App() {
}, [dismissInstall]); }, [dismissInstall]);
// PWA: Service worker update notification // PWA: Service worker update notification
// Only show banner when replacing an existing controller (real update),
// not on first registration when clients.claim() fires controllerchange
// on a previously uncontrolled page.
useEffect(() => { useEffect(() => {
const handleControllerChange = () => setSwUpdateAvailable(true); if (!navigator.serviceWorker) return;
navigator.serviceWorker?.addEventListener('controllerchange', handleControllerChange); let hadController = !!navigator.serviceWorker.controller;
return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange); const handleControllerChange = () => {
if (hadController) setSwUpdateAvailable(true);
hadController = true;
};
navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange);
return () => navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
}, []); }, []);
// PWA iOS: Sync --app-height CSS variable to the true viewport height. // PWA iOS: Sync --app-height CSS variable to the true viewport height.
+12 -2
View File
@@ -12,6 +12,16 @@ function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
return outputArray.buffer as ArrayBuffer; return outputArray.buffer as ArrayBuffer;
} }
function getOrCreateDeviceId(): string {
const key = 'clawtap_device_id';
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
export function usePushNotifications() { export function usePushNotifications() {
const [permission, setPermission] = useState<NotificationPermission>( const [permission, setPermission] = useState<NotificationPermission>(
typeof Notification !== 'undefined' ? Notification.permission : 'denied' typeof Notification !== 'undefined' ? Notification.permission : 'denied'
@@ -54,8 +64,8 @@ export function usePushNotifications() {
}); });
} }
// Send subscription to server // Send subscription to server with stable device ID
await api.pushSubscribe(sub.toJSON()); await api.pushSubscribe(sub.toJSON(), getOrCreateDeviceId());
setSubscribed(true); setSubscribed(true);
return true; return true;
}, [supported]); }, [supported]);
+2 -2
View File
@@ -109,10 +109,10 @@ export const api = {
vapidPublicKey: () => vapidPublicKey: () =>
request<{ publicKey: string }>('/api/push/vapid-public-key'), request<{ publicKey: string }>('/api/push/vapid-public-key'),
pushSubscribe: (subscription: PushSubscriptionJSON) => pushSubscribe: (subscription: PushSubscriptionJSON, deviceId: string) =>
request<{ ok: boolean }>('/api/push/subscribe', { request<{ ok: boolean }>('/api/push/subscribe', {
method: 'POST', method: 'POST',
body: JSON.stringify({ subscription }), body: JSON.stringify({ subscription, deviceId }),
}), }),
pushUnsubscribe: (endpoint: string) => pushUnsubscribe: (endpoint: string) =>
+4
View File
@@ -8,6 +8,8 @@ export const WS = {
SET_PERMISSION_MODE: 'set-permission-mode', SET_PERMISSION_MODE: 'set-permission-mode',
SET_MODEL: 'set-model', SET_MODEL: 'set-model',
PLAN_RESPONSE: 'plan-response', PLAN_RESPONSE: 'plan-response',
PAGE_VISIBILITY: 'page-visibility',
APP_PONG: 'app-pong',
// Server → Client // Server → Client
SESSION_CREATED: 'session-created', SESSION_CREATED: 'session-created',
TEXT_DELTA: 'text-delta', TEXT_DELTA: 'text-delta',
@@ -39,6 +41,8 @@ export const WS = {
REVIEW_ENDED: 'review-ended', REVIEW_ENDED: 'review-ended',
// Task Progress // Task Progress
TASK_STATE: 'task-state', TASK_STATE: 'task-state',
// Push notification coordination
APP_PING: 'app-ping',
} as const; } as const;
/** /**
+14 -3
View File
@@ -41,6 +41,8 @@ export class WsClient {
if (this.activeSessionId) { if (this.activeSessionId) {
this.send({ type: WS.RECONNECT, sessionId: this.activeSessionId, adapter: this.activeAdapter }); this.send({ type: WS.RECONNECT, sessionId: this.activeSessionId, adapter: this.activeAdapter });
} }
// Tell server our current visibility state immediately on (re)connect
this.send({ type: WS.PAGE_VISIBILITY, visible: document.visibilityState === 'visible' });
}; };
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
@@ -50,6 +52,14 @@ export class WsClient {
this.activeSessionId = msg.sessionId; this.activeSessionId = msg.sessionId;
if (msg.adapter) this.activeAdapter = msg.adapter; if (msg.adapter) this.activeAdapter = msg.adapter;
} }
// Respond to server app-ping only when page is visible.
// If hidden, no pong → server fires push after 2s timeout.
if (msg.type === WS.APP_PING) {
if (document.visibilityState === 'visible') {
this.send({ type: WS.APP_PONG, sessionId: msg.sessionId });
}
return;
}
this.onMessage(msg); this.onMessage(msg);
} catch (err) { } catch (err) {
console.error('[ws] Failed to parse message:', err); console.error('[ws] Failed to parse message:', err);
@@ -109,12 +119,13 @@ export class WsClient {
private _startVisibilityWatch() { private _startVisibilityWatch() {
if (this.visibilityHandler) return; if (this.visibilityHandler) return;
this.visibilityHandler = () => { this.visibilityHandler = () => {
if (document.visibilityState === 'hidden') { const visible = document.visibilityState === 'visible';
this.send({ type: WS.PAGE_VISIBILITY, visible });
if (!visible) {
this.hiddenSince = Date.now(); this.hiddenSince = Date.now();
} else if (document.visibilityState === 'visible' && this.shouldReconnect) { } else if (this.shouldReconnect) {
const elapsed = this.hiddenSince ? Date.now() - this.hiddenSince : 0; const elapsed = this.hiddenSince ? Date.now() - this.hiddenSince : 0;
this.hiddenSince = null; this.hiddenSince = null;
// Only force reconnect if page was hidden long enough for the WS to go stale
if (elapsed >= VISIBILITY_RECONNECT_THRESHOLD) { if (elapsed >= VISIBILITY_RECONNECT_THRESHOLD) {
this.forceReconnect(); this.forceReconnect();
} }