feat: add webchat pwa push subscription support
This commit is contained in:
@@ -1328,6 +1328,28 @@ Companion runtime helper:
|
|||||||
- runtime observability/control passthroughs (`pendingRequestCount`, `pendingEventWaitCount`, `hasPendingWork`, `idle`, `lastDisconnectCode`, `lastDisconnectReason`, `getPendingWorkSnapshot()`, `getEventSurfaceSnapshot()`, `getConnectionSnapshot()`, `connected`, `waitForIdle()`)
|
- 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.
|
- `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
|
## Canvas / A2UI Foundation
|
||||||
|
|
||||||
Gateway provides a session-scoped canvas artifact API for companion/UI surfaces:
|
Gateway provides a session-scoped canvas artifact API for companion/UI surfaces:
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ server:
|
|||||||
enabled: false
|
enabled: false
|
||||||
push:
|
push:
|
||||||
enabled: false
|
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.
|
# Local-network service discovery (mDNS/Bonjour). Keep disabled by default.
|
||||||
# Requires server.localhost: false so LAN clients can actually connect.
|
# Requires server.localhost: false so LAN clients can actually connect.
|
||||||
discovery:
|
discovery:
|
||||||
|
|||||||
@@ -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 /google-chat/events` (Google Chat event callback, optional webhook token check)
|
||||||
- `POST /bluebubbles/events` (BlueBubbles iMessage webhook 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
|
## Message Format
|
||||||
|
|
||||||
### Request (Client → Server)
|
### Request (Client → Server)
|
||||||
|
|||||||
+28
-2
@@ -5323,10 +5323,36 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/commands/builtin/index.test.ts src/daemon/routing.test.ts + pnpm typecheck passing"
|
"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": {
|
"overall_progress": {
|
||||||
"total_test_count": 1911,
|
"total_test_count": 1913,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (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",
|
"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",
|
"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",
|
"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": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -147,6 +147,13 @@ describe('configSchema — server', () => {
|
|||||||
expect(result.server.nodes.push.enabled).toBe(false);
|
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', () => {
|
it('accepts custom node policy settings', () => {
|
||||||
const result = configSchema.parse({
|
const result = configSchema.parse({
|
||||||
...minimalConfig,
|
...minimalConfig,
|
||||||
@@ -194,6 +201,22 @@ describe('configSchema — server', () => {
|
|||||||
expect(result.server.discovery.service_type).toBe('_custom._tcp');
|
expect(result.server.discovery.service_type).toBe('_custom._tcp');
|
||||||
expect(result.server.discovery.txt).toEqual({ env: 'dev' });
|
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', () => {
|
describe('configSchema — browser', () => {
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ const serverDiscoverySchema = z.object({
|
|||||||
txt: z.record(z.string(), z.string()).default({}),
|
txt: z.record(z.string(), z.string()).default({}),
|
||||||
}).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({
|
const serverNodePolicySchema = z.object({
|
||||||
/** Enable node registration/capability RPC surface. */
|
/** Enable node registration/capability RPC surface. */
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
@@ -118,6 +127,8 @@ const serverSchema = z.object({
|
|||||||
queue: laneQueueSchema,
|
queue: laneQueueSchema,
|
||||||
/** Optional companion-node registration/capability settings. */
|
/** Optional companion-node registration/capability settings. */
|
||||||
nodes: serverNodePolicySchema,
|
nodes: serverNodePolicySchema,
|
||||||
|
/** Optional WebChat PWA push-subscription settings. */
|
||||||
|
webchat_push: serverWebchatPushSchema,
|
||||||
/** Optional Bonjour/mDNS advertisement settings. */
|
/** Optional Bonjour/mDNS advertisement settings. */
|
||||||
discovery: serverDiscoverySchema,
|
discovery: serverDiscoverySchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -361,6 +361,11 @@ export function createGateway(deps: GatewayDeps): GatewayServer {
|
|||||||
locationEnabled: config.server.nodes.location.enabled,
|
locationEnabled: config.server.nodes.location.enabled,
|
||||||
pushEnabled: config.server.nodes.push.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: {
|
discovery: {
|
||||||
enabled: config.server.discovery.enabled,
|
enabled: config.server.discovery.enabled,
|
||||||
serviceName: config.server.discovery.service_name,
|
serviceName: config.server.discovery.service_name,
|
||||||
|
|||||||
@@ -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', () => {
|
describe('GatewayServer WebSocket ingress rate limiting', () => {
|
||||||
const RATE_PORT = 18895;
|
const RATE_PORT = 18895;
|
||||||
let rateServer: GatewayServer;
|
let rateServer: GatewayServer;
|
||||||
|
|||||||
@@ -131,6 +131,23 @@ export interface GatewayServerConfig {
|
|||||||
feishuHandler?: Pick<FeishuAdapter, 'handleRequest'>;
|
feishuHandler?: Pick<FeishuAdapter, 'handleRequest'>;
|
||||||
/** Optional Zalo adapter for inbound webhook events. */
|
/** Optional Zalo adapter for inbound webhook events. */
|
||||||
zaloHandler?: Pick<ZaloAdapter, 'handleRequest'>;
|
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 {
|
export class GatewayServer {
|
||||||
@@ -158,6 +175,7 @@ export class GatewayServer {
|
|||||||
windowStartMs: number;
|
windowStartMs: number;
|
||||||
}> = new Map();
|
}> = new Map();
|
||||||
private connectionStateMap: Map<string, NodeConnectionState> = new Map();
|
private connectionStateMap: Map<string, NodeConnectionState> = new Map();
|
||||||
|
private webchatPushSubscriptions: Map<string, WebchatPushSubscriptionRecord> = new Map();
|
||||||
private config: GatewayServerConfig;
|
private config: GatewayServerConfig;
|
||||||
private startTime: number = Date.now();
|
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.
|
* Handle incoming HTTP requests.
|
||||||
* Optionally applies auth (when authHttp is enabled and a token is configured).
|
* 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;
|
const uiDir = this.config.uiDir;
|
||||||
|
|
||||||
if (uiDir) {
|
if (uiDir) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const CONTENT_TYPES: Record<string, string> = {
|
|||||||
'.js': 'application/javascript',
|
'.js': 'application/javascript',
|
||||||
'.mjs': 'application/javascript',
|
'.mjs': 'application/javascript',
|
||||||
'.json': 'application/json',
|
'.json': 'application/json',
|
||||||
|
'.webmanifest': 'application/manifest+json',
|
||||||
'.svg': 'image/svg+xml',
|
'.svg': 'image/svg+xml',
|
||||||
'.png': 'image/png',
|
'.png': 'image/png',
|
||||||
'.ico': 'image/x-icon',
|
'.ico': 'image/x-icon',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Hash-based routing with page lifecycle management.
|
* Hash-based routing with page lifecycle management.
|
||||||
*/
|
*/
|
||||||
import { getClient } from './lib/ws-client.js';
|
import { getClient } from './lib/ws-client.js';
|
||||||
|
import { registerPwaServiceWorker } from './lib/pwa.js';
|
||||||
|
|
||||||
const routes = new Map();
|
const routes = new Map();
|
||||||
let currentPage = null;
|
let currentPage = null;
|
||||||
@@ -55,6 +56,7 @@ async function render() {
|
|||||||
export function initRouter() {
|
export function initRouter() {
|
||||||
contentEl = document.getElementById('content');
|
contentEl = document.getElementById('content');
|
||||||
window.addEventListener('hashchange', render);
|
window.addEventListener('hashchange', render);
|
||||||
|
void registerPwaServiceWorker().catch(() => undefined);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#101216">
|
||||||
<title>Flynn</title>
|
<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">
|
<link rel="stylesheet" href="style.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<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">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
* Read-only config view (redacted), editable hook patterns,
|
* Read-only config view (redacted), editable hook patterns,
|
||||||
* tool list, and channel overview.
|
* tool list, and channel overview.
|
||||||
*/
|
*/
|
||||||
|
import {
|
||||||
|
isPushSupported,
|
||||||
|
getPushStatus,
|
||||||
|
enablePushNotifications,
|
||||||
|
disablePushNotifications,
|
||||||
|
} from '../lib/pwa.js';
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -14,6 +20,75 @@ function escapeHtml(text) {
|
|||||||
let _client = null;
|
let _client = null;
|
||||||
let _el = 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() {
|
async function loadSettings() {
|
||||||
if (!_client || !_el) {return;}
|
if (!_client || !_el) {return;}
|
||||||
|
|
||||||
@@ -52,6 +127,16 @@ async function loadSettings() {
|
|||||||
_el.innerHTML = `
|
_el.innerHTML = `
|
||||||
<h1 class="page-title">Settings</h1>
|
<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>
|
<h2 class="section-title">Hook Patterns</h2>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="hook-editor">
|
<div class="hook-editor">
|
||||||
@@ -133,6 +218,9 @@ async function loadSettings() {
|
|||||||
|
|
||||||
// Bind save hooks
|
// Bind save hooks
|
||||||
_el.querySelector('#hooks-save').addEventListener('click', saveHooks);
|
_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() {
|
async function saveHooks() {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
})());
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user