From 11ab8b57bfe4db41daa515cfcf0c130531aa39eb Mon Sep 17 00:00:00 2001 From: root Date: Thu, 14 May 2026 03:43:51 +0000 Subject: [PATCH] feat: PWA + Mikrofon-Aufnahme + HTTPS via selbst-signiertem Cert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend-Aufnahme (MediaRecorder): - Aufnahme-Button im Upload-Form, große Touch-Targets - Start / Pause / Fortsetzen / Stop mit Live-Timer - Format-Auto-Detect (Opus/WebM, Safari nimmt M4A) - Wake Lock damit iOS nicht ins Sleep geht - Aufnahme-Datei landet als File-Objekt im Upload-Flow - Beim Submit: aufgenommene Datei bevorzugt vor File-Input PWA: - manifest.webmanifest, icon.svg + PNG-Icons (192/512) - apple-mobile-web-app-capable + theme-color - "Zum Home-Bildschirm" hinzufügen verwandelt Seite in App Mobile-Layout: - Single-Column ab 700px, größere Buttons - Header stapelt sich vertikal - Tabelle scrollt horizontal statt zu zerquetschen - Safe-Area-Insets für iPhone-Notch HTTPS (Pflicht für Mikrofon-Zugriff): - install.sh erzeugt selbst-signiertes Cert (4096 Bit, 825 Tage, mit SAN aus Hostname + LAN-IP) - Nginx: HTTP → HTTPS Redirect, Cert unter /ssl/voice-agent.crt ungesichert abrufbar (für bequemen iOS-Profil-Import) Backend: - ALLOWED_EXTS um 'webm' erweitert --- .claude/settings.local.json | 3 +- deploy/install.sh | 44 ++++- deploy/nginx/voice-agent.conf | 32 +++- lxc-frontend/.env.example | 2 +- lxc-frontend/app/core/config.py | 2 +- lxc-frontend/app/static/app.js | 183 ++++++++++++++++++- lxc-frontend/app/static/icon-192.png | Bin 0 -> 5863 bytes lxc-frontend/app/static/icon-512.png | Bin 0 -> 16671 bytes lxc-frontend/app/static/icon.svg | 14 ++ lxc-frontend/app/static/index.html | 41 ++++- lxc-frontend/app/static/manifest.webmanifest | 17 ++ lxc-frontend/app/static/style.css | 129 +++++++++++++ 12 files changed, 451 insertions(+), 16 deletions(-) create mode 100644 lxc-frontend/app/static/icon-192.png create mode 100644 lxc-frontend/app/static/icon-512.png create mode 100644 lxc-frontend/app/static/icon.svg create mode 100644 lxc-frontend/app/static/manifest.webmanifest diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 418b61f..9c561c0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Write(*)", "Edit(*)", "Glob(*)", - "Grep(*)" + "Grep(*)", + "WebFetch(domain:192.168.85.11)" ] } } diff --git a/deploy/install.sh b/deploy/install.sh index c71c86d..dd473c0 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -61,7 +61,32 @@ systemctl daemon-reload systemctl enable voice-agent systemctl restart voice-agent -echo "[7/7] Nginx-Reverse-Proxy" +echo "[7/8] Selbst-signiertes TLS-Zertifikat (für Mikrofon-Zugriff via HTTPS)" +SSL_DIR="/etc/ssl/voice-agent" +SSL_CRT="$SSL_DIR/voice-agent.crt" +SSL_KEY="$SSL_DIR/voice-agent.key" +mkdir -p "$SSL_DIR" + +if [ ! -f "$SSL_CRT" ] || [ ! -f "$SSL_KEY" ]; then + apt-get install -y --no-install-recommends openssl >/dev/null + HOST_NAME="$(hostname --fqdn 2>/dev/null || hostname)" + HOST_SHORT="$(hostname)" + HOST_IP="$(hostname -I | awk '{print $1}')" + SAN="DNS:${HOST_NAME},DNS:${HOST_SHORT},DNS:voice-agent.local,IP:${HOST_IP}" + echo " Erzeuge Cert für: $SAN" + openssl req -x509 -nodes -newkey rsa:4096 -days 825 \ + -keyout "$SSL_KEY" -out "$SSL_CRT" \ + -subj "/CN=${HOST_NAME}" -addext "subjectAltName=${SAN}" \ + -addext "extendedKeyUsage=serverAuth" \ + -addext "basicConstraints=CA:FALSE" 2>/dev/null + chmod 0644 "$SSL_CRT" + chmod 0600 "$SSL_KEY" + echo " Cert erzeugt: $SSL_CRT" +else + echo " Cert bereits vorhanden, behalte: $SSL_CRT" +fi + +echo "[8/8] Nginx-Reverse-Proxy (HTTPS + HTTP-Redirect)" cp "$APP_DIR/deploy/nginx/voice-agent.conf" /etc/nginx/sites-available/voice-agent ln -sf /etc/nginx/sites-available/voice-agent /etc/nginx/sites-enabled/voice-agent rm -f /etc/nginx/sites-enabled/default @@ -78,10 +103,21 @@ chmod 0755 /usr/local/bin/voice-agent-admin echo echo "Fertig. Health-Check:" -echo " curl -s http://localhost/health" +echo " curl -sk https://localhost/health" echo -echo "Beim ersten Aufruf im Browser: Initialer Admin wird angelegt — bestehende Jobs" -echo "werden diesem Account automatisch zugeordnet." +echo "Aufruf im Browser: https:/// (HTTP wird auf HTTPS umgeleitet)" +echo "Beim ersten Aufruf wird der initiale Admin angelegt." +echo +echo "── Mikrofon-Aufnahme von Handys ──────────────────────────────────────" +echo "Damit iOS/Android das Mikrofon freigibt, MUSS die Seite über HTTPS laufen." +echo "Das selbst-signierte Zertifikat einmalig auf jedem Gerät installieren:" +echo +echo " iPhone: Safari öffnen, http:///ssl/voice-agent.crt aufrufen" +echo " → Profil herunterladen → Einstellungen → Profil installieren" +echo " → Allgemein → Info → Zertifikatsvertrauen → 'voice-agent' aktivieren" +echo +echo " Android: Browser öffnen, http:///ssl/voice-agent.crt aufrufen" +echo " → Cert installieren als 'VPN/Apps' (Variiert je nach Hersteller)" echo echo "Falls noch nicht geschehen: MAC_API_URL in $APP_DIR/lxc-frontend/.env setzen" echo "und Service neu starten: sudo systemctl restart voice-agent" diff --git a/deploy/nginx/voice-agent.conf b/deploy/nginx/voice-agent.conf index 9fd547d..dd9d941 100644 --- a/deploy/nginx/voice-agent.conf +++ b/deploy/nginx/voice-agent.conf @@ -1,11 +1,39 @@ +# HTTP — leitet auf HTTPS um, außer für /ssl/voice-agent.crt (Cert-Download) server { listen 80 default_server; server_name _; - # Upload-Limit muss zu MAX_UPLOAD_MB in .env passen client_max_body_size 600M; + proxy_read_timeout 1800; + proxy_send_timeout 1800; - # Lange Verarbeitung — Timeouts hoch + # Cert für Handy-Import bequem ohne HTTPS abholbar + location /ssl/voice-agent.crt { + alias /etc/ssl/voice-agent/voice-agent.crt; + default_type application/x-x509-ca-cert; + add_header Content-Disposition 'attachment; filename="voice-agent.crt"'; + } + + # Alles andere → HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS mit selbst-signiertem Cert +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + http2 on; + server_name _; + + ssl_certificate /etc/ssl/voice-agent/voice-agent.crt; + ssl_certificate_key /etc/ssl/voice-agent/voice-agent.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + client_max_body_size 600M; proxy_read_timeout 1800; proxy_send_timeout 1800; diff --git a/lxc-frontend/.env.example b/lxc-frontend/.env.example index ef31a8c..0ebfb6f 100644 --- a/lxc-frontend/.env.example +++ b/lxc-frontend/.env.example @@ -14,7 +14,7 @@ MAC_API_TIMEOUT=1800 # Limits MAX_UPLOAD_MB=500 -ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac +ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac,webm # Session-Cookie-Signierung — wird vom install.sh automatisch generiert. # NIE committen. Bei Wechsel werden alle Sessions invalidiert. diff --git a/lxc-frontend/app/core/config.py b/lxc-frontend/app/core/config.py index 8b0efb7..b34efa8 100644 --- a/lxc-frontend/app/core/config.py +++ b/lxc-frontend/app/core/config.py @@ -17,7 +17,7 @@ class Settings(BaseSettings): mac_api_timeout: int = 1800 max_upload_mb: int = 500 - allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac" + allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac,webm" # Wird beim Deploy automatisch generiert (siehe install.sh). session_secret: str = "CHANGE-ME-SET-IN-ENV" diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js index de71d24..719bd9d 100644 --- a/lxc-frontend/app/static/app.js +++ b/lxc-frontend/app/static/app.js @@ -37,6 +37,17 @@ const defaultProfileName = document.getElementById("default-profile-name"); const headerProfileWrap = document.getElementById("header-profile-wrap"); const headerProfile = document.getElementById("header-profile"); +const recordToggle = document.getElementById("record-toggle"); +const recordToggleLabel = document.getElementById("record-toggle-label"); +const recordPane = document.getElementById("record-pane"); +const recordTimer = document.getElementById("record-timer"); +const recordStatus = document.getElementById("record-status"); +const recPauseBtn = document.getElementById("rec-pause"); +const recStopBtn = document.getElementById("rec-stop"); +const recordResult = document.getElementById("record-result"); +const recordFilename = document.getElementById("record-filename"); +const recordDiscardBtn = document.getElementById("record-discard"); + let currentUser = null; // { id, username, is_admin, default_profile } let mode = "login"; // "login" | "setup" let availableProfiles = []; // [{ name, display_name, language }, ...] @@ -310,9 +321,15 @@ async function loadJobs() { form.addEventListener("submit", (ev) => { ev.preventDefault(); - if (!audioInput.files.length) return; + // Quelle: Aufnahme bevorzugt, sonst File-Input + const file = recordedFile || (audioInput.files.length ? audioInput.files[0] : null); + if (!file) { + uploadMsg.textContent = "Bitte Datei wählen oder Aufnahme erstellen."; + uploadMsg.className = "msg err"; + return; + } const fd = new FormData(); - fd.append("audio", audioInput.files[0]); + fd.append("audio", file, file.name); fd.append("title", titleInput.value); // Profile mitgeben — leer = Server nimmt user.default_profile fd.append("profile", profileSelect.value || ""); @@ -341,6 +358,7 @@ form.addEventListener("submit", (ev) => { uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`; uploadMsg.className = "msg ok"; form.reset(); + discardRecording(); loadJobs(); } else { let err = "Upload fehlgeschlagen"; @@ -434,6 +452,167 @@ usersBody.addEventListener("click", async (ev) => { } }); +// ─── Audio-Aufnahme (MediaRecorder) ──────────────────────────────────────── +let mediaRecorder = null; +let recordedChunks = []; +let recordedFile = null; // File-Objekt nach Stop +let recStream = null; // MediaStream zum sauberen Schließen +let recStartedAt = 0; +let recPausedAt = 0; // wenn paused: Zeitpunkt des Pausierens +let recElapsedBeforePause = 0; // aufaddierte Aufnahme-Sekunden vor letztem Resume +let timerInterval = null; +let wakeLock = null; + +function pickMimeType() { + const candidates = [ + "audio/webm;codecs=opus", + "audio/webm", + "audio/mp4", // Safari iOS + "audio/mpeg", + "", // Browser-Default + ]; + for (const m of candidates) { + if (m === "" || (window.MediaRecorder && MediaRecorder.isTypeSupported(m))) return m; + } + return ""; +} + +function extFromMime(mime) { + if (!mime) return "webm"; + if (mime.includes("webm")) return "webm"; + if (mime.includes("mp4")) return "m4a"; + if (mime.includes("mpeg")) return "mp3"; + if (mime.includes("ogg")) return "ogg"; + return "webm"; +} + +function fmtTimer(s) { + const mm = String(Math.floor(s / 60)).padStart(2, "0"); + const ss = String(Math.floor(s % 60)).padStart(2, "0"); + return `${mm}:${ss}`; +} + +function tickTimer() { + if (!mediaRecorder) return; + let elapsed = recElapsedBeforePause; + if (mediaRecorder.state === "recording") elapsed += (Date.now() - recStartedAt) / 1000; + recordTimer.textContent = fmtTimer(elapsed); +} + +async function acquireWakeLock() { + try { + if ("wakeLock" in navigator) { + wakeLock = await navigator.wakeLock.request("screen"); + } + } catch { /* nicht kritisch */ } +} +async function releaseWakeLock() { + try { if (wakeLock) await wakeLock.release(); } catch {} + wakeLock = null; +} + +async function startRecording() { + if (!navigator.mediaDevices?.getUserMedia) { + alert("Dein Browser unterstützt kein Mikrofon — oder die Seite läuft nicht über HTTPS."); + return; + } + try { + recStream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + }); + } catch (e) { + alert("Mikrofon-Zugriff verweigert oder fehlgeschlagen: " + e.message); + return; + } + + const mime = pickMimeType(); + recordedChunks = []; + try { + mediaRecorder = mime ? new MediaRecorder(recStream, { mimeType: mime }) : new MediaRecorder(recStream); + } catch (e) { + alert("MediaRecorder konnte nicht starten: " + e.message); + cleanupStream(); + return; + } + + mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); }; + mediaRecorder.onstop = () => { + const type = mediaRecorder.mimeType || mime || "audio/webm"; + const ext = extFromMime(type); + const blob = new Blob(recordedChunks, { type }); + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + recordedFile = new File([blob], `aufnahme-${ts}.${ext}`, { type }); + onRecordingFinalized(); + }; + + mediaRecorder.start(1000); // alle 1s ein dataavailable-Event + recStartedAt = Date.now(); + recPausedAt = 0; + recElapsedBeforePause = 0; + recordTimer.textContent = "00:00"; + recordStatus.textContent = "Aufnahme läuft"; + recPauseBtn.textContent = "Pause"; + recordPane.classList.remove("hidden"); + recordResult.classList.add("hidden"); + recordToggle.classList.add("hidden"); + audioInput.value = ""; // konkurrierende Datei-Auswahl löschen + timerInterval = setInterval(tickTimer, 250); + acquireWakeLock(); +} + +function pauseRecording() { + if (!mediaRecorder) return; + if (mediaRecorder.state === "recording") { + mediaRecorder.pause(); + recElapsedBeforePause += (Date.now() - recStartedAt) / 1000; + recPausedAt = Date.now(); + recordStatus.textContent = "Pausiert"; + recPauseBtn.textContent = "Fortsetzen"; + } else if (mediaRecorder.state === "paused") { + mediaRecorder.resume(); + recStartedAt = Date.now(); + recordStatus.textContent = "Aufnahme läuft"; + recPauseBtn.textContent = "Pause"; + } +} + +function stopRecording() { + if (!mediaRecorder) return; + if (mediaRecorder.state !== "inactive") mediaRecorder.stop(); + if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } + cleanupStream(); + releaseWakeLock(); +} + +function cleanupStream() { + if (recStream) { + recStream.getTracks().forEach((t) => t.stop()); + recStream = null; + } +} + +function onRecordingFinalized() { + recordPane.classList.add("hidden"); + recordResult.classList.remove("hidden"); + recordToggle.classList.remove("hidden"); + recordToggleLabel.textContent = "Neue Aufnahme starten"; + const sizeKB = Math.round(recordedFile.size / 1024); + recordFilename.textContent = `${recordedFile.name} (${sizeKB} KB)`; +} + +function discardRecording() { + recordedFile = null; + recordedChunks = []; + recordResult.classList.add("hidden"); + recordToggleLabel.textContent = "Aufnahme starten"; + recordFilename.textContent = ""; +} + +recordToggle.addEventListener("click", startRecording); +recPauseBtn.addEventListener("click", pauseRecording); +recStopBtn.addEventListener("click", stopRecording); +recordDiscardBtn.addEventListener("click", discardRecording); + // ─── Init ────────────────────────────────────────────────────────────────── bootstrap(); checkMac(); diff --git a/lxc-frontend/app/static/icon-192.png b/lxc-frontend/app/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..9ecdb19eca64c1be791978d533010d9a8ca34916 GIT binary patch literal 5863 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 literal 0 HcmV?d00001 diff --git a/lxc-frontend/app/static/icon.svg b/lxc-frontend/app/static/icon.svg new file mode 100644 index 0000000..a715d9a --- /dev/null +++ b/lxc-frontend/app/static/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/lxc-frontend/app/static/index.html b/lxc-frontend/app/static/index.html index 5a7cc6b..58eeb39 100644 --- a/lxc-frontend/app/static/index.html +++ b/lxc-frontend/app/static/index.html @@ -2,9 +2,17 @@ - + + Voice-Agent — Transkription + + + + + + +

@@ -63,10 +71,33 @@ Standard: (in Einstellungen änderbar) - +
+ +
— oder —
+ +
+ + + + +