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:
Johan LEROY
2026-06-15 12:23:00 +02:00
parent 28b9283825
commit fbbfafae9b
14 changed files with 505 additions and 1 deletions

View 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();
}