feat: add webchat pwa push subscription support
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
<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">
|
||||
|
||||
@@ -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,
|
||||
* 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() {
|
||||
|
||||
@@ -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