- PWA: public/manifest.webmanifest + icon.svg + meta index.html (theme-color, apple-touch-icon) - public/sw.js: service worker push-only (push → showNotification tag=sessionId ; notificationclick → focus/ouvre /sessions/<id>) — pas de précache (hors périmètre) - lib/push.ts + stores/push.ts: enable/disable, abonnement VAPID, enregistrement SW au boot - DashboardView: toggle « Notifications » (gardé par pushSupported) + i18n en/fr - campagne de fiabilité (verdict S3): classification de tous les types de dialogue sur captures réelles (trust/permission×2/question) + refus Esc (deny-esc2) + plan (synthétique) - acceptance-p4.mjs: VAPID/auth/Origin, subscribe idempotent/unsubscribe, answer (rejets) Choix: SW écrit à la main plutôt que vite-plugin-pwa (qui tirait ~295 paquets Workbox + 2 vulns esbuild high pour un SW push-only). Zéro dépendance front nouvelle. 175 tests verts, acceptance p1..p4 ALL GREEN. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
71 lines
2.8 KiB
TypeScript
71 lines
2.8 KiB
TypeScript
// Abonnement Web Push côté navigateur (P4-C). Enregistre le service worker, demande la permission,
|
|
// s'abonne avec la clé VAPID publique du serveur, et transmet l'abonnement au daemon.
|
|
import { api } from './api';
|
|
import type { VapidKeyResponse } from '@arboretum/shared';
|
|
|
|
const SW_URL = '/sw.js';
|
|
|
|
/** Push nécessite service worker + PushManager + Notification (et un contexte sécurisé : localhost ou HTTPS). */
|
|
export function pushSupported(): boolean {
|
|
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
|
}
|
|
|
|
export function notificationPermission(): NotificationPermission {
|
|
return 'Notification' in window ? Notification.permission : 'denied';
|
|
}
|
|
|
|
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
|
if (!('serviceWorker' in navigator)) return null;
|
|
try {
|
|
return await navigator.serviceWorker.register(SW_URL);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// La clé VAPID publique est transmise en base64url ; PushManager attend un BufferSource
|
|
// adossé à un ArrayBuffer (pas SharedArrayBuffer) → on type explicitement le buffer.
|
|
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
|
|
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
|
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = atob(b64);
|
|
const out = new Uint8Array(new ArrayBuffer(raw.length));
|
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
export async function currentSubscription(): Promise<PushSubscription | null> {
|
|
if (!pushSupported()) return null;
|
|
const reg = await navigator.serviceWorker.ready;
|
|
return reg.pushManager.getSubscription();
|
|
}
|
|
|
|
export type EnableResult = 'enabled' | 'denied' | 'unsupported';
|
|
|
|
export async function enablePush(): Promise<EnableResult> {
|
|
if (!pushSupported()) return 'unsupported';
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== 'granted') return 'denied';
|
|
await registerServiceWorker();
|
|
const reg = await navigator.serviceWorker.ready;
|
|
const { key } = await api.get<VapidKeyResponse>('/api/v1/push/vapid-public-key');
|
|
const sub =
|
|
(await reg.pushManager.getSubscription()) ??
|
|
(await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(key) }));
|
|
const json = sub.toJSON();
|
|
const keys = json.keys ?? {};
|
|
await api.post('/api/v1/push/subscribe', { endpoint: sub.endpoint, keys: { p256dh: keys.p256dh ?? '', auth: keys.auth ?? '' } });
|
|
return 'enabled';
|
|
}
|
|
|
|
export async function disablePush(): Promise<void> {
|
|
const sub = await currentSubscription();
|
|
if (!sub) return;
|
|
try {
|
|
await api.post('/api/v1/push/unsubscribe', { endpoint: sub.endpoint });
|
|
} catch {
|
|
// le retrait local prime ; un échec réseau sur l'unsubscribe serveur n'est pas bloquant
|
|
}
|
|
await sub.unsubscribe();
|
|
}
|