diff --git a/README.md b/README.md index 780ef6e..cef55fe 100644 --- a/README.md +++ b/README.md @@ -1328,6 +1328,28 @@ Companion runtime helper: - runtime observability/control passthroughs (`pendingRequestCount`, `pendingEventWaitCount`, `hasPendingWork`, `idle`, `lastDisconnectCode`, `lastDisconnectReason`, `getPendingWorkSnapshot()`, `getEventSurfaceSnapshot()`, `getConnectionSnapshot()`, `connected`, `waitForIdle()`) - `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load (with safe normalization for invalid random samples), `tickNow()` for manual sends, success/error hooks, loop observability (`successCount`, `lastSuccessAt`, `failureCount`, `lastFailure`, `getState()`), and optional auto-stop after repeated failures. +## WebChat PWA Push Subscriptions + +Enable installable WebChat PWA metadata and browser push-subscription storage on the gateway: + +```yaml +server: + webchat_push: + enabled: true + vapid_public_key: ${WEBCHAT_VAPID_PUBLIC_KEY} + max_subscriptions: 5000 +``` + +Notes: +- WebChat now serves `manifest.webmanifest` and a service worker (`/sw.js`). +- Settings page includes Push controls (Enable/Disable) that subscribe the current browser and register/unregister its endpoint with the gateway. +- Gateway endpoints: + - `GET /webchat/push/public-key` + - `GET /webchat/push/subscriptions` + - `POST /webchat/push/subscriptions` + - `DELETE /webchat/push/subscriptions` +- These endpoints are protected by normal gateway HTTP auth (`server.token` + `server.auth_http`) and support `?token=` query auth for browser clients. + ## Canvas / A2UI Foundation Gateway provides a session-scoped canvas artifact API for companion/UI surfaces: diff --git a/config/default.yaml b/config/default.yaml index 68f1985..84f5860 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -115,6 +115,12 @@ server: enabled: false push: enabled: false + # Optional WebChat push subscription endpoints for PWA notifications. + # Set `enabled: true` and provide a VAPID public key for PushManager. + webchat_push: + enabled: false + # vapid_public_key: ${WEBCHAT_VAPID_PUBLIC_KEY} + max_subscriptions: 5000 # Local-network service discovery (mDNS/Bonjour). Keep disabled by default. # Requires server.localhost: false so LAN clients can actually connect. discovery: diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 2f3fd0a..7f9efb6 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -158,6 +158,12 @@ Exceptions (handled by their own trust/auth model and therefore bypass gateway t - `POST /google-chat/events` (Google Chat event callback, optional webhook token check) - `POST /bluebubbles/events` (BlueBubbles iMessage webhook callback, optional webhook token check) +WebChat PWA push-subscription endpoints (auth-protected): +- `GET /webchat/push/public-key` (returns enabled/configured push metadata) +- `GET /webchat/push/subscriptions` (returns current subscription count/cap) +- `POST /webchat/push/subscriptions` (registers/updates one browser subscription) +- `DELETE /webchat/push/subscriptions` (removes one browser subscription by endpoint) + ## Message Format ### Request (Client → Server) diff --git a/docs/plans/state.json b/docs/plans/state.json index 48fc1c1..c4f3632 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5323,10 +5323,36 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/commands/builtin/index.test.ts src/daemon/routing.test.ts + pnpm typecheck passing" + }, + "webchat-pwa-push-subscriptions-tier-b3": { + "status": "completed", + "date": "2026-02-18", + "updated": "2026-02-18", + "summary": "Implemented Tier B3 WebChat PWA baseline with service worker + manifest, browser push enable/disable controls in WebChat settings, and authenticated gateway subscription endpoints (`/webchat/push/*`) for storing/removing browser push subscriptions with VAPID public-key discovery.", + "files_modified": [ + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/daemon/services.ts", + "src/gateway/server.ts", + "src/gateway/server.test.ts", + "src/gateway/static.ts", + "src/gateway/ui/index.html", + "src/gateway/ui/app.js", + "src/gateway/ui/sw.js", + "src/gateway/ui/manifest.webmanifest", + "src/gateway/ui/flynn-icon.svg", + "src/gateway/ui/lib/pwa.js", + "src/gateway/ui/pages/settings.js", + "config/default.yaml", + "README.md", + "docs/api/PROTOCOL.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/gateway/server.test.ts src/config/schema.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 1911, + "total_test_count": 1913, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -5346,7 +5372,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Implement Tier B3 progressive web app push notifications for WebChat" + "next_up": "Implement Tier B1 guided onboarding improvement" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 4ed5ec2..bcb44b3 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -147,6 +147,13 @@ describe('configSchema — server', () => { expect(result.server.nodes.push.enabled).toBe(false); }); + it('defaults webchat push settings', () => { + const result = configSchema.parse(minimalConfig); + expect(result.server.webchat_push.enabled).toBe(false); + expect(result.server.webchat_push.vapid_public_key).toBeUndefined(); + expect(result.server.webchat_push.max_subscriptions).toBe(5000); + }); + it('accepts custom node policy settings', () => { const result = configSchema.parse({ ...minimalConfig, @@ -194,6 +201,22 @@ describe('configSchema — server', () => { expect(result.server.discovery.service_type).toBe('_custom._tcp'); expect(result.server.discovery.txt).toEqual({ env: 'dev' }); }); + + it('accepts custom webchat push settings', () => { + const result = configSchema.parse({ + ...minimalConfig, + server: { + webchat_push: { + enabled: true, + vapid_public_key: 'BOrSAMPLEPUBLICKEY____', + max_subscriptions: 42, + }, + }, + }); + expect(result.server.webchat_push.enabled).toBe(true); + expect(result.server.webchat_push.vapid_public_key).toBe('BOrSAMPLEPUBLICKEY____'); + expect(result.server.webchat_push.max_subscriptions).toBe(42); + }); }); describe('configSchema — browser', () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 4018720..1d1ea80 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -79,6 +79,15 @@ const serverDiscoverySchema = z.object({ txt: z.record(z.string(), z.string()).default({}), }).default({}); +const serverWebchatPushSchema = z.object({ + /** Enable WebChat web-push subscription endpoints and PWA metadata. */ + enabled: z.boolean().default(false), + /** VAPID public key used by browser PushManager.subscribe(). */ + vapid_public_key: z.string().optional(), + /** Soft cap for stored web-push subscriptions. */ + max_subscriptions: z.number().min(1).max(50_000).default(5000), +}).default({}); + const serverNodePolicySchema = z.object({ /** Enable node registration/capability RPC surface. */ enabled: z.boolean().default(false), @@ -118,6 +127,8 @@ const serverSchema = z.object({ queue: laneQueueSchema, /** Optional companion-node registration/capability settings. */ nodes: serverNodePolicySchema, + /** Optional WebChat PWA push-subscription settings. */ + webchat_push: serverWebchatPushSchema, /** Optional Bonjour/mDNS advertisement settings. */ discovery: serverDiscoverySchema, }); diff --git a/src/daemon/services.ts b/src/daemon/services.ts index 71a534a..06a6fa4 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -361,6 +361,11 @@ export function createGateway(deps: GatewayDeps): GatewayServer { locationEnabled: config.server.nodes.location.enabled, pushEnabled: config.server.nodes.push.enabled, }, + webchatPush: { + enabled: config.server.webchat_push.enabled, + vapidPublicKey: config.server.webchat_push.vapid_public_key, + maxSubscriptions: config.server.webchat_push.max_subscriptions, + }, discovery: { enabled: config.server.discovery.enabled, serviceName: config.server.discovery.service_name, diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 3229d77..fd00408 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -554,6 +554,99 @@ describe('GatewayServer request body limits', () => { }); }); +describe('GatewayServer WebChat push endpoints', () => { + const PUSH_PORT = 18894; + let pushServer: GatewayServer; + + beforeAll(async () => { + if (!LISTEN_ALLOWED) { + return; + } + pushServer = new GatewayServer({ + port: PUSH_PORT, + sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], + modelClient: mockModelClient, + systemPrompt: 'Test prompt', + toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], + toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], + auth: { token: 'push-secret' }, + authHttp: true, + uiDir: resolve(import.meta.dirname, 'ui'), + webchatPush: { + enabled: true, + vapidPublicKey: 'BO_test_public_key', + maxSubscriptions: 2, + }, + }); + await pushServer.start(); + }); + + afterAll(async () => { + if (!LISTEN_ALLOWED) { + return; + } + await pushServer.stop(); + }); + + it('returns push public key metadata when authenticated', async () => { + if (!LISTEN_ALLOWED) { + return; + } + + const res = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/public-key`, { + headers: { Authorization: 'Bearer push-secret' }, + }); + expect(res.status).toBe(200); + const body = await res.json() as { enabled: boolean; vapidPublicKey: string | null }; + expect(body.enabled).toBe(true); + expect(body.vapidPublicKey).toBe('BO_test_public_key'); + }); + + it('stores and deletes webchat push subscriptions', async () => { + if (!LISTEN_ALLOWED) { + return; + } + + const headers = { Authorization: 'Bearer push-secret', 'Content-Type': 'application/json' }; + const payload = { + endpoint: 'https://example.invalid/sub/1', + keys: { + p256dh: 'p256dh-sample', + auth: 'auth-sample', + }, + userAgent: 'vitest', + }; + + const putRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + expect(putRes.status).toBe(200); + const putBody = await putRes.json() as { stored: boolean; count: number }; + expect(putBody.stored).toBe(true); + expect(putBody.count).toBe(1); + + const listRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, { + headers: { Authorization: 'Bearer push-secret' }, + }); + expect(listRes.status).toBe(200); + const listBody = await listRes.json() as { count: number; maxSubscriptions: number }; + expect(listBody.count).toBe(1); + expect(listBody.maxSubscriptions).toBe(2); + + const delRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, { + method: 'DELETE', + headers, + body: JSON.stringify({ endpoint: payload.endpoint }), + }); + expect(delRes.status).toBe(200); + const delBody = await delRes.json() as { removed: boolean; count: number }; + expect(delBody.removed).toBe(true); + expect(delBody.count).toBe(0); + }); +}); + describe('GatewayServer WebSocket ingress rate limiting', () => { const RATE_PORT = 18895; let rateServer: GatewayServer; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index c373eae..9f5016d 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -131,6 +131,23 @@ export interface GatewayServerConfig { feishuHandler?: Pick; /** Optional Zalo adapter for inbound webhook events. */ zaloHandler?: Pick; + /** Optional WebChat PWA push-subscription settings. */ + webchatPush?: { + enabled?: boolean; + vapidPublicKey?: string; + maxSubscriptions?: number; + }; +} + +interface WebchatPushSubscriptionRecord { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; + userAgent?: string; + createdAt: number; + updatedAt: number; } export class GatewayServer { @@ -158,6 +175,7 @@ export class GatewayServer { windowStartMs: number; }> = new Map(); private connectionStateMap: Map = new Map(); + private webchatPushSubscriptions: Map = new Map(); private config: GatewayServerConfig; private startTime: number = Date.now(); @@ -670,6 +688,160 @@ export class GatewayServer { }; } + private getWebchatPushConfig(): { enabled: boolean; vapidPublicKey?: string; maxSubscriptions: number } { + const runtimeConfig = this.config.config?.server.webchat_push; + const override = this.config.webchatPush; + const enabled = override?.enabled ?? runtimeConfig?.enabled ?? false; + const vapidPublicKey = override?.vapidPublicKey ?? runtimeConfig?.vapid_public_key; + const maxSubscriptions = override?.maxSubscriptions ?? runtimeConfig?.max_subscriptions ?? 5000; + return { enabled, vapidPublicKey, maxSubscriptions }; + } + + private async handleWebchatPushRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (!req.url?.startsWith('/webchat/push')) { + return false; + } + + const parsed = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`); + const pathname = parsed.pathname; + const cfg = this.getWebchatPushConfig(); + + if (pathname === '/webchat/push/public-key' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + enabled: cfg.enabled, + vapidPublicKey: cfg.vapidPublicKey ?? null, + })); + return true; + } + + if (pathname === '/webchat/push/subscriptions' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + enabled: cfg.enabled, + count: this.webchatPushSubscriptions.size, + maxSubscriptions: cfg.maxSubscriptions, + })); + return true; + } + + if (pathname === '/webchat/push/subscriptions' && req.method === 'POST') { + if (!cfg.enabled) { + res.writeHead(409, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'WebChat push is disabled' })); + return true; + } + + let rawBody: string; + try { + rawBody = await this.readRequestBody(req); + } catch (err) { + if (err instanceof RequestBodyTooLargeError) { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + return true; + } + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request body' })); + return true; + } + + let parsedBody: unknown; + try { + parsedBody = JSON.parse(rawBody); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return true; + } + + const body = parsedBody as { + endpoint?: unknown; + keys?: { p256dh?: unknown; auth?: unknown }; + userAgent?: unknown; + }; + const endpoint = typeof body.endpoint === 'string' ? body.endpoint.trim() : ''; + const p256dh = typeof body.keys?.p256dh === 'string' ? body.keys.p256dh.trim() : ''; + const auth = typeof body.keys?.auth === 'string' ? body.keys.auth.trim() : ''; + const userAgent = typeof body.userAgent === 'string' ? body.userAgent.trim() : undefined; + + if (!endpoint || !p256dh || !auth) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing subscription endpoint or keys' })); + return true; + } + + if (!this.webchatPushSubscriptions.has(endpoint) && this.webchatPushSubscriptions.size >= cfg.maxSubscriptions) { + res.writeHead(429, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Subscription cap reached (${cfg.maxSubscriptions})` })); + return true; + } + + const now = Date.now(); + const previous = this.webchatPushSubscriptions.get(endpoint); + this.webchatPushSubscriptions.set(endpoint, { + endpoint, + keys: { p256dh, auth }, + userAgent, + createdAt: previous?.createdAt ?? now, + updatedAt: now, + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + stored: true, + count: this.webchatPushSubscriptions.size, + })); + return true; + } + + if (pathname === '/webchat/push/subscriptions' && req.method === 'DELETE') { + let rawBody = ''; + try { + rawBody = await this.readRequestBody(req); + } catch (err) { + if (err instanceof RequestBodyTooLargeError) { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + return true; + } + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request body' })); + return true; + } + + let endpoint = ''; + if (rawBody.trim().length > 0) { + try { + const body = JSON.parse(rawBody) as { endpoint?: unknown }; + endpoint = typeof body.endpoint === 'string' ? body.endpoint.trim() : ''; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return true; + } + } else { + endpoint = parsed.searchParams.get('endpoint')?.trim() ?? ''; + } + + if (!endpoint) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing subscription endpoint' })); + return true; + } + + const removed = this.webchatPushSubscriptions.delete(endpoint); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + removed, + count: this.webchatPushSubscriptions.size, + })); + return true; + } + + return false; + } + /** * Handle incoming HTTP requests. * Optionally applies auth (when authHttp is enabled and a token is configured). @@ -777,6 +949,11 @@ export class GatewayServer { } } + // WebChat PWA push-subscription endpoints (auth-protected) + if (await this.handleWebchatPushRequest(req, res)) { + return; + } + const uiDir = this.config.uiDir; if (uiDir) { diff --git a/src/gateway/static.ts b/src/gateway/static.ts index cdf7d79..4c91058 100644 --- a/src/gateway/static.ts +++ b/src/gateway/static.ts @@ -9,6 +9,7 @@ const CONTENT_TYPES: Record = { '.js': 'application/javascript', '.mjs': 'application/javascript', '.json': 'application/json', + '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', diff --git a/src/gateway/ui/app.js b/src/gateway/ui/app.js index 53fa3b2..564c57f 100644 --- a/src/gateway/ui/app.js +++ b/src/gateway/ui/app.js @@ -4,6 +4,7 @@ * Hash-based routing with page lifecycle management. */ import { getClient } from './lib/ws-client.js'; +import { registerPwaServiceWorker } from './lib/pwa.js'; const routes = new Map(); let currentPage = null; @@ -55,6 +56,7 @@ async function render() { export function initRouter() { contentEl = document.getElementById('content'); window.addEventListener('hashchange', render); + void registerPwaServiceWorker().catch(() => undefined); render(); } diff --git a/src/gateway/ui/flynn-icon.svg b/src/gateway/ui/flynn-icon.svg new file mode 100644 index 0000000..92b90a7 --- /dev/null +++ b/src/gateway/ui/flynn-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/gateway/ui/index.html b/src/gateway/ui/index.html index da3bf0a..221e30c 100644 --- a/src/gateway/ui/index.html +++ b/src/gateway/ui/index.html @@ -3,7 +3,10 @@ + Flynn + + diff --git a/src/gateway/ui/lib/pwa.js b/src/gateway/ui/lib/pwa.js new file mode 100644 index 0000000..c3309a1 --- /dev/null +++ b/src/gateway/ui/lib/pwa.js @@ -0,0 +1,158 @@ +function readGatewayToken() { + const params = new URLSearchParams(window.location.search); + const token = params.get('token'); + return token && token.trim() ? token.trim() : null; +} + +function withToken(path) { + const token = readGatewayToken(); + if (!token) { + return path; + } + const separator = path.includes('?') ? '&' : '?'; + return `${path}${separator}token=${encodeURIComponent(token)}`; +} + +async function requestJson(path, options) { + const response = await fetch(withToken(path), { + ...(options ?? {}), + headers: { + 'Content-Type': 'application/json', + ...(options?.headers ?? {}), + }, + }); + + let body = null; + try { + body = await response.json(); + } catch { + body = null; + } + + if (!response.ok) { + const message = typeof body?.error === 'string' + ? body.error + : `Request failed (${response.status})`; + throw new Error(message); + } + + return body; +} + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +} + +export function isPushSupported() { + return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; +} + +export async function registerPwaServiceWorker() { + if (!('serviceWorker' in navigator)) { + return null; + } + return navigator.serviceWorker.register(withToken('/sw.js')); +} + +export async function getPushStatus() { + const supported = isPushSupported(); + if (!supported) { + return { + supported: false, + permission: 'unsupported', + subscribed: false, + enabled: false, + configured: false, + message: 'Push is not supported by this browser.', + }; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + const publicKeyPayload = await requestJson('/webchat/push/public-key', { method: 'GET' }); + const configured = Boolean(publicKeyPayload?.vapidPublicKey); + + return { + supported: true, + permission: Notification.permission, + subscribed: Boolean(subscription), + enabled: Boolean(publicKeyPayload?.enabled), + configured, + message: null, + }; +} + +export async function enablePushNotifications() { + if (!isPushSupported()) { + throw new Error('Push notifications are not supported by this browser.'); + } + + const publicKeyPayload = await requestJson('/webchat/push/public-key', { method: 'GET' }); + if (!publicKeyPayload?.enabled) { + throw new Error('WebChat push is disabled on the gateway.'); + } + if (!publicKeyPayload?.vapidPublicKey) { + throw new Error('Gateway is missing server.webchat_push.vapid_public_key.'); + } + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + throw new Error('Notification permission was not granted.'); + } + + const registration = await navigator.serviceWorker.ready; + let subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKeyPayload.vapidPublicKey), + }); + } + + const json = subscription.toJSON(); + await requestJson('/webchat/push/subscriptions', { + method: 'POST', + body: JSON.stringify({ + endpoint: subscription.endpoint, + keys: json.keys ?? {}, + userAgent: navigator.userAgent, + }), + }); + + return getPushStatus(); +} + +export async function disablePushNotifications() { + if (!('serviceWorker' in navigator)) { + return { + supported: false, + permission: 'unsupported', + subscribed: false, + enabled: false, + configured: false, + message: 'Push is not supported by this browser.', + }; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await requestJson('/webchat/push/subscriptions', { + method: 'DELETE', + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + await subscription.unsubscribe(); + } + + return getPushStatus(); +} diff --git a/src/gateway/ui/manifest.webmanifest b/src/gateway/ui/manifest.webmanifest new file mode 100644 index 0000000..18f99f1 --- /dev/null +++ b/src/gateway/ui/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Flynn WebChat", + "short_name": "Flynn", + "start_url": "/#/chat", + "display": "standalone", + "background_color": "#101216", + "theme_color": "#101216", + "description": "Flynn WebChat companion UI", + "icons": [ + { + "src": "/flynn-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/src/gateway/ui/pages/settings.js b/src/gateway/ui/pages/settings.js index b91fa0a..8bbd9d4 100644 --- a/src/gateway/ui/pages/settings.js +++ b/src/gateway/ui/pages/settings.js @@ -4,6 +4,12 @@ * Read-only config view (redacted), editable hook patterns, * tool list, and channel overview. */ +import { + isPushSupported, + getPushStatus, + enablePushNotifications, + disablePushNotifications, +} from '../lib/pwa.js'; function escapeHtml(text) { const div = document.createElement('div'); @@ -14,6 +20,75 @@ function escapeHtml(text) { let _client = null; let _el = null; +function describePushStatus(status) { + if (!status.supported) { + return status.message || 'Push notifications are not supported in this browser.'; + } + if (!status.enabled) { + return 'Gateway push is disabled (`server.webchat_push.enabled: false`).'; + } + if (!status.configured) { + return 'Gateway push key is missing (`server.webchat_push.vapid_public_key`).'; + } + if (status.permission === 'denied') { + return 'Browser notifications are blocked. Allow notifications in browser settings.'; + } + if (status.subscribed) { + return 'Push notifications are enabled for this browser.'; + } + return 'Push is configured. Click Enable to subscribe this browser.'; +} + +async function renderPushStatus() { + const statusEl = _el.querySelector('#push-status'); + const enableBtn = _el.querySelector('#push-enable'); + const disableBtn = _el.querySelector('#push-disable'); + if (!statusEl || !enableBtn || !disableBtn) { + return; + } + + try { + const status = await getPushStatus(); + statusEl.textContent = describePushStatus(status); + statusEl.className = status.subscribed ? 'text-sm text-success' : 'text-sm text-muted'; + enableBtn.disabled = !status.supported || !status.enabled || !status.configured || status.subscribed; + disableBtn.disabled = !status.supported || !status.subscribed; + } catch (err) { + statusEl.textContent = `Push status error: ${err.message}`; + statusEl.className = 'text-sm text-error'; + enableBtn.disabled = true; + disableBtn.disabled = true; + } +} + +async function onEnablePush() { + const statusEl = _el.querySelector('#push-status'); + if (!statusEl) {return;} + statusEl.textContent = 'Enabling push notifications...'; + statusEl.className = 'text-sm text-muted'; + try { + await enablePushNotifications(); + await renderPushStatus(); + } catch (err) { + statusEl.textContent = `Enable failed: ${err.message}`; + statusEl.className = 'text-sm text-error'; + } +} + +async function onDisablePush() { + const statusEl = _el.querySelector('#push-status'); + if (!statusEl) {return;} + statusEl.textContent = 'Disabling push notifications...'; + statusEl.className = 'text-sm text-muted'; + try { + await disablePushNotifications(); + await renderPushStatus(); + } catch (err) { + statusEl.textContent = `Disable failed: ${err.message}`; + statusEl.className = 'text-sm text-error'; + } +} + async function loadSettings() { if (!_client || !_el) {return;} @@ -52,6 +127,16 @@ async function loadSettings() { _el.innerHTML = `

Settings

+

WebChat Push Notifications

+
+ ${isPushSupported() ? '' : '
This browser does not support PushManager APIs.
'} +
+ + +
+
+
+

Hook Patterns

@@ -133,6 +218,9 @@ async function loadSettings() { // Bind save hooks _el.querySelector('#hooks-save').addEventListener('click', saveHooks); + _el.querySelector('#push-enable').addEventListener('click', onEnablePush); + _el.querySelector('#push-disable').addEventListener('click', onDisablePush); + await renderPushStatus(); } async function saveHooks() { diff --git a/src/gateway/ui/sw.js b/src/gateway/ui/sw.js new file mode 100644 index 0000000..542807e --- /dev/null +++ b/src/gateway/ui/sw.js @@ -0,0 +1,92 @@ +const CACHE_NAME = 'flynn-webchat-v1'; +const token = new URL(self.location.href).searchParams.get('token'); +const withToken = (path) => { + if (!token) { + return path; + } + const sep = path.includes('?') ? '&' : '?'; + return `${path}${sep}token=${encodeURIComponent(token)}`; +}; +const OFFLINE_ASSETS = ['/', '/index.html', '/style.css', '/app.js', '/manifest.webmanifest'].map(withToken); + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(OFFLINE_ASSETS)) + .catch(() => undefined), + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)), + )), + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') { + return; + } + + event.respondWith((async () => { + const cached = await caches.match(event.request, { ignoreSearch: true }); + if (cached) { + return cached; + } + + try { + return await fetch(event.request); + } catch { + const fallback = await caches.match('/index.html', { ignoreSearch: true }); + return fallback || new Response('Offline', { status: 503 }); + } + })()); +}); + +self.addEventListener('push', (event) => { + let payload = { + title: 'Flynn', + body: 'You have a new update.', + }; + + try { + const parsed = event.data?.json(); + if (parsed && typeof parsed === 'object') { + payload = { + ...payload, + ...parsed, + }; + } + } catch { + const text = event.data?.text(); + if (text) { + payload.body = text; + } + } + + event.waitUntil(self.registration.showNotification(payload.title, { + body: payload.body, + tag: payload.tag || 'flynn-webchat', + renotify: false, + data: payload.data || {}, + })); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil((async () => { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + if (clients.length > 0) { + await clients[0].focus(); + return; + } + await self.clients.openWindow('/#/chat'); + })()); +});