Files
OwnCord/Client/tauri-client/src/lib/livekitSession.ts
T
J3vb c3a8aa477c fix: resolve 20 code review bugs across Rust, TypeScript, and Go
Critical/High Rust (Tauri client):
- BUG-140: replace .run() with .build() + RunEvent::Exit handler; native error dialog on startup failure
- BUG-141: eliminate PTT thread TOCTOU race with Mutex critical section; add AtomicBool shutdown and catch_unwind
- BUG-144: fix TOFU cert store corruption — read-before-write rollback restores previous fingerprint on save failure (all 3 write sites)
- BUG-145: add VK code range guard (1..=254) in is_key_down; fix cast to (state as i16) < 0
- BUG-147: replace bare spawns with JoinSet; abort_all + drain on exit; unconditional closed event
- BUG-150: add CRLF guard in handle_connection before header rewriting
- BUG-151: wrap header read loop in tokio::time::timeout(10s)
- BUG-158: extract CERTS_STORE/SETTINGS_STORE to constants.rs (eliminate 3 duplicates)
- HIGH-2: PTT thread self-cleanup uses unwrap_or_else defensive pattern
- HIGH-4: ws_send distinguishes Full vs Closed errors; warn log on backpressure

Critical/High TypeScript (Tauri client):
- BUG-142: join-generation counter prevents stale connectAndSetup completions
- BUG-143: replace 8 mutable LiveKit session fields with discriminated union SessionState
- BUG-146: 60s token refresh deadline; cleared on reply or voice leave
- BUG-148: ResizeObserver hoisted to outer scope; disconnect() in destroy() before ac.abort()
- BUG-152: dismissSignal.aborted guard already present (no change needed)
- BUG-153: measureRendered split into two-pass read-then-write; eliminates per-message reflow
- BUG-154: WS dedup cache batch-evicts to 80% on overflow (amortised O(1))
- BUG-157: pendingUpdates replaced with coalesced function-composition slot (O(1) queue depth)

Go server:
- BUG-149: safe two-value type assertion in getOutboundIP with localhost fallback
- BUG-155: broadcast buffer 256→1024; broadcastDrops atomic counter exposed in /api/v1/metrics
- BUG-156: LiveKitHealthCheck and implementations accept ctx context.Context; all call sites pass r.Context() (12 files)
- BUG-159: MaxMessageBytes constant in config/constants.go; replaces 1<<20 literals in serve.go and updater.go
- HIGH-1: cert store rollback reads old value before write; restores previous cert on save failure

All validation passes: go build, go vet, cargo check, npm typecheck
2026-04-03 23:18:06 +02:00

1224 lines
49 KiB
TypeScript

