mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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
1224 lines
49 KiB
TypeScript
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();
|
|
}
|