From 2b4494628e9306acd4aa11bdd840c932d3764c29 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 15:36:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(mana/web):=20unblock=20voice=20capture=20?= =?UTF-8?q?=E2=80=94=20permissions=20policy,=20notification=20mount,=20dev?= =?UTF-8?q?=20SW?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent bugs that conspired to make the dreams + memoro mic buttons completely unusable in production AND in dev. Each one alone would have been the only blocker; they layered on top of each other so fixing the top one just exposed the next. 1. Permissions-Policy header blocked the microphone API entirely. `packages/shared-utils/src/security-headers.ts` set `microphone=()` which means "no origin, including self, may use the microphone". `getUserMedia()` throws a `Permissions policy violation` and the browser never even shows the permission dialog — no amount of OS / browser / site settings can override it because the policy blocks the API at the document level. Fix: change to `microphone=(self)` so mana.how itself can use the API. Camera stays disallowed (no module needs it). 2. Notification permission was requested at layout mount time. `(app)/+layout.svelte` called `notificationService.requestPermission()` from `onMount()`. Modern browsers require permission requests to come from a user gesture — calling it without one queues the prompt until the next click. That meant the user's FIRST click on any button (in this case the dreams "Traum sprechen" mic button) showed the queued notifications prompt instead of the action they actually clicked. Worse, `getUserMedia()` was then silently dropped because Chrome only shows one permission dialog at a time. Fix: remove the mount-time call entirely. Notification permission must be requested from a button the user explicitly clicks ("Benachrichtigungen aktivieren" toggle in Settings or first time a reminder is created) — the reminder scheduler still runs without permission, it just won't fire OS notifications until granted. 3. vite-plugin-pwa registered a service worker in dev that cached the old layout chunks across reloads, so the fix for #2 was invisible until the user manually unregistered the SW in DevTools. `vite-plugin-pwa` defaults `devEnabled: true`, which is a well-known footgun for fast iteration. Production still gets the full SW (this only flips dev). The 2026-04-08 mic-button hunt took an extra hour for exactly this reason. Fix: pass `devEnabled: false` to createPWAConfig in vite.config.ts. Verified: in a fresh incognito tab on `localhost:5173/`, opening the Dreams app in the workbench and clicking the mic button now shows the microphone permission dialog directly (no notifications hijack), and recording → transcription works end-to-end against the production mana-stt service on the GPU box. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/routes/(app)/+layout.svelte | 17 +++++++++++++++-- apps/mana/apps/web/vite.config.ts | 9 +++++++++ packages/shared-utils/src/security-headers.ts | 9 ++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index e8fe6b9a0..a16cc5984 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores'; import type { Snippet } from 'svelte'; import { onDestroy, setContext } from 'svelte'; - import { createReminderScheduler, notificationService } from '@mana/shared-stores'; + import { createReminderScheduler } from '@mana/shared-stores'; import { todoReminderSource } from '$lib/modules/todo/reminder-source'; import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte'; import SessionWarning from '$lib/components/SessionWarning.svelte'; @@ -332,7 +332,20 @@ // Phase B2: Start reminder scheduler reminderScheduler.start(); - notificationService.requestPermission(); + // IMPORTANT: do NOT call notificationService.requestPermission() here. + // Browsers (Chrome/Firefox) require permission requests to come from + // a user gesture. Calling it at mount time queues the prompt until + // the next click, which means the FIRST click on any button (e.g. + // the dreams "Traum sprechen" mic button) shows a notification + // permission popup instead of the action the user actually clicked + // — and getUserMedia() / other permission requests get silently + // dropped because Chrome only shows one permission dialog at a time. + // + // Notification permission must be requested from a button the user + // explicitly clicks ("Benachrichtigungen aktivieren" toggle in + // Settings, or first time a reminder is created). The reminder + // scheduler still runs without permission — it just won't fire + // OS notifications until the user grants it. // Phase C: Guest mode — welcome modal + nudge if (!authStore.isAuthenticated) { diff --git a/apps/mana/apps/web/vite.config.ts b/apps/mana/apps/web/vite.config.ts index 510b5ad16..815874a96 100644 --- a/apps/mana/apps/web/vite.config.ts +++ b/apps/mana/apps/web/vite.config.ts @@ -20,6 +20,15 @@ export default defineConfig({ themeColor: '#6366f1', registerType: 'prompt', preset: 'full', + // Disable the service worker in dev. With devEnabled=true (the + // default) vite-plugin-pwa registers a SW that aggressively + // precaches the route chunks — and after the first dev session + // the SW keeps serving the OLD JS even when Vite HMR pushes + // new code, so source edits become invisible until the user + // manually unregisters the worker in DevTools. The 2026-04-08 + // dreams mic-button bug took an extra hour to track down for + // exactly this reason. Production still gets the full SW. + devEnabled: false, shortcuts: [ { name: 'Dashboard', short_name: 'Home', url: '/', description: 'Zum Dashboard' }, { diff --git a/packages/shared-utils/src/security-headers.ts b/packages/shared-utils/src/security-headers.ts index e2eed020c..4209e9993 100644 --- a/packages/shared-utils/src/security-headers.ts +++ b/packages/shared-utils/src/security-headers.ts @@ -49,7 +49,14 @@ export function setSecurityHeaders(response: Response, options: SecurityHeadersO response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)'); + // Permissions-Policy: allow microphone for `self` so dreams/memoro voice + // capture (getUserMedia) works on mana.how. `microphone=()` would block + // the API entirely — Chrome reports `[Violation] Permissions policy + // violation: microphone is not allowed in this document` and the + // permission dialog never appears, even if the user has explicitly + // granted access in OS + browser settings. Camera stays disallowed + // since no module needs it. + response.headers.set('Permissions-Policy', 'camera=(), microphone=(self), geolocation=(self)'); // Content Security Policy const cspDirectives = [