feat: add Web Push API support and PWA integration (#189)
* feat: add Web Push API support and PWA integration Add web Push API with subscribe/unsubscribe and visibility endpoints Introduce usePushVisibilityBeacon and useSessionDeepLink hooks Integrate PWA with service worker, registerSW, and VAPID key persistence * feat: add heartbeat visibility beacon for web runtime Add a 10s heartbeat to ping visibility while visible Subscribe to visibilitychange, focus, blur, pageshow, and pagehide events to report state Clear heartbeat interval on unmount to avoid leaks
This commit is contained in:
committed by
GitHub
parent
06c5e821a4
commit
1f23b63c0b
@@ -6,6 +6,7 @@ import { createWebSettingsAPI } from './settings';
|
||||
import { createWebPermissionsAPI } from './permissions';
|
||||
import { createWebNotificationsAPI } from './notifications';
|
||||
import { createWebToolsAPI } from './tools';
|
||||
import { createWebPushAPI } from './push';
|
||||
|
||||
export const createWebAPIs = (): RuntimeAPIs => ({
|
||||
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
|
||||
@@ -15,5 +16,6 @@ export const createWebAPIs = (): RuntimeAPIs => ({
|
||||
settings: createWebSettingsAPI(),
|
||||
permissions: createWebPermissionsAPI(),
|
||||
notifications: createWebNotificationsAPI(),
|
||||
push: createWebPushAPI(),
|
||||
tools: createWebToolsAPI(),
|
||||
});
|
||||
|
||||
@@ -6,8 +6,15 @@ const notifyWithWebAPI = async (payload?: NotificationPayload): Promise<boolean>
|
||||
return false;
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
if (Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.warn('Notification permission not granted');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
console.warn('Notification permission not granted');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const fetchJson = async <T>(input: RequestInfo | URL, init?: RequestInit): Promise<T | null> => {
|
||||
try {
|
||||
const res = await fetch(input, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebPushAPI = (): PushAPI => ({
|
||||
async getVapidPublicKey() {
|
||||
return fetchJson<{ publicKey: string }>('/api/push/vapid-public-key');
|
||||
},
|
||||
|
||||
async subscribe(payload: PushSubscribePayload) {
|
||||
return fetchJson<{ ok: true }>('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async unsubscribe(payload: PushUnsubscribePayload) {
|
||||
return fetchJson<{ ok: true }>('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async setVisibility(payload: { visible: boolean }) {
|
||||
return fetchJson<{ ok: true }>('/api/push/visibility', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createWebAPIs } from './api';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import '@openchamber/ui/index.css';
|
||||
import '@openchamber/ui/styles/fonts';
|
||||
@@ -10,4 +12,21 @@ declare global {
|
||||
}
|
||||
|
||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
|
||||
|
||||
registerSW({
|
||||
onRegistered(registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Periodic update check (best-effort)
|
||||
setInterval(() => {
|
||||
void registration.update();
|
||||
}, 60 * 60 * 1000);
|
||||
},
|
||||
onRegisterError(error: unknown) {
|
||||
console.warn('[PWA] service worker registration failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
import('@openchamber/ui/main');
|
||||
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'virtual:pwa-register' {
|
||||
export interface RegisterSWOptions {
|
||||
immediate?: boolean;
|
||||
onNeedRefresh?: () => void;
|
||||
onOfflineReady?: () => void;
|
||||
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void;
|
||||
onRegisterError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
// NOTE: keep the Workbox injection point so vite-plugin-pwa can build.
|
||||
// We intentionally do not use Workbox runtime helpers here: iOS Safari can be
|
||||
// fragile with more complex SW bundles. For push notifications we only need a
|
||||
// minimal SW.
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<string | { url: string; revision?: string }>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const __precacheManifest = self.__WB_MANIFEST;
|
||||
|
||||
type PushPayload = {
|
||||
title?: string;
|
||||
body?: string;
|
||||
tag?: string;
|
||||
data?: {
|
||||
url?: string;
|
||||
sessionId?: string;
|
||||
type?: string;
|
||||
};
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const payload = (event.data?.json() ?? null) as PushPayload | null;
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = payload.title || 'OpenChamber';
|
||||
const body = payload.body ?? '';
|
||||
const icon = payload.icon ?? '/apple-touch-icon-180x180.png';
|
||||
const badge = payload.badge ?? '/favicon-32.png';
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon,
|
||||
badge,
|
||||
tag: payload.tag,
|
||||
data: payload.data,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const data = (event.notification.data ?? null) as { url?: string } | null;
|
||||
const url = data?.url ?? '/';
|
||||
|
||||
event.waitUntil(self.clients.openWindow(url));
|
||||
});
|
||||
Reference in New Issue
Block a user