fix(mana/web): unblock voice capture — permissions policy, notification mount, dev SW

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 15:36:03 +02:00
parent 4cb1bc1827
commit 2b4494628e
3 changed files with 32 additions and 3 deletions

View file

@ -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) {

View file

@ -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' },
{

View file

@ -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 = [