From a6828a16c2e674bf029ec482e9decad2ccfc4ad6 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 15:55:05 +0200 Subject: [PATCH] fix(dreams): explain why the mic prompt isn't appearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The voice capture used to surface a generic "Mikrofon-Zugriff verweigert" whenever getUserMedia rejected, even though the actual cause was usually one of three distinct, fixable conditions: - Insecure context (http://192.168.x.x:5173 instead of localhost or https) — getUserMedia is silently unavailable, no prompt - Sticky deny — user previously refused once, browser remembers and rejects without ever asking again - Hardware: no microphone, busy mic, security policy Now the recorder: 1. Checks window.isSecureContext first and tells the user to switch to https or localhost, naming the offending host 2. Queries the Permissions API for "microphone" before calling getUserMedia. If state is "denied", shows step-by-step recovery instructions (lock icon → mic → allow → reload) instead of pretending the user actively denied just now 3. Maps NotAllowedError / NotFoundError / NotReadableError / SecurityError to specific German messages, with the raw error as a fallback so the rest is still debuggable Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/modules/dreams/recorder.svelte.ts | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts b/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts index acf43c1a3..8988fbbc1 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts @@ -34,13 +34,37 @@ class DreamRecorder { ); } + get isSecureContext(): boolean { + return typeof window !== 'undefined' && window.isSecureContext === true; + } + async start(): Promise { if (this.status !== 'idle') return; + + // 1. Secure context check — getUserMedia is silently unavailable + // over plain http (except localhost), with no permission prompt. + if (!this.isSecureContext) { + const host = typeof window !== 'undefined' ? window.location.host : ''; + this.error = `Mikrofon-Zugriff braucht eine sichere Verbindung. Öffne die App über https:// oder http://localhost statt http://${host}.`; + return; + } + + // 2. Browser API present? if (!this.isAvailable) { this.error = 'Audio-Aufnahme wird in diesem Browser nicht unterstützt.'; return; } + // 3. Sticky deny check — Permissions API tells us if the user + // previously denied access. The browser will silently reject + // getUserMedia without showing a prompt in that case. + const stickyDenied = await this.#checkStickyDeny(); + if (stickyDenied) { + this.error = + 'Mikrofon-Zugriff wurde für diese Seite blockiert. Klicke in der Adressleiste auf das Schloss-Symbol → Mikrofon → Erlauben, dann lade die Seite neu.'; + return; + } + this.error = null; this.status = 'requesting'; @@ -53,10 +77,7 @@ class DreamRecorder { }, }); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - this.error = msg.includes('Permission') - ? 'Mikrofon-Zugriff wurde verweigert.' - : `Mikrofon konnte nicht geöffnet werden: ${msg}`; + this.error = this.#explainError(e); this.status = 'idle'; return; } @@ -141,6 +162,44 @@ class DreamRecorder { reject?.(err); } + async #checkStickyDeny(): Promise { + try { + // Permissions API may not be available everywhere; treat as unknown. + const perms = ( + navigator as Navigator & { + permissions?: { + query: (descriptor: { name: string }) => Promise<{ state: string }>; + }; + } + ).permissions; + if (!perms?.query) return false; + const status = await perms.query({ name: 'microphone' }); + return status.state === 'denied'; + } catch { + return false; + } + } + + #explainError(e: unknown): string { + const err = e instanceof Error ? e : new Error(String(e)); + const name = err.name || ''; + const msg = err.message || ''; + + if (name === 'NotAllowedError' || /denied|permission/i.test(msg)) { + return 'Mikrofon-Zugriff wurde verweigert. Klicke in der Adressleiste auf das Schloss-Symbol und erlaube den Zugriff.'; + } + if (name === 'NotFoundError' || /not.?found|no.?device/i.test(msg)) { + return 'Kein Mikrofon gefunden. Schließe ein Mikrofon an oder prüfe deine System-Einstellungen.'; + } + if (name === 'NotReadableError' || /in use|busy/i.test(msg)) { + return 'Mikrofon ist gerade von einer anderen Anwendung belegt.'; + } + if (name === 'SecurityError') { + return 'Mikrofon-Zugriff vom Browser blockiert (Sicherheitsrichtlinie).'; + } + return `Mikrofon konnte nicht geöffnet werden: ${msg || name || 'Unbekannter Fehler'}`; + } + #cleanupStream() { this.#stream?.getTracks().forEach((t) => t.stop()); this.#stream = null;