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.
This commit is contained in:
2026-06-15 12:23:00 +02:00
parent 664e2b583c
commit 32e6ffba70
14 changed files with 505 additions and 1 deletions

View File

@@ -7,10 +7,21 @@
<div class="ml-auto flex items-center gap-2">
<RouterLink :to="{ name: 'sessions' }" class="btn">{{ t('dashboard.allSessions') }}</RouterLink>
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</button>
<button
v-if="push.supported"
class="btn"
:class="push.enabled ? 'border-emerald-700 text-emerald-300' : ''"
:disabled="push.busy"
:title="pushErrorText"
@click="push.toggle()"
>
{{ push.enabled ? t('push.disable') : t('push.enable') }}
</button>
<LanguageSwitcher />
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
</div>
</header>
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
<form class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
@@ -33,12 +44,13 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import { useWorktreesStore } from '../stores/worktrees';
import { useSessionsStore } from '../stores/sessions';
import { usePushStore } from '../stores/push';
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
import RepoSection from '../components/RepoSection.vue';
@@ -47,11 +59,19 @@ const router = useRouter();
const auth = useAuthStore();
const store = useWorktreesStore();
const sessions = useSessionsStore();
const push = usePushStore();
const newPath = ref('');
const adding = ref(false);
const addError = ref<string | null>(null);
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
const pushErrorText = computed(() => {
const e = push.error;
if (!e) return '';
return e === 'denied' || e === 'unsupported' ? t(`push.${e}`) : e;
});
onMounted(() => {
void store.fetchAll();
store.startRealtime();
@@ -59,6 +79,7 @@ onMounted(() => {
// suit leurs mises à jour temps réel pour que les badges d'état (busy/waiting/idle) restent live.
void sessions.fetchSessions();
sessions.startRealtime();
void push.refresh();
});
async function onAddRepo(): Promise<void> {