P4-C: PWA installable, service worker push & campagne de fiabilité dialogues
- 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>
This commit is contained in:
70
packages/web/src/lib/push.ts
Normal file
70
packages/web/src/lib/push.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// 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();
|
||||
}
|
||||
Reference in New Issue
Block a user