From b14728e1c816329614c7570ba159b1e17516530b Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Wed, 15 Jul 2026 09:50:00 -0500 Subject: [PATCH] feat(ui): vault lock card, audio device test meter, quick hardware stress test --- src/lib/views/Settings.svelte | 479 +++++++++++++++++++++++++++++----- 1 file changed, 412 insertions(+), 67 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 1b7c311..104f895 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -9,9 +9,16 @@ import { t, i18n, LOCALES } from "../i18n/index.svelte"; import ConsentNotice from "../components/ConsentNotice.svelte"; import HostedAiBanner from "../components/HostedAiBanner.svelte"; + import LevelMeter from "../components/LevelMeter.svelte"; import { open } from "@tauri-apps/plugin-dialog"; import { api, errorMessage, events } from "../api"; - import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api"; + import type { + BackendId, + StressTestResult, + SyncKind, + SyncTargetConfig, + SyncTargetInfo, + } from "../api"; import { trapFocus } from "../actions/trapFocus"; import { X, @@ -23,7 +30,11 @@ CalendarDays, UploadCloud, ShieldCheck, + Lock, + LockOpen, Sparkles, + Volume2, + Zap, RefreshCw, ChevronRight, RotateCcw, @@ -381,11 +392,98 @@ let showConsent = $state(false); let testResult = $state<{ ok: boolean; message: string } | null>(null); + // ---- Audio device test (live level meter) ---- + let monitorKind = $state<"input" | "loopback" | null>(null); + let monitorRms = $state(0); + let monitorPeak = $state(0); + let monitorUnlisten: (() => void) | null = null; + function stopMonitor() { + monitorUnlisten?.(); + monitorUnlisten = null; + monitorKind = null; + monitorRms = 0; + monitorPeak = 0; + } + async function testDevice(kind: "input" | "loopback") { + if (monitorKind) return; + monitorKind = kind; + monitorRms = 0; + monitorPeak = 0; + monitorUnlisten = await events.onDeviceLevel((p) => { + if (p.kind !== kind) return; + if (p.done) { + stopMonitor(); + return; + } + monitorRms = p.rms ?? 0; + monitorPeak = p.peak ?? 0; + }); + const deviceId = + kind === "input" + ? (settings.settings.audio_input_device ?? null) + : (settings.settings.audio_output_device ?? null); + try { + await api.monitorAudioLevel(kind, deviceId, 6000); + } catch (e) { + testResult = { ok: false, message: errorMessage(e) }; + } finally { + stopMonitor(); + } + } + // A 440Hz beep to the default output so the user can confirm speakers work. + function playTone() { + try { + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.frequency.value = 440; + gain.gain.value = 0.15; + osc.connect(gain).connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + 0.5); + osc.onended = () => ctx.close(); + } catch { + /* no Web Audio available */ + } + } + + // ---- Quick hardware stress test ---- + let stressRunning = $state(false); + let stressProgress = $state(null); + let stressResult = $state(null); + let stressError = $state(null); + async function runStressTest() { + if (stressRunning) return; + stressRunning = true; + stressError = null; + stressResult = null; + const un = await events.onStressProgress((p) => { + stressProgress = `${p.backend} · ${p.model}`; + }); + try { + stressResult = await api.stressTestHardware(); + } catch (e) { + stressError = errorMessage(e); + } finally { + un(); + stressProgress = null; + stressRunning = false; + } + } + async function applyRecommendation() { + const r = stressResult?.recommended; + if (!r) return; + await settings.setPreferredBackend(r.backend as BackendId | "auto"); + await settings.patch({ whisper_model: r.model }); + } + // ---- At-rest encryption vault (T8.8, FR-SEC-3) ---- let vault = $state<{ enabled: boolean; unlocked: boolean } | null>(null); let vaultPw = $state(""); let vaultPw2 = $state(""); let vaultMsg = $state(null); + let vaultMsgError = $state(false); + let showVaultPw = $state(false); async function loadVault() { try { vault = await api.vaultStatus(); @@ -394,42 +492,46 @@ } } onMount(loadVault); + function setVaultMsg(msg: string | null, isError = false) { + vaultMsg = msg; + vaultMsgError = isError; + } async function enableVault() { - vaultMsg = null; + setVaultMsg(null); try { await api.enableVault(vaultPw); vaultPw = ""; - vaultMsg = "Vault enabled and unlocked."; + setVaultMsg("Vault enabled and unlocked."); await loadVault(); } catch (e) { - vaultMsg = errorMessage(e); + setVaultMsg(errorMessage(e), true); } } async function unlockVault() { - vaultMsg = null; + setVaultMsg(null); try { await api.unlockVault(vaultPw); vaultPw = ""; - vaultMsg = "Unlocked."; + setVaultMsg("Unlocked."); await loadVault(); } catch (e) { - vaultMsg = errorMessage(e); + setVaultMsg(errorMessage(e), true); } } async function lockVault() { await api.lockVault(); - vaultMsg = "Locked."; + setVaultMsg("Locked."); await loadVault(); } async function changeVaultPassword() { - vaultMsg = null; + setVaultMsg(null); try { await api.changeVaultPassword(vaultPw, vaultPw2); vaultPw = ""; vaultPw2 = ""; - vaultMsg = "Password changed."; + setVaultMsg("Password changed."); } catch (e) { - vaultMsg = errorMessage(e); + setVaultMsg(errorMessage(e), true); } } @@ -820,6 +922,20 @@