// LiveKit Session — lifecycle orchestrator for voice chat via LiveKit
import { Room, RoomEvent } from "livekit-client";
import type { WsClient } from "@lib/ws";
import {
voiceStore,
setLocalMuted,
setLocalDeafened,
setLocalCamera,
setLocalScreenshare,
leaveVoiceChannel,
setListenOnly,
} from "@stores/voice.store";
import { loadPref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
import { invoke } from "@tauri-apps/api/core";
import { AudioPipeline } from "@lib/audioPipeline";
import { AudioElements } from "@lib/audioElements";
import { DeviceManager } from "@lib/deviceManager";
import {
type VideoTrackDeps,
type CameraTrackState,
type ScreenTrackState,
CAMERA_PRESETS,
CAMERA_PUBLISH_BITRATES,
SCREENSHARE_PUBLISH_BITRATES,
getStreamQuality,
enableCamera as doEnableCamera,
disableCamera as doDisableCamera,
stopManualCameraTrack,
enableScreenshare as doEnableScreenshare,
disableScreenshare as doDisableScreenshare,
stopManualScreenTracks,
getLocalCameraStream as doGetLocalCameraStream,
getLocalScreenshareStream as doGetLocalScreenshareStream,
getRemoteVideoStream as doGetRemoteVideoStream,
} from "@lib/screenShare";
import {
logIceConnectionInfo,
buildSessionDebugInfo,
attachDiagnosticListeners,
} from "@lib/livekitDiagnostics";
import { createRoomEventHandlers, type RoomEventHandlers } from "@lib/roomEventHandlers";
// Re-export StreamQuality so existing consumers don't break
export type { StreamQuality } from "@lib/screenShare";
const log = createLogger("livekitSession");
// --- Pure helpers (no instance state) ---
/** Parse userId from LiveKit participant identity "user-{id}" or "user-{id}:{token}". Returns 0 if unparseable. */
export function parseUserId(identity: string): number {
const match = identity.match(/^user-(\d+)(?::|$)/);
if (match !== null && match[1] !== undefined) return parseInt(match[1], 10);
return 0;
}
// --- Types ---
export type RemoteVideoCallback = (
userId: number,
stream: MediaStream,
isScreenshare: boolean,
) => void;
export type RemoteVideoRemovedCallback = (userId: number, isScreenshare: boolean) => void;
type PendingVoiceJoin = {
readonly token: string;
readonly url: string;
readonly channelId: number;
readonly directUrl?: string;
};
// --- State machine ---
/** Discriminated-union session state. All connection-lifecycle fields live here.
* The "connecting" variant also carries the BUG-142 monotonic generation counter
* (joinGeneration) so superseded-join detection is co-located with the state. */
type SessionState =
| { readonly type: "idle" }
| {
readonly type: "connecting";
readonly pendingJoin: PendingVoiceJoin | null;
readonly joinGeneration: number;
}
| {
readonly type: "connected";
readonly room: Room;
readonly channelId: number;
readonly latestToken: string;
readonly lastUrl: string;
readonly lastDirectUrl: string | undefined;
}
| {
readonly type: "reconnecting";
readonly channelId: number;
readonly latestToken: string;
readonly lastUrl: string;
readonly lastDirectUrl: string | undefined;
readonly ac: AbortController;
};
// --- LiveKitSession class ---
export class LiveKitSession {
/** Single source of truth for all connection-lifecycle state. */
private _state: SessionState = { type: "idle" };
// --- Non-connection fields (configuration / callbacks / infrastructure) ---
private ws: WsClient | null = null;
private onErrorCallback: ((message: string) => void) | null = null;
private serverHost: string | null = null;
private onRemoteVideoCallback: RemoteVideoCallback | null = null;
private onRemoteVideoRemovedCallback: RemoteVideoRemovedCallback | null = null;
private tokenRefreshTimer: ReturnType<typeof setTimeout> | null = null;
/** BUG-146: Guard timer — fires if the server never responds to voice_token_refresh. */
private tokenRefreshTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
/** Max auto-reconnect attempts before giving up and showing error. */
private static readonly MAX_RECONNECT_ATTEMPTS = 2;
private static readonly RECONNECT_DELAY_MS = 3000;
/** Master output volume multiplier (0-2.0). Per-user volumes are scaled by this. */
private outputVolumeMultiplier = loadPref<number>("outputVolume", 100) / 100;
/** Cached port for the local LiveKit TLS proxy (Rust-side, for self-signed cert support). */
private liveKitProxyPort: number | null = null;
// --- State transition (single writer) ---
private setState(next: SessionState): void {
const prev = this._state.type;
this._state = next;
log.debug("Session state transition", { from: prev, to: next.type });
}
// --- Typed state accessors (replace scattered field reads) ---
/** Room from state, or null when idle/connecting/reconnecting. */
private get _room(): Room | null {
return this._state.type === "connected" ? this._state.room : null;
}
/** Channel ID from state, or null when idle/connecting. */
private get _currentChannelId(): number | null {
return this._state.type === "connected" || this._state.type === "reconnecting"
? this._state.channelId
: null;
}
/** Latest token from state, or null when idle/connecting. */
private get _latestToken(): string | null {
return this._state.type === "connected" || this._state.type === "reconnecting"
? this._state.latestToken
: null;
}
/** Last URL from state, or null when idle/connecting. */
private get _lastUrl(): string | null {
return this._state.type === "connected" || this._state.type === "reconnecting"
? this._state.lastUrl
: null;
}
/** Last direct URL from state. */
private get _lastDirectUrl(): string | undefined {
return this._state.type === "connected" || this._state.type === "reconnecting"
? this._state.lastDirectUrl
: undefined;
}
/** True while a connect attempt is running. */
private get _connecting(): boolean {
return this._state.type === "connecting";
}
/** The abort controller for an in-flight reconnect, or null. */
private get _reconnectAc(): AbortController | null {
return this._state.type === "reconnecting" ? this._state.ac : null;
}
/** Helper to check state is "connected" for a specific channelId, reading
* through a method call so TS control-flow narrowing cannot cache the result.
* Used in connectAndSetup() checkpoints after setState() transitions. */
private isStateConnected(channelId: number): boolean {
const s: SessionState = this._state;
return s.type === "connected" && s.channelId === channelId;
}
// --- Extracted modules (facade pattern) ---
private _audioPipeline = new AudioPipeline();
private _audioElements = new AudioElements();
private _deviceManager = new DeviceManager();
private _eventHandlers: RoomEventHandlers;
/** Manually published local tracks (camera/screenshare) for explicit cleanup. */
private _cameraState: CameraTrackState = { manualCameraTrack: null };
private _screenState: ScreenTrackState = { manualScreenTracks: [] };
/** Lazily built deps for the extracted video track functions. */
private get _videoTrackDeps(): VideoTrackDeps {
return {
getRoom: () => this._room,
getWs: () => this.ws,
onError: (msg) => {
this.onErrorCallback?.(msg);
},
reapplyAudioPipeline: () => {
this._audioPipeline.setupAudioPipeline();
this.reapplyMuteGain();
},
};
}
constructor() {
this._eventHandlers = createRoomEventHandlers({
getRoom: () => this._room,
setRoom: (r) => {
// Called by handleDisconnected immediately before setReconnectAc.
// Capture the reconnect fields from the current "connected" state
// while we still have them, then clear the room (transition to idle).
// setReconnectAc will pick up _pendingReconnectFields to form the
// "reconnecting" state atomically.
if (r === null && this._state.type === "connected") {
this._pendingReconnectFields = {
channelId: this._state.channelId,
latestToken: this._state.latestToken,
lastUrl: this._state.lastUrl,
lastDirectUrl: this._state.lastDirectUrl,
};
this.setState({ type: "idle" });
}
},
getCurrentChannelId: () => this._currentChannelId,
getAudioElements: () => this._audioElements,
getOnRemoteVideoCallback: () => this.onRemoteVideoCallback,
getOnRemoteVideoRemovedCallback: () => this.onRemoteVideoRemovedCallback,
getOnErrorCallback: () => this.onErrorCallback,
isConnecting: () => this._connecting,
getLatestToken: () => this._latestToken,
getLastUrl: () => this._lastUrl,
getLastDirectUrl: () => this._lastDirectUrl,
setReconnectAc: (ac) => {
if (ac !== null && this._pendingReconnectFields !== null) {
// Transition from idle → reconnecting atomically using the fields
// captured in setRoom() above.
const { channelId, latestToken, lastUrl, lastDirectUrl } = this._pendingReconnectFields;
this._pendingReconnectFields = null;
this.setState({
type: "reconnecting",
channelId,
latestToken,
lastUrl,
lastDirectUrl,
ac,
});
}
// ac === null: reconnect succeeded — connectAndSetup already set "connected".
// No transition needed; just discard stale pending fields if any.
if (ac === null) {
this._pendingReconnectFields = null;
}
},
syncModuleRooms: () => this.syncModuleRooms(),
teardownForReconnect: () => {
this._audioPipeline.teardownAudioPipeline();
this.clearTokenRefreshTimer();
// BUG-098: Stop leaked camera/screen tracks before room is nulled.
stopManualCameraTrack(this._cameraState, this._room);
stopManualScreenTracks(this._screenState, this._room);
setLocalCamera(false);
setLocalScreenshare(false);
},
leaveVoice: (sendWs) => this.leaveVoice(sendWs),
applyMicMuteState: (muted) => this.applyMicMuteState(muted),
attemptAutoReconnect: (token, url, channelId, directUrl, signal) =>
this.attemptAutoReconnect(token, url, channelId, directUrl, signal),
});
}
/** Temporary holding field: populated by setRoom(null) in handleDisconnected's
* callback sequence so setReconnectAc can form the "reconnecting" state atomically. */
private _pendingReconnectFields: {
channelId: number;
latestToken: string;
lastUrl: string;
lastDirectUrl: string | undefined;
} | null = null;
// --- Room factory ---
private createRoom(): Room {
const quality = getStreamQuality();
const isSource = quality === "source";
const newRoom = new Room({
// Adaptive features reduce quality based on subscriber viewport —
// disable for "source" quality to maintain full resolution.
adaptiveStream: !isSource,
dynacast: !isSource,
audioCaptureDefaults: {
echoCancellation: loadPref("echoCancellation", true),
noiseSuppression: loadPref("noiseSuppression", true),
autoGainControl: loadPref("autoGainControl", true),
},
videoCaptureDefaults: CAMERA_PRESETS[quality],
publishDefaults: {
videoEncoding: {
maxBitrate: CAMERA_PUBLISH_BITRATES[quality],
maxFramerate: quality === "low" ? 15 : 30,
},
screenShareEncoding: {
maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality],
maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30,
},
},
});
newRoom.on(RoomEvent.TrackSubscribed, this._eventHandlers.handleTrackSubscribed);
newRoom.on(RoomEvent.TrackUnsubscribed, this._eventHandlers.handleTrackUnsubscribed);
newRoom.on(RoomEvent.Disconnected, this._eventHandlers.handleDisconnected);
newRoom.on(RoomEvent.ActiveSpeakersChanged, this._eventHandlers.handleActiveSpeakersChanged);
newRoom.on(
RoomEvent.AudioPlaybackStatusChanged,
this._eventHandlers.handleAudioPlaybackChanged,
);
newRoom.on(RoomEvent.LocalTrackPublished, this._eventHandlers.handleLocalTrackPublished);
attachDiagnosticListeners(newRoom);
return newRoom;
}
// --- Module wiring helper ---
/** Update all extracted modules with the current room reference. */
private syncModuleRooms(): void {
const room = this._room;
this._audioPipeline.setRoom(room);
this._audioElements.setRoom(room);
this._deviceManager.setRoom(room);
this._deviceManager.setAudioPipeline(room !== null ? this._audioPipeline : null);
this._deviceManager.setOnError(this.onErrorCallback);
this._deviceManager.setOnToast(this.onErrorCallback);
}
/** Attempt to auto-reconnect after unexpected disconnect using stored token.
* The signal is aborted by leaveVoice() to cancel the loop when the user
* voluntarily leaves voice during the reconnect delay. */
private async attemptAutoReconnect(
token: string,
url: string,
channelId: number,
directUrl: string | undefined,
signal: AbortSignal,
): Promise<void> {
for (let attempt = 1; attempt <= LiveKitSession.MAX_RECONNECT_ATTEMPTS; attempt++) {
log.info("Auto-reconnect attempt", {
attempt,
maxAttempts: LiveKitSession.MAX_RECONNECT_ATTEMPTS,
});
// eslint-disable-next-line no-await-in-loop -- intentional sequential polling with backoff delay
await new Promise((r) => setTimeout(r, LiveKitSession.RECONNECT_DELAY_MS));
// If user manually left or joined a different channel during the delay, abort.
if (signal.aborted || this._currentChannelId !== channelId) {
log.info("Auto-reconnect aborted — user left or channel changed");
return;
}
try {
const newRoom = this.createRoom();
// Set state to reconnecting with the fresh room-less attempt info;
// the actual room appears in "connected" state after connect succeeds.
if (this._state.type === "reconnecting") {
this.setState({ ...this._state, ac: this._state.ac });
}
this._audioPipeline.setRoom(newRoom);
this._audioElements.setRoom(newRoom);
this._deviceManager.setRoom(newRoom);
this._deviceManager.setAudioPipeline(this._audioPipeline);
// eslint-disable-next-line no-await-in-loop -- sequential reconnect: resolve URL then connect
const resolvedUrl = await this.resolveLiveKitUrl(url, directUrl);
// eslint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state
await newRoom.connect(resolvedUrl, token);
log.info("Auto-reconnect succeeded", { attempt, channelId, url: resolvedUrl });
// Transition to "connected" — this is the single atomic write.
this.setState({
type: "connected",
room: newRoom,
channelId,
latestToken: token,
lastUrl: url,
lastDirectUrl: directUrl,
});
this._deviceManager.setOnError(this.onErrorCallback);
this._deviceManager.setOnToast(this.onErrorCallback);
logIceConnectionInfo(newRoom);
newRoom
.startAudio()
.catch((err) => log.debug("Failed to start audio after reconnect", err));
// eslint-disable-next-line no-await-in-loop -- sequential reconnect: must restore voice state after connect
await this.restoreLocalVoiceState("reconnect");
// BUG-099: Reapply saved audio devices after reconnect (matches initial join path).
const savedInput = loadPref<string>("audioInputDevice", "");
if (savedInput) {
try {
await newRoom.switchActiveDevice("audioinput", savedInput);
} catch (err) {
log.warn("Reconnect: saved input device unavailable, using default", err);
}
}
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput) {
try {
await newRoom.switchActiveDevice("audiooutput", savedOutput);
} catch (err) {
log.warn("Reconnect: saved output device unavailable, using default", err);
}
}
this._audioPipeline.setupAudioPipeline();
this.reapplyMuteGain();
this.startTokenRefreshTimer();
// Signal the setReconnectAc callback that the reconnect is done.
// ac === null clears the pending state in the callback.
this._pendingReconnectFields = null;
// Request a fresh token since the stored one may be close to expiry.
this.requestTokenRefresh();
return;
} catch (err) {
log.warn("Auto-reconnect failed", { attempt, url, error: err });
const failedRoom = this._room;
if (failedRoom !== null) {
failedRoom.removeAllListeners();
failedRoom
.disconnect()
.catch((disconnectErr) =>
log.warn("Failed to disconnect room after reconnect failure", disconnectErr),
);
}
// Return to idle so the next attempt starts fresh.
if (this._state.type === "reconnecting") {
this.setState({
type: "reconnecting",
channelId: this._state.channelId,
latestToken: this._state.latestToken,
lastUrl: this._state.lastUrl,
lastDirectUrl: this._state.lastDirectUrl,
ac: this._state.ac,
});
}
this._audioPipeline.setRoom(null);
this._audioElements.setRoom(null);
this._deviceManager.setRoom(null);
this._deviceManager.setAudioPipeline(null);
}
}
// All attempts exhausted — give up and clean up.
// Send voice_leave over WS so the server removes our voice state;
// without this the server and other clients see us as a ghost participant.
log.error("Auto-reconnect exhausted all attempts, giving up");
this.leaveVoice(true);
leaveVoiceChannel();
this.onErrorCallback?.("Voice connection lost — failed to reconnect");
}
// --- URL resolution ---
private async resolveLiveKitUrl(proxyPath: string, directUrl?: string): Promise<string> {
if (this.serverHost !== null) {
// Extract hostname, handling IPv6 bracket notation (e.g. "[::1]:7880")
// and bare IPv6 (e.g. "::1").
let host: string;
if (this.serverHost.startsWith("[")) {
host = this.serverHost.slice(1, this.serverHost.indexOf("]"));
} else if ((this.serverHost.match(/:/g) ?? []).length > 1) {
// Bare IPv6 address (multiple colons, no brackets) — use as-is
host = this.serverHost;
} else {
host = this.serverHost.split(":")[0] ?? "";
}
const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
if (isLocal && directUrl) {
log.debug("LiveKit URL resolved via direct (local)", { url: directUrl });
return directUrl;
}
if (proxyPath.startsWith("/")) {
// Remote server: route through the local Rust TLS proxy so WebView2
// doesn't reject self-signed certificates on the LiveKit signal WS.
const port = await this.ensureLiveKitProxy();
const resolved = `ws://127.0.0.1:${port}${proxyPath}`;
log.debug("LiveKit URL resolved via TLS proxy", {
url: resolved,
remoteHost: this.serverHost,
});
return resolved;
}
}
log.debug("LiveKit URL resolved as passthrough", { url: proxyPath });
return proxyPath;
}
/** Start (or reuse) the Rust-side local TCP-to-TLS proxy for LiveKit. */
private async ensureLiveKitProxy(): Promise<number> {
if (this.liveKitProxyPort !== null) return this.liveKitProxyPort;
if (this.serverHost === null) throw new Error("no server host for LiveKit proxy");
// Ensure host:port format — default to 443 (standard HTTPS) when the
// server is behind a reverse proxy. Without an explicit port, the Rust
// proxy would default to 8443 which may not be exposed.
// Handle IPv6: "[::1]:7880" has port, "[::1]" and bare "::1" do not.
let hostWithPort: string;
if (this.serverHost.startsWith("[")) {
// Bracketed IPv6 — check for "]:port" suffix
hostWithPort = this.serverHost.includes("]:") ? this.serverHost : `${this.serverHost}:443`;
} else if ((this.serverHost.match(/:/g) ?? []).length > 1) {
// Bare IPv6 (multiple colons) — wrap in brackets and add default port
hostWithPort = `[${this.serverHost}]:443`;
} else {
hostWithPort = this.serverHost.includes(":") ? this.serverHost : `${this.serverHost}:443`;
}
this.liveKitProxyPort = await invoke<number>("start_livekit_proxy", {
remoteHost: hostWithPort,
});
log.info("LiveKit TLS proxy started on localhost", { port: this.liveKitProxyPort });
return this.liveKitProxyPort;
}
// --- Token refresh ---
/** Token refresh interval: 23 hours (refresh 1h before 24h TTL expiry). */
private static readonly TOKEN_REFRESH_MS = 23 * 60 * 60 * 1000;
private startTokenRefreshTimer(): void {
this.clearTokenRefreshTimer();
this.tokenRefreshTimer = setTimeout(() => {
this.requestTokenRefresh();
}, LiveKitSession.TOKEN_REFRESH_MS);
log.debug("Token refresh timer started", { refreshInMs: LiveKitSession.TOKEN_REFRESH_MS });
}
private clearTokenRefreshTimer(): void {
if (this.tokenRefreshTimer !== null) {
clearTimeout(this.tokenRefreshTimer);
this.tokenRefreshTimer = null;
}
// BUG-146: Also cancel any in-flight refresh response timeout so it does
// not fire after the session is torn down (leaveVoice / cleanupAll both
// call this method, so one clearing point covers all cleanup paths).
if (this.tokenRefreshTimeoutTimer !== null) {
clearTimeout(this.tokenRefreshTimeoutTimer);
this.tokenRefreshTimeoutTimer = null;
}
}
private requestTokenRefresh(): void {
if (this.ws === null || this._room === null) {
log.debug("Skipping token refresh — no active session");
return;
}
log.info("Requesting voice token refresh");
this.ws.send({ type: "voice_token_refresh", payload: {} });
// NOTE: startTokenRefreshTimer is called from handleVoiceTokenRefresh
// (the server response handler), not here, to avoid scheduling two
// competing timers per cycle.
// BUG-146: Arm a 60-second response deadline. If the server never replies,
// the token stalls silently. On timeout we log a warning and reschedule the
// next refresh attempt rather than disconnecting — the current live session
// is unaffected (LiveKit keeps active connections alive beyond token expiry);
// the risk is only that a network blip during the stale window would fail to
// reconnect. Reconnecting for a refresh timeout is intentionally NOT done here
// because the WS connection itself may be degraded; a forced disconnect would
// make the UX worse than leaving the existing (still-valid) token in place.
if (this.tokenRefreshTimeoutTimer !== null) {
clearTimeout(this.tokenRefreshTimeoutTimer);
}
this.tokenRefreshTimeoutTimer = setTimeout(() => {
this.tokenRefreshTimeoutTimer = null;
log.warn(
"Voice token refresh timed out — server did not respond within 60 s. " +
"Rescheduling refresh; existing token remains in use.",
);
// Re-arm the next scheduled refresh so the client keeps trying.
this.startTokenRefreshTimer();
}, 60_000);
}
handleVoiceTokenRefresh(token?: string): void {
// BUG-146: Cancel the response-deadline timer — the server replied in time.
if (this.tokenRefreshTimeoutTimer !== null) {
clearTimeout(this.tokenRefreshTimeoutTimer);
this.tokenRefreshTimeoutTimer = null;
}
// KNOWN LIMITATION: The livekit-client SDK does not expose a method to
// rotate the token on an active connection. We store the fresh token so
// that reconnection (auto-reconnect or manual rejoin) uses it, but the
// live session continues with the original token. This means:
// - Sessions longer than the 4h TTL remain connected (LiveKit keeps
// active connections alive) but lose the ability to reconnect after a
// network blip once the original token expires.
// - The 23h refresh timer ensures a fresh token is always ready
// *before* the original expires, so reconnects within the window work.
// See also: Server/ws/livekit.go tokenTTL constant.
if (token && this._state.type === "connected") {
this.setState({ ...this._state, latestToken: token });
} else if (token && this._state.type === "reconnecting") {
this.setState({ ...this._state, latestToken: token });
}
this.startTokenRefreshTimer();
log.info("Voice token refreshed, timer restarted");
}
// --- Volume helpers ---
private async restoreLocalVoiceState(mode: "join" | "reconnect"): Promise<void> {
const room = this._room;
if (room === null) return;
const state = voiceStore.getState();
const muted = state.localMuted || state.localDeafened;
const deafened = state.localDeafened;
const shouldEnableMicrophone = !muted;
try {
await room.localParticipant.setMicrophoneEnabled(shouldEnableMicrophone);
if (shouldEnableMicrophone) {
log.info(
mode === "join"
? "Published mic via LiveKit native capture"
: "Auto-reconnect restored live microphone",
);
if (loadPref<boolean>("enhancedNoiseSuppression", false)) {
await this._audioPipeline.applyNoiseSuppressor();
}
}
setListenOnly(false); // Mic acquired successfully
} catch (micErr) {
setListenOnly(true);
if (mode === "reconnect") {
log.warn("Auto-reconnect: mic unavailable — listen-only mode", micErr);
} else if (micErr instanceof DOMException && micErr.name === "NotAllowedError") {
log.warn("Microphone permission denied — joined in listen-only mode");
this.onErrorCallback?.("Microphone permission denied — joined in listen-only mode");
} else if (micErr instanceof DOMException && micErr.name === "NotFoundError") {
log.warn("No microphone found — joined in listen-only mode");
this.onErrorCallback?.("No microphone found — joined in listen-only mode");
} else {
log.warn("Microphone unavailable — joined in listen-only mode", micErr);
this.onErrorCallback?.("Microphone unavailable — joined in listen-only mode");
}
}
// Always enforce mute at the track level even if no pipeline exists yet.
// setMicrophoneEnabled(false) doesn't guarantee mediaStreamTrack.enabled=false,
// and renegotiation when a new participant joins can bring a track back alive.
if (muted) {
this.applyMicMuteState(true).catch((e) =>
log.warn("applyMicMuteState failed in restoreLocalVoiceState", e),
);
}
this._audioElements.applyRemoteAudioSubscriptionState(deafened);
}
// --- Public API ---
setWsClient(client: WsClient): void {
this.ws = client;
}
setServerHost(host: string): void {
this.serverHost = host;
}
setOnError(cb: (message: string) => void): void {
this.onErrorCallback = cb;
this._deviceManager.setOnError(cb);
}
clearOnError(): void {
this.onErrorCallback = null;
this._deviceManager.setOnError(null);
}
setOnRemoteVideo(cb: RemoteVideoCallback): void {
this.onRemoteVideoCallback = cb;
}
setOnRemoteVideoRemoved(cb: RemoteVideoRemovedCallback): void {
this.onRemoteVideoRemovedCallback = cb;
}
clearOnRemoteVideo(): void {
this.onRemoteVideoCallback = null;
this.onRemoteVideoRemovedCallback = null;
}
/** Shared connect-with-retry + post-connect setup used by both the primary
* handleVoiceToken path and the pending-join drain loop.
* Returns true if the room ended up connected and set up,
* false on error, or "superseded" if a newer join generation invalidated
* this attempt (caller should re-read pendingJoin immediately). */
private async connectAndSetup(
token: string,
url: string,
channelId: number,
directUrl?: string,
): Promise<boolean | "superseded"> {
if (this._room !== null) this.leaveVoice(false);
// Increment the generation counter and embed it into the "connecting" state.
// Any newer call to connectAndSetup() will produce a larger generation,
// making myGeneration !== currentGeneration at each checkpoint.
const prevState = this._state;
const prevGeneration = prevState.type === "connecting" ? prevState.joinGeneration : 0;
const myGeneration = prevGeneration + 1;
this.setState({ type: "connecting", pendingJoin: null, joinGeneration: myGeneration });
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
// been claimed by a newer attempt).
let localRoom: Room | null = null;
try {
localRoom = this.createRoom();
this._audioPipeline.setRoom(localRoom);
this._audioElements.setRoom(localRoom);
this._deviceManager.setRoom(localRoom);
this._deviceManager.setAudioPipeline(this._audioPipeline);
this._deviceManager.setOnError(this.onErrorCallback);
this._deviceManager.setOnToast(this.onErrorCallback);
resolvedUrl = await this.resolveLiveKitUrl(url, directUrl);
// Checkpoint 1: after URL resolution (may be slow for TLS proxy init).
if (this._state.type !== "connecting" || this._state.joinGeneration !== myGeneration) {
log.info("connectAndSetup: superseded after URL resolution — aborting", {
channelId,
myGeneration,
currentGeneration: this._state.type === "connecting" ? this._state.joinGeneration : "n/a",
});
return "superseded";
}
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
// eslint-disable-next-line no-await-in-loop -- sequential retry: must attempt connect before checking result
await localRoom.connect(resolvedUrl, token);
// Checkpoint 2: after room.connect() — the primary race window.
if (this._state.type !== "connecting" || this._state.joinGeneration !== myGeneration) {
log.info("connectAndSetup: superseded after room.connect() — aborting", {
channelId,
myGeneration,
currentGeneration:
this._state.type === "connecting" ? this._state.joinGeneration : "n/a",
});
localRoom.removeAllListeners();
localRoom
.disconnect()
.catch((err) => log.debug("Failed to disconnect superseded room", err));
return "superseded";
}
// Belt-and-suspenders: also keep existing pending-join token check
// for logging clarity when a newer request arrived via pendingJoin.
const queuedJoin = this._state.type === "connecting" ? this._state.pendingJoin : null;
if (
queuedJoin !== null &&
(queuedJoin.token !== token ||
queuedJoin.url !== url ||
queuedJoin.channelId !== channelId ||
queuedJoin.directUrl !== directUrl)
) {
log.info("Discarding stale voice join in favor of queued request", {
channelId,
queuedChannelId: queuedJoin.channelId,
});
localRoom.removeAllListeners();
localRoom
.disconnect()
.catch((err) => log.debug("Failed to disconnect room during cleanup", err));
localRoom = null;
this._audioPipeline.setRoom(null);
this._audioElements.setRoom(null);
this._deviceManager.setRoom(null);
this._deviceManager.setAudioPipeline(null);
break;
}
break;
} catch (connectErr) {
if (attempt < MAX_RETRIES) {
log.warn("LiveKit connect failed, retrying", {
attempt,
maxRetries: MAX_RETRIES,
url: resolvedUrl,
error: connectErr,
});
// eslint-disable-next-line no-await-in-loop -- intentional backoff delay between retry attempts
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
// Generation check inside retry loop: a superseding join may arrive
// during the backoff delay.
if (this._state.type !== "connecting" || this._state.joinGeneration !== myGeneration) {
log.info("connectAndSetup: superseded during retry backoff — aborting", {
channelId,
attempt,
});
return "superseded";
}
if (localRoom === null) throw connectErr;
localRoom.removeAllListeners();
localRoom = this.createRoom();
this._audioPipeline.setRoom(localRoom);
this._audioElements.setRoom(localRoom);
this._deviceManager.setRoom(localRoom);
this._deviceManager.setAudioPipeline(this._audioPipeline);
} else {
throw connectErr;
}
}
}
// If the room was discarded (stale join superseded by pending), skip setup.
if (localRoom !== null) {
log.info("Connected to LiveKit room", { channelId, url: resolvedUrl });
logIceConnectionInfo(localRoom);
// Atomic transition to "connected" — all connection fields set together.
this.setState({
type: "connected",
room: localRoom,
channelId,
latestToken: token,
lastUrl: url,
lastDirectUrl: directUrl,
});
// 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.
localRoom.startAudio().catch(() => {
log.debug("Optimistic startAudio failed — waiting for user gesture");
});
await this.restoreLocalVoiceState("join");
// Checkpoint 3: after restoreLocalVoiceState (mic acquisition can be slow).
// Cast to SessionState to escape TS control-flow narrowing that incorrectly
// assumes _state is still "connecting" (it was set to "connected" above, but
// TS cannot see through the setState() opaque method call).
if (!this.isStateConnected(channelId)) {
log.info("connectAndSetup: superseded after restoreLocalVoiceState — aborting", {
channelId,
});
this.leaveVoice(false);
return "superseded";
}
const savedInput = loadPref<string>("audioInputDevice", "");
if (savedInput) {
try {
await localRoom.switchActiveDevice("audioinput", savedInput);
} catch (err) {
log.warn("Saved input device unavailable, using default", err);
}
}
// Checkpoint 4: after audioinput switchActiveDevice.
if (!this.isStateConnected(channelId)) {
log.info("connectAndSetup: superseded after audioinput switch — aborting", {
channelId,
});
this.leaveVoice(false);
return "superseded";
}
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput) {
try {
await localRoom.switchActiveDevice("audiooutput", savedOutput);
} catch (err) {
log.warn("Saved output device unavailable, using default", err);
}
}
// Checkpoint 5: after audiooutput switchActiveDevice.
if (!this.isStateConnected(channelId)) {
log.info("connectAndSetup: superseded after audiooutput switch — aborting", {
channelId,
});
this.leaveVoice(false);
return "superseded";
}
this._audioPipeline.setupAudioPipeline();
this.reapplyMuteGain();
this.startTokenRefreshTimer();
log.info("Voice session active", { channelId });
return true;
}
return false;
} catch (err) {
log.error("Failed to connect to LiveKit", { url: resolvedUrl, error: err });
if (localRoom !== null) {
this.onErrorCallback?.("Failed to join voice — connection error");
}
this.leaveVoice(false);
return false;
} finally {
// Only clear "connecting" back to "idle" if we are still in the connecting
// state for this generation — never overwrite a "connected" state that was
// set by the success path above (guards against risk #4 in the analysis).
if (this._state.type === "connecting" && this._state.joinGeneration === myGeneration) {
this.setState({ type: "idle" });
}
}
}
async handleVoiceToken(
token: string,
url: string,
channelId: number,
directUrl?: string,
): Promise<void> {
const s = this._state;
if (s.type === "connected" && s.channelId === channelId && s.room.state === "connected") {
this.handleVoiceTokenRefresh(token);
return;
}
// Prevent concurrent connect attempts (rapid channel switching).
if (this._connecting) {
// Update the pendingJoin on the existing "connecting" state immutably.
if (this._state.type === "connecting") {
this.setState({
...this._state,
pendingJoin: { token, url, channelId, directUrl },
});
}
log.warn("handleVoiceToken: already connecting, queued latest join request", { channelId });
return;
}
await this.connectAndSetup(token, url, channelId, directUrl);
// Drain pending joins iteratively to avoid unbounded recursion when
// rapid channel switches queue multiple requests.
// A "superseded" result means connectAndSetup() already aborted early;
// we still drain pendingJoin so the latest request always wins.
let pendingJoin = this._state.type === "connecting" ? this._state.pendingJoin : null;
if (this._state.type === "connecting") {
this.setState({ ...this._state, pendingJoin: null });
}
while (pendingJoin !== null) {
const {
token: pToken,
url: pUrl,
channelId: pChannelId,
directUrl: pDirectUrl,
} = pendingJoin;
const cur = this._state;
if (
cur.type === "connected" &&
cur.channelId === pChannelId &&
cur.room.state === "connected"
) {
this.handleVoiceTokenRefresh(pToken);
} else {
// eslint-disable-next-line no-await-in-loop -- sequential drain of pending joins to avoid unbounded recursion
await this.connectAndSetup(pToken, pUrl, pChannelId, pDirectUrl);
// If this attempt was itself superseded (another join arrived during the
// await), the loop will naturally pick it up via the updated pendingJoin.
}
pendingJoin = this._state.type === "connecting" ? this._state.pendingJoin : null;
if (this._state.type === "connecting") {
this.setState({ ...this._state, pendingJoin: null });
}
}
}
/** Retry microphone permission after being in listen-only mode. */
async retryMicPermission(): Promise<void> {
const room = this._room;
if (room === null) return;
try {
await room.localParticipant.setMicrophoneEnabled(true);
setListenOnly(false);
// BUG-103: Honor deafened state — keep mic muted if user is deafened.
const { localDeafened } = voiceStore.getState();
if (localDeafened) {
await this.applyMicMuteState(true);
log.info("Microphone acquired but muted (user is deafened)");
} else {
setLocalMuted(false);
log.info("Microphone permission granted — exited listen-only mode");
}
// Set up audio pipeline for the new mic track
this._audioPipeline.setupAudioPipeline();
if (loadPref<boolean>("enhancedNoiseSuppression", false)) {
await this._audioPipeline.applyNoiseSuppressor();
}
} catch (err) {
log.warn("Microphone retry failed — still in listen-only mode", err);
this.onErrorCallback?.("Microphone still unavailable — check your browser permissions");
}
}
leaveVoice(sendWs = true): void {
// Cancel any pending auto-reconnect loop first.
const ac = this._reconnectAc;
if (ac !== null) {
ac.abort();
}
this._pendingReconnectFields = null;
this.clearTokenRefreshTimer();
this._audioPipeline.teardownAudioPipeline();
this._eventHandlers.removeAutoplayUnlock();
// Clean up manually published tracks.
stopManualCameraTrack(this._cameraState, this._room);
stopManualScreenTracks(this._screenState, this._room);
if (sendWs && this.ws !== null) {
this.ws.send({ type: "voice_leave", payload: {} });
}
// Remove orphaned remote audio elements (normally cleaned up by
// TrackUnsubscribed, but may be missed during rapid reconnection).
// Full cleanup: also clears screenshare mute state on intentional leave.
this._audioElements.cleanupAllAudioElementsFull();
const room = this._room;
if (room !== null) {
room.removeAllListeners();
room.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err));
}
// Transition to idle — atomically clears room, channelId, tokens, reconnectAc,
// 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" });
this.syncModuleRooms();
setLocalCamera(false);
setLocalScreenshare(false);
log.info("Left voice session");
}
cleanupAll(): void {
this.leaveVoice(false);
// leaveVoice() already transitions state to "idle".
// Clear non-connection fields (config / callbacks / infrastructure).
this.onErrorCallback = null;
this.onRemoteVideoCallback = null;
this.onRemoteVideoRemovedCallback = null;
this.ws = null;
this.serverHost = null;
this.liveKitProxyPort = null;
// Stop the Rust-side TLS proxy (fire-and-forget).
invoke("stop_livekit_proxy").catch((err) => log.warn("Failed to stop LiveKit proxy", err));
}
setMuted(muted: boolean): void {
setLocalMuted(muted);
this.applyMicMuteState(muted).catch((e) => log.warn("applyMicMuteState failed", e));
}
setDeafened(deafened: boolean): void {
setLocalDeafened(deafened);
this._audioElements.applyRemoteAudioSubscriptionState(deafened);
const shouldMute = deafened || voiceStore.getState().localMuted;
this.applyMicMuteState(shouldMute).catch((e) => log.warn("applyMicMuteState failed", e));
log.debug("Deafen state changed", { deafened });
}
/** Nuclear mute: fully unpublish the mic track when muting and tear down
* the audio pipeline. Re-publish and rebuild when unmuting. This guarantees
* the SFU has no audio track to forward to other participants. */
private async applyMicMuteState(muted: boolean): Promise<void> {
const room = this._room;
if (room === null) return;
if (muted) {
// Tear down pipeline first so it doesn't hold refs to the track
this._audioPipeline.teardownAudioPipeline();
// Fully disable the mic — this unpublishes the track from the SFU
await room.localParticipant.setMicrophoneEnabled(false);
log.debug("Mic fully unpublished (muted)");
} else {
// Re-enable mic — this re-publishes the track to the SFU
await room.localParticipant.setMicrophoneEnabled(true);
// Rebuild the audio pipeline on the fresh track
this._audioPipeline.setupAudioPipeline();
log.debug("Mic re-published (unmuted)");
}
}
async enableCamera(): Promise<void> {
return doEnableCamera(this._cameraState, this._videoTrackDeps);
}
async disableCamera(): Promise<void> {
return doDisableCamera(this._cameraState, this._videoTrackDeps);
}
async enableScreenshare(): Promise<void> {
return doEnableScreenshare(this._screenState, this._videoTrackDeps);
}
async disableScreenshare(): Promise<void> {
return doDisableScreenshare(this._screenState, this._videoTrackDeps);
}
// --- Delegating methods to DeviceManager ---
async switchInputDevice(deviceId: string): Promise<void> {
return this._deviceManager.switchInputDevice(deviceId);
}
async switchOutputDevice(deviceId: string): Promise<void> {
return this._deviceManager.switchOutputDevice(deviceId);
}
// --- Delegating methods to AudioElements ---
setUserVolume(userId: number, volume: number): void {
this._audioElements.setUserVolume(userId, volume);
}
getUserVolume(userId: number): number {
return this._audioElements.getUserVolume(userId);
}
setScreenshareAudioVolume(userId: number, volume: number): void {
this._audioElements.setScreenshareAudioVolume(userId, volume);
}
muteScreenshareAudio(userId: number, muted: boolean): void {
this._audioElements.muteScreenshareAudio(userId, muted);
}
getScreenshareAudioMuted(userId: number): boolean {
return this._audioElements.getScreenshareAudioMuted(userId);
}
// --- Audio pipeline delegates (all state lives in AudioPipeline) ---
/** Re-apply mute/deafen state after events that may reset the audio pipeline. */
private reapplyMuteGain(): void {
const { localMuted, localDeafened } = voiceStore.getState();
if (localMuted || localDeafened) {
this.applyMicMuteState(true).catch((e) => log.warn("applyMicMuteState failed", e));
}
}
setInputVolume(volume: number): void {
this._audioPipeline.setInputVolume(volume);
}
setOutputVolume(volume: number): void {
this._audioElements.setOutputVolume(volume);
}
setVoiceSensitivity(sensitivity: number): void {
this._audioPipeline.setVoiceSensitivity(sensitivity);
}
async reapplyAudioProcessing(): Promise<void> {
return this._audioPipeline.reapplyAudioProcessing(this.onErrorCallback ?? undefined);
}
getLocalCameraStream(): MediaStream | null {
return doGetLocalCameraStream(this._room);
}
getLocalScreenshareStream(): MediaStream | null {
return doGetLocalScreenshareStream(this._room);
}
/** Get a remote participant's video MediaStream by userId and track type. Returns null if not available. */
getRemoteVideoStream(userId: number, type: "camera" | "screenshare"): MediaStream | null {
return doGetRemoteVideoStream(this._room, userId, type);
}
getRoom(): Room | null {
return this._room;
}
getSessionDebugInfo(): Record<string, unknown> {
return buildSessionDebugInfo({
room: this._room,
currentChannelId: this._currentChannelId,
outputVolumeMultiplier: this.outputVolumeMultiplier,
audioPipeline: this._audioPipeline,
audioElements: this._audioElements,
});
}
}
// --- Singleton instance + re-exported bound methods ---
const session = new LiveKitSession();
// Expose debug info on window under __owncord namespace for DevTools console access
// Usage: JSON.stringify(__owncord.lkDebug(), null, 2)
const owncordNs = ((window as unknown as Record<string, unknown>).__owncord ??= {}) as Record<
string,
unknown
>;
owncordNs.lkDebug = session.getSessionDebugInfo.bind(session);
export const setWsClient = session.setWsClient.bind(session);
export const setServerHost = session.setServerHost.bind(session);
export const setOnError = session.setOnError.bind(session);
export const clearOnError = session.clearOnError.bind(session);
export const setOnRemoteVideo = session.setOnRemoteVideo.bind(session);
export const setOnRemoteVideoRemoved = session.setOnRemoteVideoRemoved.bind(session);
export const clearOnRemoteVideo = session.clearOnRemoteVideo.bind(session);
export const handleVoiceToken = session.handleVoiceToken.bind(session);
export const leaveVoice = session.leaveVoice.bind(session);
export const retryMicPermission = session.retryMicPermission.bind(session);
export const cleanupAll = session.cleanupAll.bind(session);
export const setMuted = session.setMuted.bind(session);
export const setDeafened = session.setDeafened.bind(session);
export const enableCamera = session.enableCamera.bind(session);
export const disableCamera = session.disableCamera.bind(session);
export const enableScreenshare = session.enableScreenshare.bind(session);
export const disableScreenshare = session.disableScreenshare.bind(session);
export const switchInputDevice = session.switchInputDevice.bind(session);
export const switchOutputDevice = session.switchOutputDevice.bind(session);
export const setUserVolume = session.setUserVolume.bind(session);
export const getUserVolume = session.getUserVolume.bind(session);
export const setInputVolume = session.setInputVolume.bind(session);
export const setOutputVolume = session.setOutputVolume.bind(session);
export const setVoiceSensitivity = session.setVoiceSensitivity.bind(session);
export const reapplyAudioProcessing = session.reapplyAudioProcessing.bind(session);
export const getLocalCameraStream = session.getLocalCameraStream.bind(session);
export const getLocalScreenshareStream = session.getLocalScreenshareStream.bind(session);
export const getRemoteVideoStream = session.getRemoteVideoStream.bind(session);
export const getSessionDebugInfo = session.getSessionDebugInfo.bind(session);
export const setScreenshareAudioVolume = session.setScreenshareAudioVolume.bind(session);
export const muteScreenshareAudio = session.muteScreenshareAudio.bind(session);
export const getScreenshareAudioMuted = session.getScreenshareAudioMuted.bind(session);
/** True when the LiveKit session has an active room connection. */
export function isVoiceConnected(): boolean {
return session.getRoom() !== null;
}
export function getRoomForStats(): Room | null {
return session.getRoom();
}