feat(ui): vault lock card, audio device test meter, quick hardware stress test
This commit is contained in:
1 parent
b7e6f54d91
commit
b14728e1c8
1 file changed
+412
-67
+412
-67
@@ -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<string | null>(null);
|
||||
let stressResult = $state<StressTestResult | null>(null);
|
||||
let stressError = $state<string | null>(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<string | null>(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 @@
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">{t("settings.hardware.recording_device_hint")}</p>
|
||||
<div class="device-test">
|
||||
<button onclick={() => testDevice("loopback")} disabled={monitorKind !== null}>
|
||||
<Volume2 size={13} aria-hidden="true" />
|
||||
{monitorKind === "loopback"
|
||||
? t("settings.hardware.testing")
|
||||
: t("settings.hardware.test_output")}
|
||||
</button>
|
||||
<button onclick={playTone} disabled={monitorKind !== null}>
|
||||
{t("settings.hardware.play_tone")}
|
||||
</button>
|
||||
{#if monitorKind === "loopback"}
|
||||
<LevelMeter rms={monitorRms} peak={monitorPeak} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label
|
||||
>{t("settings.hardware.microphone")}
|
||||
@@ -841,6 +957,75 @@
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">{t("settings.hardware.mic_hint")}</p>
|
||||
<div class="device-test">
|
||||
<button
|
||||
onclick={() => testDevice("input")}
|
||||
disabled={monitorKind !== null || !settings.settings.microphone_enabled}
|
||||
>
|
||||
<Mic size={13} aria-hidden="true" />
|
||||
{monitorKind === "input"
|
||||
? t("settings.hardware.testing")
|
||||
: t("settings.hardware.test_mic")}
|
||||
</button>
|
||||
{#if monitorKind === "input"}
|
||||
<LevelMeter rms={monitorRms} peak={monitorPeak} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h4>{t("settings.hardware.stress_title")}</h4>
|
||||
<p class="muted">{t("settings.hardware.stress_hint")}</p>
|
||||
<button onclick={runStressTest} disabled={stressRunning}>
|
||||
<Zap size={13} aria-hidden="true" />
|
||||
{stressRunning ? t("settings.hardware.stress_running") : t("settings.hardware.stress_run")}
|
||||
</button>
|
||||
{#if stressProgress}
|
||||
<p class="muted">{t("settings.hardware.stress_progress", { pair: stressProgress })}</p>
|
||||
{/if}
|
||||
{#if stressError}<p class="muted err">{stressError}</p>{/if}
|
||||
{#if stressResult}
|
||||
{#if stressResult.recommended}
|
||||
<div class="stress-rec">
|
||||
<ShieldCheck size={14} aria-hidden="true" />
|
||||
<span
|
||||
>{t("settings.hardware.stress_recommend", {
|
||||
backend: stressResult.recommended.backend,
|
||||
model: stressResult.recommended.model,
|
||||
})}</span
|
||||
>
|
||||
<button class="primary" onclick={applyRecommendation}
|
||||
>{t("settings.hardware.stress_apply")}</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{t("settings.hardware.stress_none")}</p>
|
||||
{/if}
|
||||
<table class="stress-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("settings.hardware.stress_backend")}</th>
|
||||
<th>{t("settings.hardware.stress_model")}</th>
|
||||
<th>{t("settings.hardware.stress_rtf")}</th>
|
||||
<th>{t("settings.hardware.stress_realtime")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each stressResult.results as r (r.backend + r.model)}
|
||||
<tr>
|
||||
<td>{r.backend}</td>
|
||||
<td>{r.model}</td>
|
||||
<td class="num">{r.rtf.toFixed(2)}×</td>
|
||||
<td>
|
||||
{#if r.realtime}
|
||||
<Check size={13} aria-hidden="true" /> {t("settings.hardware.stress_yes")}
|
||||
{:else}
|
||||
{t("settings.hardware.stress_no")}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
{#if settings.hardware.npu?.present}
|
||||
{@const npu = settings.hardware.npu}
|
||||
@@ -1934,67 +2119,118 @@
|
||||
{/if}
|
||||
|
||||
{#if vault}
|
||||
<h4>{t("settings.privacy.vault_title")}</h4>
|
||||
{#if !vault.enabled}
|
||||
<p class="muted">{t("settings.privacy.vault_intro")}</p>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.vault_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
>
|
||||
<div
|
||||
class="vault-card"
|
||||
class:locked={vault.enabled && !vault.unlocked}
|
||||
class:unlocked={vault.enabled && vault.unlocked}
|
||||
>
|
||||
<div class="vault-head">
|
||||
{#if !vault.enabled}
|
||||
<ShieldCheck size={18} aria-hidden="true" />
|
||||
{:else if !vault.unlocked}
|
||||
<Lock size={18} aria-hidden="true" />
|
||||
{:else}
|
||||
<LockOpen size={18} aria-hidden="true" />
|
||||
{/if}
|
||||
<h4>{t("settings.privacy.vault_title")}</h4>
|
||||
{#if vault.enabled}
|
||||
<span class="badge" class:busy={!vault.unlocked}>
|
||||
{vault.unlocked
|
||||
? t("settings.privacy.unlocked_word")
|
||||
: t("settings.privacy.locked_word")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
|
||||
>{t("settings.privacy.enable_vault")}</button
|
||||
>
|
||||
<p class="muted">{t("settings.privacy.vault_pw_hint")}</p>
|
||||
{:else if !vault.unlocked}
|
||||
<p class="muted">
|
||||
{t("settings.privacy.vault_locked_1")}<strong
|
||||
>{t("settings.privacy.locked_word")}</strong
|
||||
>{t("settings.privacy.vault_locked_2")}
|
||||
</p>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.password")}<input
|
||||
type="password"
|
||||
|
||||
{#if !vault.enabled}
|
||||
<p class="muted">{t("settings.privacy.vault_intro")}</p>
|
||||
<div class="pw-row">
|
||||
<input
|
||||
type={showVaultPw ? "text" : "password"}
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<button class="primary" onclick={unlockVault} disabled={!vaultPw}
|
||||
>{t("settings.privacy.unlock")}</button
|
||||
>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
{t("settings.privacy.vault_unlocked_1")}<strong
|
||||
>{t("settings.privacy.unlocked_word")}</strong
|
||||
>{t("settings.privacy.vault_unlocked_2")}
|
||||
</p>
|
||||
<button onclick={lockVault}>{t("settings.privacy.lock_now")}</button>
|
||||
<details>
|
||||
<summary>{t("settings.privacy.change_password")}</summary>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.current_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
>
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.new_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw2}
|
||||
/></label
|
||||
placeholder={t("settings.privacy.vault_password")}
|
||||
aria-label={t("settings.privacy.vault_password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon pw-toggle"
|
||||
onclick={() => (showVaultPw = !showVaultPw)}
|
||||
aria-label={t("settings.privacy.show_password")}
|
||||
title={t("settings.privacy.show_password")}
|
||||
>
|
||||
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
</button>
|
||||
</div>
|
||||
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8}
|
||||
>{t("settings.privacy.change_password")}</button
|
||||
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
|
||||
>{t("settings.privacy.enable_vault")}</button
|
||||
>
|
||||
</details>
|
||||
{/if}
|
||||
{#if vaultMsg}<p class="muted">{vaultMsg}</p>{/if}
|
||||
<p class="muted hint">{t("settings.privacy.vault_pw_hint")}</p>
|
||||
{:else if !vault.unlocked}
|
||||
<p class="muted">
|
||||
{t("settings.privacy.vault_locked_1")}<strong
|
||||
>{t("settings.privacy.locked_word")}</strong
|
||||
>{t("settings.privacy.vault_locked_2")}
|
||||
</p>
|
||||
<div class="pw-row">
|
||||
<input
|
||||
type={showVaultPw ? "text" : "password"}
|
||||
bind:value={vaultPw}
|
||||
placeholder={t("settings.privacy.password")}
|
||||
aria-label={t("settings.privacy.password")}
|
||||
onkeydown={(e) => e.key === "Enter" && vaultPw && unlockVault()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon pw-toggle"
|
||||
onclick={() => (showVaultPw = !showVaultPw)}
|
||||
aria-label={t("settings.privacy.show_password")}
|
||||
title={t("settings.privacy.show_password")}
|
||||
>
|
||||
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
</button>
|
||||
</div>
|
||||
<button class="primary" onclick={unlockVault} disabled={!vaultPw}
|
||||
>{t("settings.privacy.unlock")}</button
|
||||
>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
{t("settings.privacy.vault_unlocked_1")}<strong
|
||||
>{t("settings.privacy.unlocked_word")}</strong
|
||||
>{t("settings.privacy.vault_unlocked_2")}
|
||||
</p>
|
||||
<button onclick={lockVault}>
|
||||
<Lock size={14} aria-hidden="true" />
|
||||
{t("settings.privacy.lock_now")}
|
||||
</button>
|
||||
<details>
|
||||
<summary>{t("settings.privacy.change_password")}</summary>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.current_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
>
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.new_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw2}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8}
|
||||
>{t("settings.privacy.change_password")}</button
|
||||
>
|
||||
</details>
|
||||
{/if}
|
||||
{#if vaultMsg}<p class="vault-msg" class:error={vaultMsgError}>{vaultMsg}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{: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;
|
||||
|
||||
Reference in new issue
Block a user