diff --git a/Client/tauri-client/src/components/VoiceWidget.ts b/Client/tauri-client/src/components/VoiceWidget.ts index f73ec764..6abbcaf0 100644 --- a/Client/tauri-client/src/components/VoiceWidget.ts +++ b/Client/tauri-client/src/components/VoiceWidget.ts @@ -9,8 +9,9 @@ import { createElement, appendChildren, setText } from "@lib/dom"; import { createIcon, createSignalIcon } from "@lib/icons"; import type { IconName } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; -import { voiceStore } from "@stores/voice.store"; +import { voiceStore, type VoiceStatus } from "@stores/voice.store"; import { channelsStore } from "@stores/channels.store"; +import { uiStore } from "@stores/ui.store"; import { createConnectionStatsPoller, formatBytes, @@ -44,6 +45,16 @@ const QUALITY_BARS: Record = { bad: 1, }; +/** Header status text per voice-session lifecycle state + * (docs/architecture/ux/voice-and-e2ee.md §2). */ +const STATUS_LABELS: Record = { + idle: "Voice Connected", + joining: "Connecting…", + securing: "Securing…", + connected: "Voice Connected", + reconnecting: "Reconnecting voice…", +}; + /** Format milliseconds elapsed into HH:MM:SS or MM:SS. */ function formatElapsed(ms: number): string { const totalSec = Math.floor(ms / 1000); @@ -59,10 +70,14 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone const ac = new AbortController(); let root: HTMLDivElement | null = null; let channelNameEl: HTMLSpanElement | null = null; + let statusLabel: HTMLSpanElement | null = null; + let securedBadge: HTMLSpanElement | null = null; + let controlsRow: HTMLDivElement | null = null; let muteBtn: HTMLButtonElement | null = null; let deafenBtn: HTMLButtonElement | null = null; let cameraBtn: HTMLButtonElement | null = null; let shareBtn: HTMLButtonElement | null = null; + let disconnectBtn: HTMLButtonElement | null = null; // Listen-only mode: "Grant Microphone" button let grantMicBtn: HTMLButtonElement | null = null; @@ -168,6 +183,33 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone if (timerEl !== null) setText(timerEl, "00:00"); } + /** Header E2EE status: dynamic label + a persistent "secured" lock once the + * room key is ready (docs/architecture/ux/voice-and-e2ee.md §2). */ + function updateStatus(status: VoiceStatus): void { + if (statusLabel !== null) { + setText(statusLabel, STATUS_LABELS[status]); + statusLabel.classList.toggle("vw-securing", status === "securing"); + statusLabel.classList.toggle("vw-reconnecting", status === "reconnecting"); + } + if (securedBadge !== null) { + securedBadge.style.display = status === "connected" ? "inline-flex" : "none"; + } + } + + /** Freeze voice controls while the WS socket is not live — the controls would + * otherwise send over a down socket (docs/architecture/ux/README.md §3). + * LiveKit's own reconnect keeps retrying underneath; we only gate the UI. */ + function updateFrozen(status: "connected" | "reconnecting" | "disconnected"): void { + const frozen = status !== "connected"; + const reason = status === "reconnecting" ? "Reconnecting…" : "Not connected"; + controlsRow?.classList.toggle("vw-controls--frozen", frozen); + for (const btn of [muteBtn, deafenBtn, cameraBtn, shareBtn, disconnectBtn, grantMicBtn]) { + if (btn === null) continue; + btn.disabled = frozen; + btn.title = frozen ? reason : ""; + } + } + function render(): void { if (root === null || channelNameEl === null) return; @@ -185,6 +227,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone root.classList.add("visible"); startStatsPoller(); startElapsedTimer(); + updateStatus(voice.voiceStatus); + updateFrozen(uiStore.getState().connectionStatus); // Channel name const channel = channelsStore.getState().channels.get(channelId); @@ -244,9 +288,21 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone function mount(container: Element): void { root = createElement("div", { class: "voice-widget", "data-testid": "voice-widget" }); - // Header row: "Voice Connected" + channel name + signal icon + // Header row: lifecycle status + secured lock + channel name + signal icon const header = createElement("div", { class: "vw-header" }); - const connLabel = createElement("span", { class: "vw-connected" }, "Voice Connected"); + statusLabel = createElement("span", { + class: "vw-connected", + "data-testid": "vw-status", + }); + setText(statusLabel, STATUS_LABELS.connected); + // Persistent E2EE affirmation, shown only once the room key is ready. + securedBadge = createElement("span", { + class: "vw-secured", + "data-testid": "vw-secured", + title: "End-to-end encrypted", + }); + setText(securedBadge, "🔒 Secured"); + securedBadge.style.display = "none"; timerEl = createElement("span", { class: "vw-timer" }, "00:00"); channelNameEl = createElement("span", { class: "vw-channel" }, "Voice Channel"); @@ -263,7 +319,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone { signal: ac.signal }, ); - appendChildren(header, connLabel, timerEl, channelNameEl, signalWrap); + appendChildren(header, statusLabel, securedBadge, timerEl, channelNameEl, signalWrap); // Expanded stats pane (hidden by default) statsPane = createElement("div", { class: "vw-stats" }); @@ -326,6 +382,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone // Controls row const controls = createElement("div", { class: "vw-controls" }); + controlsRow = controls; muteBtn = createControlButton("Mute", "mic", options.onMuteToggle); deafenBtn = createControlButton("Deafen", "headphones", options.onDeafenToggle); cameraBtn = createControlButton("Camera", "camera", options.onCameraToggle); @@ -338,12 +395,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone const shareLabelSpan = createElement("span", { class: "vw-share-label" }); shareLabelSpan.style.display = "none"; shareBtn.appendChild(shareLabelSpan); - const disconnectBtn = createControlButton( - "Disconnect", - "phone", - options.onDisconnect, - "disconnect", - ); + disconnectBtn = createControlButton("Disconnect", "phone", options.onDisconnect, "disconnect"); appendChildren(controls, muteBtn, deafenBtn, cameraBtn, shareBtn, disconnectBtn); // "Grant Microphone" button for listen-only mode @@ -386,6 +438,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone camera: s.localCamera, screenshare: s.localScreenshare, listenOnly: s.listenOnly, + voiceStatus: s.voiceStatus, }), () => render(), (a, b) => @@ -394,7 +447,15 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone a.deafened === b.deafened && a.camera === b.camera && a.screenshare === b.screenshare && - a.listenOnly === b.listenOnly, + a.listenOnly === b.listenOnly && + a.voiceStatus === b.voiceStatus, + ), + ); + // Freeze controls reactively when the WS socket drops (§3 connection status). + unsubs.push( + uiStore.subscribeSelector( + (s) => s.connectionStatus, + () => render(), ), ); unsubs.push( @@ -418,10 +479,14 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone root?.remove(); root = null; channelNameEl = null; + statusLabel = null; + securedBadge = null; + controlsRow = null; muteBtn = null; deafenBtn = null; cameraBtn = null; shareBtn = null; + disconnectBtn = null; grantMicBtn = null; signalWrap = null; pingLabel = null; diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 669ed9f9..3af6682d 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -9,6 +9,7 @@ import { setLocalScreenshare, leaveVoiceChannel, setListenOnly, + setVoiceStatus, } from "@stores/voice.store"; import { authStore } from "@stores/auth.store"; import { loadPref } from "@components/settings/helpers"; @@ -292,6 +293,7 @@ export class LiveKitSession { lastDirectUrl, ac, }); + setVoiceStatus("reconnecting"); } // ac === null: reconnect succeeded — connectAndSetup already set "connected". // No transition needed; just discard stale pending fields if any. @@ -486,6 +488,7 @@ export class LiveKitSession { lastUrl: url, lastDirectUrl: directUrl, }); + setVoiceStatus("connected"); this._deviceManager.setOnError(this.onErrorCallback); this._deviceManager.setOnToast(this.onErrorCallback); logIceConnectionInfo(newRoom); @@ -803,6 +806,8 @@ export class LiveKitSession { const prevGeneration = prevState.type === "connecting" ? prevState.joinGeneration : 0; const myGeneration = prevGeneration + 1; this.setState({ type: "connecting", pendingJoin: null, joinGeneration: myGeneration }); + // "joining" = connecting to the room; the E2EE "securing" phase is set below. + setVoiceStatus("joining"); let resolvedUrl = ""; // Track the room being built in this attempt so we can disconnect it on // supersession without touching the shared state (which may already have @@ -832,6 +837,10 @@ export class LiveKitSession { const RETRY_DELAY_MS = 2000; // ── Client-side E2EE key exchange (ECDH) ────────────────────────── + // "securing" — until the room key is ready the call is not yet private. + // Non-key-holders block here waiting for the key holder's offer (up to + // ~15s); key holders pass through near-instantly. + setVoiceStatus("securing"); // Generate a fresh ECDH keypair for this session. this._ecdhKeyPair = await generateECDHKeyPair(); this._peerPublicKeys.clear(); @@ -999,6 +1008,8 @@ export class LiveKitSession { lastUrl: url, lastDirectUrl: directUrl, }); + // Room connected and E2EE key ready — the call is now secured. + setVoiceStatus("connected"); // Optimistic startAudio — may succeed if the join was triggered by a // recent user gesture. If not, the AudioPlaybackStatusChanged handler // will register a click-to-unlock fallback. @@ -1500,6 +1511,7 @@ export class LiveKitSession { // pendingJoin, and the joinGeneration (idle has none). Any in-flight // connectAndSetup() will detect the state type change at its next checkpoint. this.setState({ type: "idle" }); + setVoiceStatus("idle"); this.syncModuleRooms(); setLocalCamera(false); setLocalScreenshare(false); diff --git a/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts b/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts index 1899253a..4f80887b 100644 --- a/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts +++ b/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts @@ -6,6 +6,7 @@ import { createLogger } from "@lib/logger"; import type { WsClient } from "@lib/ws"; import { voiceStore, joinVoiceChannel, leaveVoiceChannel } from "@stores/voice.store"; +import { uiStore } from "@stores/ui.store"; import { leaveVoice as voiceSessionLeave, setMuted as voiceSessionSetMuted, @@ -18,6 +19,14 @@ import { const log = createLogger("voice-callbacks"); +/** Voice join/leave send over the WS socket; refuse when it's not live so we + * never fire voice_join/voice_leave into a down socket. The VoiceWidget freezes + * its controls with a visible reason (docs/architecture/ux/README.md §3); this + * is the defensive backstop for the sidebar join/leave path. */ +function socketLive(): boolean { + return uiStore.getState().connectionStatus === "connected"; +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -51,6 +60,7 @@ export function createVoiceWidgetCallbacks( return { onDisconnect: () => { if (voiceStore.getState().currentChannelId === null) return; + if (!socketLive()) return; log.info("Leaving voice channel (widget disconnect)"); voiceSessionLeave(false); leaveVoiceChannel(); @@ -122,11 +132,13 @@ export function createVoiceWidgetCallbacks( export function createSidebarVoiceCallbacks(ws: WsClient): SidebarVoiceCallbacks { return { onVoiceJoin: (channelId: number) => { + if (!socketLive()) return; log.info("Joining voice channel", { channelId }); joinVoiceChannel(channelId); ws.send({ type: "voice_join", payload: { channel_id: channelId } }); }, onVoiceLeave: () => { + if (!socketLive()) return; log.info("Leaving voice channel"); voiceSessionLeave(false); leaveVoiceChannel(); diff --git a/Client/tauri-client/src/stores/voice.store.ts b/Client/tauri-client/src/stores/voice.store.ts index 7e816e99..2dbb28f4 100644 --- a/Client/tauri-client/src/stores/voice.store.ts +++ b/Client/tauri-client/src/stores/voice.store.ts @@ -24,6 +24,12 @@ export interface VoiceUser { readonly screenshare: boolean; } +/** Observable voice-session lifecycle status, surfaced so the UI can + * distinguish "connecting to the room" (joining) from "securing E2EE" + * (securing) from a live, encrypted call (connected). Written from + * livekitSession.ts. See docs/architecture/ux/voice-and-e2ee.md §1–2. */ +export type VoiceStatus = "idle" | "joining" | "securing" | "connected" | "reconnecting"; + export interface VoiceConfig { readonly quality: string; readonly bitrate: number; @@ -45,6 +51,9 @@ export interface VoiceState { readonly joinedAt: number | null; /** True when joined in listen-only mode (mic permission denied or no mic found). */ readonly listenOnly: boolean; + /** Voice-session lifecycle status (drives the widget's connecting/securing/ + * secured indicators). Written from livekitSession.ts. */ + readonly voiceStatus: VoiceStatus; } const INITIAL_STATE: VoiceState = { @@ -57,6 +66,7 @@ const INITIAL_STATE: VoiceState = { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", }; export const voiceStore = createStore(INITIAL_STATE); @@ -73,6 +83,7 @@ export function resetVoiceStore(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); } @@ -173,6 +184,9 @@ export function joinVoiceChannel(channelId: number): void { ...prev, currentChannelId: channelId, joinedAt: Date.now(), + // Optimistic: the widget shows "Connecting…" the moment the user clicks, + // before the voice_token round-trip. livekitSession advances it from here. + voiceStatus: "joining", }; }); } @@ -183,11 +197,11 @@ export function leaveVoiceChannel(): void { voiceStore.setState((prev) => { const channelId = prev.currentChannelId; if (channelId === null || currentUserId === 0) { - return { ...prev, currentChannelId: null, joinedAt: null }; + return { ...prev, currentChannelId: null, joinedAt: null, voiceStatus: "idle" }; } const existingChannel = prev.voiceUsers.get(channelId); if (!existingChannel || !existingChannel.has(currentUserId)) { - return { ...prev, currentChannelId: null, joinedAt: null }; + return { ...prev, currentChannelId: null, joinedAt: null, voiceStatus: "idle" }; } const nextChannels = new Map(prev.voiceUsers); const nextUsers = new Map(existingChannel); @@ -197,10 +211,24 @@ export function leaveVoiceChannel(): void { } else { nextChannels.set(channelId, nextUsers); } - return { ...prev, currentChannelId: null, joinedAt: null, voiceUsers: nextChannels }; + return { + ...prev, + currentChannelId: null, + joinedAt: null, + voiceStatus: "idle", + voiceUsers: nextChannels, + }; }); } +/** Set the voice-session lifecycle status. Single-writer from livekitSession.ts + * at each connection-lifecycle transition. */ +export function setVoiceStatus(status: VoiceStatus): void { + voiceStore.setState((prev) => + prev.voiceStatus === status ? prev : { ...prev, voiceStatus: status }, + ); +} + /** Toggle local mute state. */ export function setLocalMuted(muted: boolean): void { voiceStore.setState((prev) => ({ diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 36d42cc0..6fa844e5 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -217,8 +217,17 @@ .voice-widget.visible { display: block; } .vw-header { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; } .vw-connected { color: var(--green); font-weight: 700; } +/* In-progress lifecycle states use amber; the call is not yet secured/live. */ +.vw-connected.vw-securing, .vw-connected.vw-reconnecting { color: var(--yellow, #f0b232); } +.vw-secured { + display: inline-flex; align-items: center; gap: 3px; + color: var(--green); font-size: 11px; font-weight: 600; +} .vw-timer { color: var(--green); font-size: 11px; opacity: 0.8; font-variant-numeric: tabular-nums; } .vw-channel { color: var(--text-muted); } +/* Frozen while the WS socket is down: controls are visibly non-interactive. */ +.vw-controls--frozen button { opacity: 0.5; cursor: not-allowed; } +.vw-controls--frozen button:hover { background: var(--bg-active); color: var(--text-muted); } .vw-controls { display: flex; gap: 4px; padding: 4px 4px 0; } .vw-controls button { flex: 1; height: 32px; border-radius: var(--radius-sm);