feat: add webchat pwa push subscription support

This commit is contained in:
William Valentin
2026-02-18 10:46:55 -08:00
parent 02fa604c7c
commit 8234cc93f3
17 changed files with 743 additions and 2 deletions
+22
View File
@@ -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:
+6
View File
@@ -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:
+6
View File
@@ -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)
+28 -2
View File
@@ -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",
+23
View File
@@ -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', () => {
+11
View File
@@ -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,
});
+5
View File
@@ -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,
+93
View File
@@ -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;
+177
View File
@@ -131,6 +131,23 @@ export interface GatewayServerConfig {
feishuHandler?: Pick<FeishuAdapter, 'handleRequest'>;
/** Optional Zalo adapter for inbound webhook events. */
zaloHandler?: Pick<ZaloAdapter, 'handleRequest'>;
/** 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<string, NodeConnectionState> = new Map();
private webchatPushSubscriptions: Map<string, WebchatPushSubscriptionRecord> = 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<boolean> {
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) {
+1
View File
@@ -9,6 +9,7 @@ const CONTENT_TYPES: Record<string, string> = {
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.json': 'application/json',
'.webmanifest': 'application/manifest+json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
+2
View File
@@ -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();
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Flynn icon">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#25c2a0"/>
<stop offset="100%" stop-color="#1877f2"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="48" fill="#0b0f14"/>
<path d="M58 72h140v28H90v28h90v28H90v52H58z" fill="url(#g)"/>
<circle cx="188" cy="188" r="16" fill="#25c2a0"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

+3
View File
@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#101216">
<title>Flynn</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/svg+xml" href="flynn-icon.svg">
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
+158
View File
@@ -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();
}
+17
View File
@@ -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"
}
]
}
+88
View File
@@ -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 = `
<h1 class="page-title">Settings</h1>
<h2 class="section-title">WebChat Push Notifications</h2>
<div class="settings-section">
${isPushSupported() ? '' : '<div class="text-sm text-muted">This browser does not support PushManager APIs.</div>'}
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<button id="push-enable" class="btn btn-primary" type="button">Enable Push</button>
<button id="push-disable" class="btn btn-secondary" type="button">Disable Push</button>
</div>
<div id="push-status" class="text-sm text-muted"></div>
</div>
<h2 class="section-title">Hook Patterns</h2>
<div class="settings-section">
<div class="hook-editor">
@@ -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() {
+92
View File
@@ -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');
})());
});