{t("settings.hardware.recording_device_hint")}

+
+ + + {#if monitorKind === "loopback"} + + {/if} +

{t("settings.hardware.mic_hint")}

+
+ + {#if monitorKind === "input"} + + {/if} +
+ +

{t("settings.hardware.stress_title")}

+

{t("settings.hardware.stress_hint")}

+ + {#if stressProgress} +

{t("settings.hardware.stress_progress", { pair: stressProgress })}

+ {/if} + {#if stressError}

{stressError}

{/if} + {#if stressResult} + {#if stressResult.recommended} +
+
+ {:else} +

{t("settings.hardware.stress_none")}

+ {/if} + + + + + + + + + + + {#each stressResult.results as r (r.backend + r.model)} + + + + + + + {/each} + +
{t("settings.hardware.stress_backend")}{t("settings.hardware.stress_model")}{t("settings.hardware.stress_rtf")}{t("settings.hardware.stress_realtime")}
{r.backend}{r.model}{r.rtf.toFixed(2)}× + {#if r.realtime} +
+ {/if} {#if settings.hardware.npu?.present} {@const npu = settings.hardware.npu} @@ -1934,67 +2119,118 @@ {/if} {#if vault} -

{t("settings.privacy.vault_title")}

- {#if !vault.enabled} -

{t("settings.privacy.vault_intro")}

-
- +
+
+ {#if !vault.enabled} +
- -

{t("settings.privacy.vault_pw_hint")}

- {:else if !vault.unlocked} -

- {t("settings.privacy.vault_locked_1")}{t("settings.privacy.locked_word")}{t("settings.privacy.vault_locked_2")} -

-
-
{/if} {:else if section === "language"} @@ -2445,6 +2681,115 @@ color: var(--accent, #2563eb); border-color: currentColor; } + .vault-card { + margin-top: 0.6rem; + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated); + display: flex; + flex-direction: column; + gap: 0.6rem; + } + .vault-card.locked { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + } + .vault-card.unlocked { + border-color: color-mix(in srgb, var(--success, #16a34a) 45%, var(--border)); + } + .vault-head { + display: flex; + align-items: center; + gap: 0.5rem; + } + .vault-head h4 { + margin: 0; + } + .vault-head :global(svg) { + color: var(--muted); + } + .vault-card.locked .vault-head :global(svg) { + color: var(--accent); + } + .vault-card.unlocked .vault-head :global(svg) { + color: var(--success, #16a34a); + } + .vault-head .badge { + margin-left: auto; + } + .pw-row { + display: flex; + align-items: center; + gap: 0.4rem; + max-width: 22rem; + } + .pw-row input { + flex: 1; + } + .pw-toggle { + flex: none; + } + .vault-card .hint { + margin: 0; + } + .vault-msg { + margin: 0; + font-size: 0.85rem; + color: var(--success, #16a34a); + } + .vault-msg.error { + color: var(--danger); + } + .device-test { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin: 0.35rem 0 0.6rem; + } + .device-test :global(.meter) { + flex: 1; + min-width: 8rem; + } + .stress-rec { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.6rem 0; + padding: 0.55rem 0.75rem; + border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); + border-radius: var(--radius-md, 8px); + background: color-mix(in srgb, var(--accent) 10%, var(--bg)); + } + .stress-rec :global(svg) { + color: var(--accent); + } + .stress-rec span { + flex: 1; + } + .stress-table { + width: 100%; + border-collapse: collapse; + margin-top: 0.5rem; + font-size: 0.82rem; + } + .stress-table th, + .stress-table td { + text-align: left; + padding: 0.3rem 0.5rem; + border-bottom: 1px solid var(--border); + } + .stress-table th { + color: var(--muted); + font-weight: 600; + } + .stress-table .num { + font-variant-numeric: tabular-nums; + text-align: right; + } + .err { + color: var(--danger); + } .npu-package { margin-top: 0.6rem; padding: 0.6rem 0.75rem;