mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
Generated
+1
@@ -2949,6 +2949,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"keyring",
|
||||
"log",
|
||||
"rfd",
|
||||
"ring",
|
||||
"rustls",
|
||||
"serde",
|
||||
|
||||
@@ -39,6 +39,7 @@ ring = "0.17"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
keyring = "3"
|
||||
rfd = { version = "0.16", default-features = false }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = ["Win32_UI_Input_KeyboardAndMouse"] }
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use serde_json::Value;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const SETTINGS_STORE: &str = "settings.json";
|
||||
const CERTS_STORE: &str = "certs.json";
|
||||
use crate::constants::{CERTS_STORE, SETTINGS_STORE};
|
||||
|
||||
/// Maximum length for a settings key to prevent denial-of-service.
|
||||
const MAX_SETTINGS_KEY_LEN: usize = 128;
|
||||
@@ -107,10 +106,19 @@ pub fn store_cert_fingerprint(
|
||||
.store(CERTS_STORE)
|
||||
.map_err(|e| format!("failed to open certs store: {e}"))?;
|
||||
|
||||
// Capture old value before mutating so we can restore it if save fails.
|
||||
let old_value = store.get(&host);
|
||||
store.set(&host, Value::String(fingerprint));
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("failed to persist cert fingerprint: {e}"))?;
|
||||
if let Err(e) = store.save() {
|
||||
// Restore previous in-memory state: put back old fingerprint if one
|
||||
// existed, or delete if there was none. Without this, a failed save
|
||||
// during cert rotation would silently lose the previously trusted cert.
|
||||
match old_value {
|
||||
Some(v) => { let _ = store.set(&host, v); }
|
||||
None => { let _ = store.delete(&host); }
|
||||
}
|
||||
return Err(format!("failed to persist cert fingerprint: {e}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/// Tauri store file for persisted certificate fingerprints (TOFU pinning).
|
||||
pub const CERTS_STORE: &str = "certs.json";
|
||||
|
||||
/// Tauri store file for user settings and preferences.
|
||||
pub const SETTINGS_STORE: &str = "settings.json";
|
||||
@@ -1,4 +1,5 @@
|
||||
mod commands;
|
||||
mod constants;
|
||||
mod credentials;
|
||||
mod livekit_proxy;
|
||||
mod ptt;
|
||||
@@ -8,7 +9,7 @@ mod ws_proxy;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
match tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
@@ -56,6 +57,29 @@ pub fn run() {
|
||||
tray::create_tray(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
{
|
||||
Ok(app) => {
|
||||
app.run(|_app, event| {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
// Stop the PTT polling thread before the process tears down.
|
||||
// This ensures the AppHandle held inside the thread is released
|
||||
// cleanly and the thread does not call app.emit on a dead runtime.
|
||||
ptt::ptt_stop_internal();
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Fatal startup error: {e}");
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
rfd::MessageDialog::new()
|
||||
.set_title("OwnCord failed to start")
|
||||
.set_description(&format!(
|
||||
"The application encountered a startup error and cannot continue.\n\n{e}"
|
||||
))
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.show();
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use tauri_plugin_store::StoreExt;
|
||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
/// Tauri-managed state for the LiveKit TLS proxy.
|
||||
pub struct LiveKitProxyState {
|
||||
@@ -67,8 +68,7 @@ impl LiveKitProxyState {
|
||||
// TLS certificate verifier — pinned fingerprint check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tauri store file for certificate fingerprints (shared with ws_proxy).
|
||||
pub(crate) const CERTS_STORE: &str = "certs.json";
|
||||
use crate::constants::CERTS_STORE;
|
||||
|
||||
/// Verifies the server certificate against a known SHA-256 fingerprint.
|
||||
/// Reuses the fingerprint stored by ws_proxy's TOFU handshake for the same
|
||||
@@ -326,22 +326,39 @@ async fn handle_connection(
|
||||
pinned_fingerprint: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// ── 1. Read HTTP request headers (up to \r\n\r\n) ────────────────────
|
||||
// Guarded by a 10-second timeout so a slow or stalled client cannot
|
||||
// hold the Tokio task open indefinitely (BUG-151).
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
let mut trailer = [0u8; 4];
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
local.read_exact(&mut byte).await?;
|
||||
buf.push(byte[0]);
|
||||
trailer[0] = trailer[1];
|
||||
trailer[1] = trailer[2];
|
||||
trailer[2] = trailer[3];
|
||||
trailer[3] = byte[0];
|
||||
if trailer == *b"\r\n\r\n" {
|
||||
break;
|
||||
}
|
||||
if buf.len() > 16_384 {
|
||||
return Err("HTTP request headers too large".into());
|
||||
timeout(Duration::from_secs(10), async {
|
||||
let mut trailer = [0u8; 4];
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
local.read_exact(&mut byte).await?;
|
||||
buf.push(byte[0]);
|
||||
trailer[0] = trailer[1];
|
||||
trailer[1] = trailer[2];
|
||||
trailer[2] = trailer[3];
|
||||
trailer[3] = byte[0];
|
||||
if trailer == *b"\r\n\r\n" {
|
||||
break;
|
||||
}
|
||||
if buf.len() > 16_384 {
|
||||
return Err(Box::<dyn std::error::Error + Send + Sync>::from(
|
||||
"HTTP request headers too large",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from(
|
||||
"upstream header read timed out",
|
||||
))??;
|
||||
|
||||
// Reject CRLF in remote_host before header insertion (defense-in-depth;
|
||||
// primary validation is in start_livekit_proxy).
|
||||
if remote_host.contains('\r') || remote_host.contains('\n') {
|
||||
return Err("remote_host contains CRLF — header injection rejected".into());
|
||||
}
|
||||
|
||||
// ── 2. Rewrite Host and Origin headers ───────────────────────────────
|
||||
|
||||
@@ -9,13 +9,21 @@
|
||||
//! This ensures the stored integer is consistent on both Windows and Linux.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
|
||||
/// Virtual key code for the PTT key. 0 = disabled.
|
||||
static PTT_VKEY: AtomicI32 = AtomicI32::new(0);
|
||||
/// Whether the polling loop is running.
|
||||
/// Whether the polling loop is running (intent flag, kept for backwards compat).
|
||||
static PTT_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
/// Shutdown signal sent into the polling thread. Separate from PTT_RUNNING so
|
||||
/// that "stop the loop now" and "should a loop be running" are distinct.
|
||||
static PTT_SHUTDOWN: AtomicBool = AtomicBool::new(false);
|
||||
/// Handle to the polling thread. `Some` means a thread is alive; `None` means
|
||||
/// no thread exists. This Mutex is the authoritative critical section that
|
||||
/// prevents duplicate thread spawns.
|
||||
static PTT_THREAD: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform-specific key detection
|
||||
@@ -23,9 +31,15 @@ static PTT_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_key_down(vk: i32) -> bool {
|
||||
// VK codes 1-254 are valid; 0 and 255 are reserved/undefined
|
||||
if !(1..=254).contains(&vk) {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: GetAsyncKeyState is safe to call with valid VK codes 1-254
|
||||
let state =
|
||||
unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(vk) };
|
||||
(state as u16 & 0x8000) != 0
|
||||
// High-order bit set (negative when interpreted as i16) = key is down
|
||||
(state as i16) < 0
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -241,33 +255,88 @@ mod linux {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Start the PTT polling loop. Emits `ptt-state` (bool) events.
|
||||
///
|
||||
/// Uses `PTT_THREAD`'s Mutex as the critical section to prevent duplicate
|
||||
/// thread spawns. The `PTT_SHUTDOWN` flag is passed into the thread loop so
|
||||
/// it can be stopped cleanly from `ptt_stop` or `ptt_stop_internal`.
|
||||
#[tauri::command]
|
||||
pub fn ptt_start<R: Runtime>(app: AppHandle<R>) {
|
||||
if PTT_RUNNING.swap(true, Ordering::SeqCst) {
|
||||
return; // already running
|
||||
let mut guard = PTT_THREAD.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if guard.is_some() {
|
||||
return; // thread already alive — Mutex is the authoritative check
|
||||
}
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut was_pressed = false;
|
||||
// Reset the shutdown flag before spawning so the loop doesn't exit
|
||||
// immediately if a previous ptt_stop set it.
|
||||
PTT_SHUTDOWN.store(false, Ordering::SeqCst);
|
||||
PTT_RUNNING.store(true, Ordering::SeqCst);
|
||||
|
||||
while PTT_RUNNING.load(Ordering::SeqCst) {
|
||||
let vk = PTT_VKEY.load(Ordering::SeqCst);
|
||||
if vk != 0 {
|
||||
let pressed = is_key_down(vk);
|
||||
if pressed != was_pressed {
|
||||
was_pressed = pressed;
|
||||
let _ = app.emit("ptt-state", pressed);
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Wrap the entire loop body in catch_unwind so that panics from
|
||||
// is_key_down (unsafe FFI) or app.emit do not leave PTT_RUNNING
|
||||
// stuck at true with no way to recover.
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut was_pressed = false;
|
||||
|
||||
while !PTT_SHUTDOWN.load(Ordering::SeqCst) {
|
||||
let vk = PTT_VKEY.load(Ordering::SeqCst);
|
||||
if vk != 0 {
|
||||
let pressed = is_key_down(vk);
|
||||
if pressed != was_pressed {
|
||||
was_pressed = pressed;
|
||||
let _ = app.emit("ptt-state", pressed);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}));
|
||||
|
||||
// Clear the thread handle slot so ptt_start can spawn a replacement.
|
||||
// Use unwrap_or_else to handle a poisoned Mutex defensively, matching
|
||||
// the pattern used in ptt_stop_internal.
|
||||
let mut g = PTT_THREAD.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*g = None;
|
||||
PTT_RUNNING.store(false, Ordering::SeqCst);
|
||||
|
||||
if result.is_err() {
|
||||
log::error!("PTT polling thread panicked — PTT is no longer active");
|
||||
// Notify the frontend so it can surface a warning and offer retry.
|
||||
let _ = app.emit("ptt-error", "PTT thread panicked");
|
||||
}
|
||||
});
|
||||
|
||||
*guard = Some(handle);
|
||||
}
|
||||
|
||||
/// Stop the PTT polling loop.
|
||||
/// Stop the PTT polling loop (IPC-callable command).
|
||||
#[tauri::command]
|
||||
pub fn ptt_stop() {
|
||||
ptt_stop_internal();
|
||||
}
|
||||
|
||||
/// Stop the PTT polling thread and block until it has fully exited.
|
||||
///
|
||||
/// This is the internal, non-IPC version called from the Tauri lifecycle
|
||||
/// handler (`RunEvent::Exit`) to guarantee the thread is gone before the
|
||||
/// process tears down, preventing the `AppHandle` from being used against
|
||||
/// a half-torn-down runtime.
|
||||
pub fn ptt_stop_internal() {
|
||||
// Signal the thread to exit.
|
||||
PTT_SHUTDOWN.store(true, Ordering::SeqCst);
|
||||
PTT_RUNNING.store(false, Ordering::SeqCst);
|
||||
|
||||
// Take the handle out of the Mutex so we can join it outside the lock,
|
||||
// avoiding a potential deadlock if the thread itself tries to lock
|
||||
// PTT_THREAD on exit.
|
||||
let handle = {
|
||||
let mut guard = PTT_THREAD.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.take()
|
||||
};
|
||||
|
||||
if let Some(h) = handle {
|
||||
// Best-effort join — ignore if the thread already exited or panicked.
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the PTT virtual key code. Pass 0 to disable.
|
||||
|
||||
@@ -15,23 +15,25 @@ use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// Maximum time to wait for the WebSocket handshake to complete.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Tauri store file for certificate fingerprints.
|
||||
const CERTS_STORE: &str = "certs.json";
|
||||
use crate::constants::CERTS_STORE;
|
||||
|
||||
/// Sender half kept in Tauri state so `ws_send` can push messages.
|
||||
/// `tx` is wrapped in `Arc` so the monitoring task can clone a reference
|
||||
/// into its closure and clear the sender even after a worker task panic.
|
||||
pub struct WsState {
|
||||
tx: Mutex<Option<mpsc::Sender<String>>>,
|
||||
tx: Arc<Mutex<Option<mpsc::Sender<String>>>>,
|
||||
}
|
||||
|
||||
impl WsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tx: Mutex::new(None),
|
||||
tx: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,8 +160,16 @@ fn tofu_check<R: Runtime>(
|
||||
match stored {
|
||||
None => {
|
||||
// First use — store the fingerprint.
|
||||
// Capture old value before mutating (None here, but consistent pattern).
|
||||
let old_value = store.get(host);
|
||||
store.set(host, Value::String(fingerprint.to_string()));
|
||||
if let Err(e) = store.save() {
|
||||
// Restore previous in-memory state: put back old value or delete
|
||||
// if there was none, keeping in-memory consistent with on-disk.
|
||||
match old_value {
|
||||
Some(v) => { let _ = store.set(host, v); }
|
||||
None => { let _ = store.delete(host); }
|
||||
}
|
||||
return Err(format!("failed to persist cert fingerprint: {e}"));
|
||||
}
|
||||
Ok("trusted_first_use".to_string())
|
||||
@@ -300,50 +310,72 @@ pub async fn ws_connect<R: Runtime>(
|
||||
|
||||
let app_read = app.clone();
|
||||
let app_state = app.clone();
|
||||
// Clone the Arc so the monitoring closure can clear tx on any exit path,
|
||||
// including worker task panics, without needing tauri::State.
|
||||
let tx_arc = Arc::clone(&state.tx);
|
||||
|
||||
// Task: forward server → JS
|
||||
let mut read_task = tokio::spawn(async move {
|
||||
while let Some(msg) = stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
let _ = app_read.emit("ws-message", text.to_string());
|
||||
}
|
||||
Ok(Message::Close(frame)) => {
|
||||
debug!("[ws_proxy] server sent Close frame: {:?}", frame);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[ws_proxy] read error: {}", e);
|
||||
let _ = app_read.emit("ws-error", format!("{e}"));
|
||||
break;
|
||||
}
|
||||
_ => {} // ignore binary/ping/pong
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Task: forward JS → server
|
||||
let mut write_task = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if sink.send(Message::Text(msg.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// When either task ends, abort sibling and emit closed
|
||||
// Single outer task owns a JoinSet containing read and write workers.
|
||||
// join_next() blocks until the first worker finishes (normally or via panic),
|
||||
// then abort_all() + drain guarantees both workers and their sockets are
|
||||
// cleaned up before the closed event is emitted.
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = &mut read_task => {
|
||||
debug!("[ws_proxy] read task ended, aborting write task");
|
||||
write_task.abort();
|
||||
let mut set = JoinSet::new();
|
||||
|
||||
// Task: forward server → JS
|
||||
set.spawn(async move {
|
||||
while let Some(msg) = stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
let _ = app_read.emit("ws-message", text.to_string());
|
||||
}
|
||||
Ok(Message::Close(frame)) => {
|
||||
debug!("[ws_proxy] server sent Close frame: {:?}", frame);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[ws_proxy] read error: {}", e);
|
||||
let _ = app_read.emit("ws-error", format!("{e}"));
|
||||
break;
|
||||
}
|
||||
_ => {} // ignore binary/ping/pong
|
||||
}
|
||||
}
|
||||
_ = &mut write_task => {
|
||||
debug!("[ws_proxy] write task ended, aborting read task");
|
||||
read_task.abort();
|
||||
});
|
||||
|
||||
// Task: forward JS → server
|
||||
set.spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if sink.send(Message::Text(msg.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Block until the first worker finishes (normal exit or panic).
|
||||
let first = set.join_next().await;
|
||||
|
||||
// Cancel the sibling and drain it so sockets close cleanly before
|
||||
// emitting state. abort_all() is a no-op if only one task remains.
|
||||
set.abort_all();
|
||||
while set.join_next().await.is_some() {}
|
||||
|
||||
match first {
|
||||
Some(Err(ref e)) if e.is_panic() => {
|
||||
error!("[ws_proxy] worker task panicked: {:?}", e);
|
||||
}
|
||||
_ => {
|
||||
info!("[ws_proxy] connection closed");
|
||||
}
|
||||
}
|
||||
info!("[ws_proxy] connection closed");
|
||||
|
||||
// Clear the sender so ws_send returns "not connected". This runs on
|
||||
// every exit path — normal close, graceful disconnect, and panic.
|
||||
{
|
||||
let mut tx_lock = tx_arc.lock().await;
|
||||
*tx_lock = None;
|
||||
}
|
||||
|
||||
// Always emit closed, even after a panic.
|
||||
emit_ws_state(&app_state, "closed");
|
||||
});
|
||||
|
||||
@@ -358,7 +390,16 @@ pub async fn ws_send(
|
||||
) -> Result<(), String> {
|
||||
let tx_lock = state.tx.lock().await;
|
||||
if let Some(tx) = tx_lock.as_ref() {
|
||||
tx.try_send(message).map_err(|e| format!("ws send failed: {e}"))
|
||||
match tx.try_send(message) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
|
||||
warn!("[ws_proxy] ws_send: outbound channel full, message dropped");
|
||||
Err("ws_send: channel full, message dropped".into())
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
|
||||
Err("ws_send: channel closed".into())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err("WebSocket not connected".into())
|
||||
}
|
||||
@@ -401,10 +442,20 @@ pub fn accept_cert_fingerprint<R: Runtime>(
|
||||
.store(CERTS_STORE)
|
||||
.map_err(|e| format!("failed to open certs store: {e}"))?;
|
||||
|
||||
// Capture old value before mutating so we can restore it if save fails.
|
||||
let old_value = store.get(&host);
|
||||
store.set(&host, Value::String(fingerprint));
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("failed to persist cert fingerprint: {e}"))?;
|
||||
if let Err(e) = store.save() {
|
||||
// Restore previous in-memory state: put back old fingerprint if one
|
||||
// existed, or delete if there was none. Without this, the new
|
||||
// fingerprint would be trusted in-process even though it was never
|
||||
// persisted to certs.json.
|
||||
match old_value {
|
||||
Some(v) => { let _ = store.set(&host, v); }
|
||||
None => { let _ = store.delete(&host); }
|
||||
}
|
||||
return Err(format!("failed to persist cert fingerprint: {e}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
function measureRendered(): void {
|
||||
if (contentContainer === null || renderedStart < 0) return;
|
||||
const children = contentContainer.children;
|
||||
|
||||
// Pass 1 — pure reads: collect all heights without touching any styles.
|
||||
// Batching all getComputedStyle / offsetHeight reads before any writes
|
||||
// allows the browser to satisfy them with a single layout calculation
|
||||
// instead of forcing a synchronous reflow on every iteration.
|
||||
interface Measurement {
|
||||
readonly key: string;
|
||||
readonly idx: number;
|
||||
readonly h: number;
|
||||
}
|
||||
const measurements: Measurement[] = [];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const globalIdx = renderedStart + i;
|
||||
if (globalIdx < 0 || (tree !== null && globalIdx >= tree.size)) continue;
|
||||
@@ -240,11 +251,16 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
const style = getComputedStyle(el);
|
||||
const h = el.offsetHeight + parseFloat(style.marginTop) + parseFloat(style.marginBottom);
|
||||
if (h > 0) {
|
||||
const key = itemKey(globalIdx);
|
||||
heightCache.set(key, h);
|
||||
if (tree !== null) {
|
||||
tree.set(globalIdx, h);
|
||||
}
|
||||
measurements.push({ key: itemKey(globalIdx), idx: globalIdx, h });
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — pure writes: apply all cached heights to heightCache and the
|
||||
// Fenwick tree. No DOM reads here, so no additional reflow is triggered.
|
||||
for (const { key, idx, h } of measurements) {
|
||||
heightCache.set(key, h);
|
||||
if (tree !== null) {
|
||||
tree.set(idx, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,6 +469,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
|
||||
let scrollRafId = 0;
|
||||
let resizeRafId = 0;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
// resizeDirty tracking removed — resize observer batches via RAF directly
|
||||
function handleScroll(): void {
|
||||
if (root === null) return;
|
||||
@@ -515,7 +532,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
|
||||
// Watch for height changes in rendered items (images loading, embeds expanding).
|
||||
// Batched via RAF with anchor-based scroll preservation.
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (root === null || contentContainer === null) return;
|
||||
if (resizeRafId !== 0) return;
|
||||
|
||||
@@ -544,7 +561,6 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(contentContainer);
|
||||
ac.signal.addEventListener("abort", () => resizeObserver.disconnect());
|
||||
|
||||
parentContainer.appendChild(root);
|
||||
|
||||
@@ -579,6 +595,10 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (resizeObserver !== null) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
ac.abort();
|
||||
if (scrollRafId !== 0) {
|
||||
cancelAnimationFrame(scrollRafId);
|
||||
|
||||
@@ -70,43 +70,119 @@ type PendingVoiceJoin = {
|
||||
readonly directUrl?: string;
|
||||
};
|
||||
|
||||
/** Read pendingJoin from an instance — bypasses TS control-flow narrowing
|
||||
* that incorrectly assumes the field is still null after an async interleave. */
|
||||
function getPendingJoin(session: LiveKitSession): PendingVoiceJoin | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TS narrowing workaround
|
||||
return (session as any).pendingJoin as PendingVoiceJoin | null;
|
||||
}
|
||||
// --- 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 {
|
||||
private room: Room | null = null;
|
||||
/** 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 currentChannelId: number | null = null;
|
||||
private serverHost: string | null = null;
|
||||
private onRemoteVideoCallback: RemoteVideoCallback | null = null;
|
||||
private onRemoteVideoRemovedCallback: RemoteVideoRemovedCallback | null = null;
|
||||
private tokenRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Latest token received from server (used for reconnection after token refresh). */
|
||||
private latestToken: string | null = null;
|
||||
/** Guard: true while handleVoiceToken is connecting — prevents concurrent joins. */
|
||||
private connecting = false;
|
||||
/** Latest join request received while a connection attempt is already running. */
|
||||
private pendingJoin: PendingVoiceJoin | null = null;
|
||||
/** Last known LiveKit URL and directUrl for auto-reconnect on unexpected disconnect. */
|
||||
private lastUrl: string | null = null;
|
||||
private lastDirectUrl: string | undefined = undefined;
|
||||
/** 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;
|
||||
/** Aborted by leaveVoice() to cancel a pending auto-reconnect loop. */
|
||||
private reconnectAc: AbortController | null = null;
|
||||
/** 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();
|
||||
@@ -120,7 +196,7 @@ export class LiveKitSession {
|
||||
/** Lazily built deps for the extracted video track functions. */
|
||||
private get _videoTrackDeps(): VideoTrackDeps {
|
||||
return {
|
||||
getRoom: () => this.room,
|
||||
getRoom: () => this._room,
|
||||
getWs: () => this.ws,
|
||||
onError: (msg) => {
|
||||
this.onErrorCallback?.(msg);
|
||||
@@ -134,29 +210,60 @@ export class LiveKitSession {
|
||||
|
||||
constructor() {
|
||||
this._eventHandlers = createRoomEventHandlers({
|
||||
getRoom: () => this.room,
|
||||
getRoom: () => this._room,
|
||||
setRoom: (r) => {
|
||||
this.room = 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,
|
||||
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,
|
||||
isConnecting: () => this._connecting,
|
||||
getLatestToken: () => this._latestToken,
|
||||
getLastUrl: () => this._lastUrl,
|
||||
getLastDirectUrl: () => this._lastDirectUrl,
|
||||
setReconnectAc: (ac) => {
|
||||
this.reconnectAc = 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);
|
||||
stopManualCameraTrack(this._cameraState, this._room);
|
||||
stopManualScreenTracks(this._screenState, this._room);
|
||||
setLocalCamera(false);
|
||||
setLocalScreenshare(false);
|
||||
},
|
||||
@@ -167,6 +274,15 @@ export class LiveKitSession {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
@@ -212,10 +328,11 @@ export class LiveKitSession {
|
||||
|
||||
/** Update all extracted modules with the current room reference. */
|
||||
private syncModuleRooms(): void {
|
||||
this._audioPipeline.setRoom(this.room);
|
||||
this._audioElements.setRoom(this.room);
|
||||
this._deviceManager.setRoom(this.room);
|
||||
this._deviceManager.setAudioPipeline(this.room !== null ? this._audioPipeline : null);
|
||||
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);
|
||||
}
|
||||
@@ -238,20 +355,39 @@ export class LiveKitSession {
|
||||
// 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) {
|
||||
if (signal.aborted || this._currentChannelId !== channelId) {
|
||||
log.info("Auto-reconnect aborted — user left or channel changed");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.room = this.createRoom();
|
||||
this.syncModuleRooms();
|
||||
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 this.room.connect(resolvedUrl, token);
|
||||
await newRoom.connect(resolvedUrl, token);
|
||||
log.info("Auto-reconnect succeeded", { attempt, channelId, url: resolvedUrl });
|
||||
logIceConnectionInfo(this.room);
|
||||
this.room
|
||||
// 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
|
||||
@@ -260,7 +396,7 @@ export class LiveKitSession {
|
||||
const savedInput = loadPref<string>("audioInputDevice", "");
|
||||
if (savedInput) {
|
||||
try {
|
||||
await this.room.switchActiveDevice("audioinput", savedInput);
|
||||
await newRoom.switchActiveDevice("audioinput", savedInput);
|
||||
} catch (err) {
|
||||
log.warn("Reconnect: saved input device unavailable, using default", err);
|
||||
}
|
||||
@@ -268,7 +404,7 @@ export class LiveKitSession {
|
||||
const savedOutput = loadPref<string>("audioOutputDevice", "");
|
||||
if (savedOutput) {
|
||||
try {
|
||||
await this.room.switchActiveDevice("audiooutput", savedOutput);
|
||||
await newRoom.switchActiveDevice("audiooutput", savedOutput);
|
||||
} catch (err) {
|
||||
log.warn("Reconnect: saved output device unavailable, using default", err);
|
||||
}
|
||||
@@ -276,24 +412,38 @@ export class LiveKitSession {
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
this.reapplyMuteGain();
|
||||
this.startTokenRefreshTimer();
|
||||
// Clear the abort controller after all post-connect work is done so
|
||||
// leaveVoice() can still abort during restoreLocalVoiceState above.
|
||||
this.reconnectAc = null;
|
||||
// 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 });
|
||||
if (this.room !== null) {
|
||||
this.room.removeAllListeners();
|
||||
this.room
|
||||
const failedRoom = this._room;
|
||||
if (failedRoom !== null) {
|
||||
failedRoom.removeAllListeners();
|
||||
failedRoom
|
||||
.disconnect()
|
||||
.catch((disconnectErr) =>
|
||||
log.warn("Failed to disconnect room after reconnect failure", disconnectErr),
|
||||
);
|
||||
this.room = null;
|
||||
this.syncModuleRooms();
|
||||
}
|
||||
// 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.
|
||||
@@ -384,10 +534,17 @@ export class LiveKitSession {
|
||||
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) {
|
||||
if (this.ws === null || this._room === null) {
|
||||
log.debug("Skipping token refresh — no active session");
|
||||
return;
|
||||
}
|
||||
@@ -396,9 +553,35 @@ export class LiveKitSession {
|
||||
// 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
|
||||
@@ -409,8 +592,10 @@ export class LiveKitSession {
|
||||
// - 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.latestToken = token;
|
||||
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");
|
||||
@@ -419,7 +604,8 @@ export class LiveKitSession {
|
||||
// --- Volume helpers ---
|
||||
|
||||
private async restoreLocalVoiceState(mode: "join" | "reconnect"): Promise<void> {
|
||||
if (this.room === null) return;
|
||||
const room = this._room;
|
||||
if (room === null) return;
|
||||
|
||||
const state = voiceStore.getState();
|
||||
const muted = state.localMuted || state.localDeafened;
|
||||
@@ -427,7 +613,7 @@ export class LiveKitSession {
|
||||
const shouldEnableMicrophone = !muted;
|
||||
|
||||
try {
|
||||
await this.room.localParticipant.setMicrophoneEnabled(shouldEnableMicrophone);
|
||||
await room.localParticipant.setMicrophoneEnabled(shouldEnableMicrophone);
|
||||
if (shouldEnableMicrophone) {
|
||||
log.info(
|
||||
mode === "join"
|
||||
@@ -496,29 +682,74 @@ export class LiveKitSession {
|
||||
}
|
||||
|
||||
/** 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 otherwise. */
|
||||
* 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> {
|
||||
if (this.room !== null) this.leaveVoice(false);
|
||||
this.connecting = true;
|
||||
): 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 {
|
||||
this.room = this.createRoom();
|
||||
this.syncModuleRooms();
|
||||
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 this.room.connect(resolvedUrl, token);
|
||||
// Check if a newer join was queued during the async connect.
|
||||
const queuedJoin = getPendingJoin(this);
|
||||
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 ||
|
||||
@@ -530,15 +761,15 @@ export class LiveKitSession {
|
||||
channelId,
|
||||
queuedChannelId: queuedJoin.channelId,
|
||||
});
|
||||
if (this.room !== null) {
|
||||
const room = this.room;
|
||||
this.room = null;
|
||||
this.syncModuleRooms();
|
||||
room.removeAllListeners();
|
||||
room
|
||||
.disconnect()
|
||||
.catch((err) => log.debug("Failed to disconnect room during cleanup", err));
|
||||
}
|
||||
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;
|
||||
@@ -552,46 +783,96 @@ export class LiveKitSession {
|
||||
});
|
||||
// eslint-disable-next-line no-await-in-loop -- intentional backoff delay between retry attempts
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||||
if (this.room === null) throw connectErr;
|
||||
this.room.removeAllListeners();
|
||||
this.room = this.createRoom();
|
||||
this.syncModuleRooms();
|
||||
// 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 (this.room !== null) {
|
||||
if (localRoom !== null) {
|
||||
log.info("Connected to LiveKit room", { channelId, url: resolvedUrl });
|
||||
logIceConnectionInfo(this.room);
|
||||
this.currentChannelId = channelId;
|
||||
this.latestToken = token;
|
||||
this.lastUrl = url;
|
||||
this.lastDirectUrl = directUrl;
|
||||
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.
|
||||
this.room.startAudio().catch(() => {
|
||||
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 this.room.switchActiveDevice("audioinput", savedInput);
|
||||
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 this.room.switchActiveDevice("audiooutput", savedOutput);
|
||||
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();
|
||||
@@ -601,13 +882,18 @@ export class LiveKitSession {
|
||||
return false;
|
||||
} catch (err) {
|
||||
log.error("Failed to connect to LiveKit", { url: resolvedUrl, error: err });
|
||||
if (this.room !== null) {
|
||||
if (localRoom !== null) {
|
||||
this.onErrorCallback?.("Failed to join voice — connection error");
|
||||
}
|
||||
this.leaveVoice(false);
|
||||
return false;
|
||||
} finally {
|
||||
this.connecting = false;
|
||||
// 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,25 +903,32 @@ export class LiveKitSession {
|
||||
channelId: number,
|
||||
directUrl?: string,
|
||||
): Promise<void> {
|
||||
if (
|
||||
this.room !== null &&
|
||||
this.currentChannelId === channelId &&
|
||||
this.room.state === "connected"
|
||||
) {
|
||||
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) {
|
||||
this.pendingJoin = { token, url, channelId, directUrl };
|
||||
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.
|
||||
let pendingJoin = this.pendingJoin;
|
||||
this.pendingJoin = null;
|
||||
// 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,
|
||||
@@ -643,26 +936,32 @@ export class LiveKitSession {
|
||||
channelId: pChannelId,
|
||||
directUrl: pDirectUrl,
|
||||
} = pendingJoin;
|
||||
const cur = this._state;
|
||||
if (
|
||||
this.room !== null &&
|
||||
this.currentChannelId === pChannelId &&
|
||||
this.room.state === "connected"
|
||||
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 });
|
||||
}
|
||||
pendingJoin = this.pendingJoin;
|
||||
this.pendingJoin = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry microphone permission after being in listen-only mode. */
|
||||
async retryMicPermission(): Promise<void> {
|
||||
if (this.room === null) return;
|
||||
const room = this._room;
|
||||
if (room === null) return;
|
||||
try {
|
||||
await this.room.localParticipant.setMicrophoneEnabled(true);
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
setListenOnly(false);
|
||||
// BUG-103: Honor deafened state — keep mic muted if user is deafened.
|
||||
const { localDeafened } = voiceStore.getState();
|
||||
@@ -685,18 +984,18 @@ export class LiveKitSession {
|
||||
}
|
||||
|
||||
leaveVoice(sendWs = true): void {
|
||||
// Cancel any pending auto-reconnect loop first
|
||||
if (this.reconnectAc !== null) {
|
||||
this.reconnectAc.abort();
|
||||
this.reconnectAc = null;
|
||||
// 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();
|
||||
this.pendingJoin = null;
|
||||
// Clean up manually published tracks.
|
||||
stopManualCameraTrack(this._cameraState, this.room);
|
||||
stopManualScreenTracks(this._screenState, this.room);
|
||||
stopManualCameraTrack(this._cameraState, this._room);
|
||||
stopManualScreenTracks(this._screenState, this._room);
|
||||
if (sendWs && this.ws !== null) {
|
||||
this.ws.send({ type: "voice_leave", payload: {} });
|
||||
}
|
||||
@@ -704,17 +1003,16 @@ export class LiveKitSession {
|
||||
// TrackUnsubscribed, but may be missed during rapid reconnection).
|
||||
// Full cleanup: also clears screenshare mute state on intentional leave.
|
||||
this._audioElements.cleanupAllAudioElementsFull();
|
||||
if (this.room !== null) {
|
||||
const r = this.room;
|
||||
this.room = null;
|
||||
this.syncModuleRooms();
|
||||
r.removeAllListeners();
|
||||
r.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err));
|
||||
const room = this._room;
|
||||
if (room !== null) {
|
||||
room.removeAllListeners();
|
||||
room.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err));
|
||||
}
|
||||
this.currentChannelId = null;
|
||||
this.latestToken = null;
|
||||
this.lastUrl = null;
|
||||
this.lastDirectUrl = undefined;
|
||||
// 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");
|
||||
@@ -722,6 +1020,8 @@ export class LiveKitSession {
|
||||
|
||||
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;
|
||||
@@ -749,16 +1049,17 @@ export class LiveKitSession {
|
||||
* 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> {
|
||||
if (this.room === null) return;
|
||||
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 this.room.localParticipant.setMicrophoneEnabled(false);
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
log.debug("Mic fully unpublished (muted)");
|
||||
} else {
|
||||
// Re-enable mic — this re-publishes the track to the SFU
|
||||
await this.room.localParticipant.setMicrophoneEnabled(true);
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
// Rebuild the audio pipeline on the fresh track
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
log.debug("Mic re-published (unmuted)");
|
||||
@@ -840,26 +1141,26 @@ export class LiveKitSession {
|
||||
}
|
||||
|
||||
getLocalCameraStream(): MediaStream | null {
|
||||
return doGetLocalCameraStream(this.room);
|
||||
return doGetLocalCameraStream(this._room);
|
||||
}
|
||||
|
||||
getLocalScreenshareStream(): MediaStream | null {
|
||||
return doGetLocalScreenshareStream(this.room);
|
||||
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);
|
||||
return doGetRemoteVideoStream(this._room, userId, type);
|
||||
}
|
||||
|
||||
getRoom(): Room | null {
|
||||
return this.room;
|
||||
return this._room;
|
||||
}
|
||||
|
||||
getSessionDebugInfo(): Record<string, unknown> {
|
||||
return buildSessionDebugInfo({
|
||||
room: this.room,
|
||||
currentChannelId: this.currentChannelId,
|
||||
room: this._room,
|
||||
currentChannelId: this._currentChannelId,
|
||||
outputVolumeMultiplier: this.outputVolumeMultiplier,
|
||||
audioPipeline: this._audioPipeline,
|
||||
audioElements: this._audioElements,
|
||||
|
||||
@@ -106,8 +106,13 @@ export function createStore<T>(initialState: T): Store<T> {
|
||||
|
||||
/** Re-entrancy guard: true while a subscriber notification is running. */
|
||||
let updating = false;
|
||||
/** Updaters queued by re-entrant setState calls during notification. */
|
||||
const pendingUpdates: Array<(prev: T) => T> = [];
|
||||
/**
|
||||
* Coalesced updater for re-entrant setState calls made during notification.
|
||||
* Instead of an unbounded array, re-entrant calls are composed into a single
|
||||
* function chain so queue depth never exceeds 1, regardless of burst size.
|
||||
* Ordering is preserved: each updater sees the output of the previous one.
|
||||
*/
|
||||
let pendingUpdater: ((prev: T) => T) | null = null;
|
||||
|
||||
function getState(): T {
|
||||
return state;
|
||||
@@ -115,8 +120,12 @@ export function createStore<T>(initialState: T): Store<T> {
|
||||
|
||||
function setState(updater: (prev: T) => T): void {
|
||||
if (updating) {
|
||||
// Re-entrant call from within a subscriber — queue for later.
|
||||
pendingUpdates.push(updater);
|
||||
// Re-entrant call from within a subscriber — coalesce into a single
|
||||
// pending updater by composing with any already-queued function.
|
||||
// This keeps queue depth at O(1) regardless of burst size while
|
||||
// preserving update ordering (each updater sees previous output).
|
||||
const existing = pendingUpdater;
|
||||
pendingUpdater = existing === null ? updater : (prev: T) => updater(existing(prev));
|
||||
return;
|
||||
}
|
||||
state = updater(state);
|
||||
@@ -129,9 +138,12 @@ export function createStore<T>(initialState: T): Store<T> {
|
||||
for (const listener of listeners) {
|
||||
listener(state);
|
||||
}
|
||||
// Drain any updates queued by re-entrant setState during notification.
|
||||
while (pendingUpdates.length > 0) {
|
||||
const queued = pendingUpdates.shift()!;
|
||||
// Drain any update coalesced by re-entrant setState during notification.
|
||||
// Loop to handle further re-entrant calls that may occur within listeners
|
||||
// invoked during the drain itself.
|
||||
while (pendingUpdater !== null) {
|
||||
const queued = pendingUpdater;
|
||||
pendingUpdater = null;
|
||||
state = queued(state);
|
||||
for (const listener of listeners) {
|
||||
listener(state);
|
||||
|
||||
@@ -215,10 +215,12 @@ export function createWsClient() {
|
||||
return;
|
||||
}
|
||||
replayDedup.add(dedupKey);
|
||||
// Evict oldest entries if set is too large
|
||||
if (replayDedup.size > MAX_DEDUP_SIZE) {
|
||||
const first = replayDedup.values().next().value;
|
||||
if (first !== undefined) replayDedup.delete(first);
|
||||
const targetSize = Math.floor(MAX_DEDUP_SIZE * 0.8);
|
||||
for (const key of replayDedup) {
|
||||
if (replayDedup.size <= targetSize) break;
|
||||
replayDedup.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,8 +421,17 @@ export function createWsClient() {
|
||||
log.warn("Cannot send, WebSocket not open");
|
||||
return;
|
||||
}
|
||||
tauriInvoke("ws_send", { message: json }).catch((err) => {
|
||||
log.error("ws_send failed", err);
|
||||
tauriInvoke("ws_send", { message: json }).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("channel full")) {
|
||||
// Outbound channel is saturated — log a prominent warning so callers
|
||||
// can detect backpressure rather than silently losing messages.
|
||||
log.warn("ws_send: outbound channel full, message dropped (backpressure)", {
|
||||
messagePreview: json.slice(0, 120),
|
||||
});
|
||||
} else {
|
||||
log.error("ws_send failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||

|
||||
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
# OwnCord
|
||||
|
||||
*The gaming chat platform you actually own.*
|
||||
The gaming chat platform you actually own.
|
||||
|
||||
> **Early Alpha — Building in the Open**
|
||||
> OwnCord is under active development and is not production-ready. Do not use it for sensitive communications. Security hardening is in progress. Contributions and [security reports](https://github.com/J3vb/OwnCord/issues) are welcome.
|
||||
> **Early Alpha / Work in Progress**
|
||||
> OwnCord is in active development and is not production-ready. Expect rough edges, rapid changes, and occasional breaking behavior.
|
||||
>
|
||||
> Do not use it for sensitive communications yet.
|
||||
|
||||
A self-hosted chat platform with real-time messaging,
|
||||
voice/video, file sharing, and a web admin panel. Run your own
|
||||
server on Windows or Linux and keep everything under your control
|
||||
— zero cloud dependencies, works fully on LAN.
|
||||
## Development Model
|
||||
|
||||
OwnCord is built with an AI-first development workflow.
|
||||
Most implementation is generated through autonomous AI tooling, with quality validated primarily through automated checks (CI, tests, linting) and real-world feedback during alpha.
|
||||
|
||||
This approach enables fast iteration, but it also means behavior may change quickly between releases.
|
||||
|
||||
OwnCord is a self-hosted chat stack with a Go server and a Tauri desktop client.
|
||||
It includes real-time messaging, voice/video via LiveKit, file sharing, and a web admin panel.
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/Client.png" alt="OwnCord Client" width="700">
|
||||
@@ -25,164 +32,73 @@ server on Windows or Linux and keep everything under your control
|
||||
<img src=".github/images/Admin_Panel.png" alt="Admin Panel" width="340">
|
||||
</p>
|
||||
|
||||
## Current Project Status
|
||||
|
||||
| Area | Status |
|
||||
| ---- | ------ |
|
||||
| Core chat flow | Working in alpha |
|
||||
| Voice/video | Working in alpha |
|
||||
| Admin panel | Working in alpha |
|
||||
| Security hardening | In progress |
|
||||
|
||||
## Platform Support (Current Releases)
|
||||
|
||||
| Component | Windows x64 | Linux x64 | Linux ARM64 |
|
||||
| --------- | ----------- | --------- | ----------- |
|
||||
| Server binary | Yes | Yes | Not published yet |
|
||||
| Desktop client | Yes | Yes | Yes |
|
||||
| Docker server | N/A | Yes | N/A |
|
||||
|
||||
## Start Here
|
||||
|
||||
- New user quick path: [docs/quick-start.md](docs/quick-start.md)
|
||||
- Linux Docker deployment: [docs/deployment.md](docs/deployment.md)
|
||||
- Remote access without router config: [docs/tailscale.md](docs/tailscale.md)
|
||||
- Manual router/network setup: [docs/port-forwarding.md](docs/port-forwarding.md)
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Option A — Binary (Windows / Linux)**
|
||||
### Option A: Prebuilt binaries
|
||||
|
||||
1. Download from [GitHub Releases](https://github.com/J3vb/OwnCord/releases):
|
||||
- **Windows**: `chatserver.exe` + `OwnCord_x.x.x_x64-setup.exe`
|
||||
- **Linux x64**: `chatserver-linux-amd64.tar.gz` + `OwnCord_x.x.x_x86_64.AppImage` (or `_amd64.deb`)
|
||||
- **Linux ARM64**: `chatserver-linux-amd64.tar.gz` + `OwnCord_x.x.x_aarch64.AppImage` (or `_arm64.deb`)
|
||||
2. Run `chatserver.exe` / `./chatserver` — generates `config.yaml` and `data/` on first run
|
||||
3. Open `https://localhost:8443/admin` to create the Owner account
|
||||
4. Generate invite codes and share them with friends
|
||||
1. Download assets from [GitHub Releases](https://github.com/J3vb/OwnCord/releases).
|
||||
2. Run the server binary:
|
||||
- Windows: `chatserver.exe`
|
||||
- Linux: `./chatserver`
|
||||
3. Open `https://localhost:8443/admin` and create your Owner account.
|
||||
4. Generate invite codes in the admin panel and share them with friends.
|
||||
|
||||
**Option B — Docker (Linux)**
|
||||
### Option B: Docker (Linux server)
|
||||
|
||||
```bash
|
||||
cd Server
|
||||
cp .env.example .env && cp livekit.yaml.example livekit.yaml
|
||||
# Edit both files (set API keys + your public IP in livekit.yaml)
|
||||
cp .env.example .env
|
||||
cp livekit.yaml.example livekit.yaml
|
||||
# Edit both files before starting
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
See [Deployment Guide](docs/deployment.md) for full Docker setup.
|
||||
See the full setup guide in [docs/deployment.md](docs/deployment.md).
|
||||
|
||||
> **Finding your IP:** `ipconfig` (Windows) or `ip a` (Linux). The server binds to `0.0.0.0:8443` — share your LAN IP (e.g. `192.168.1.2:8443`) with friends.
|
||||
The client uses TOFU (Trust On First Use) for self-signed certificates: it prompts once, then pins the certificate for future connections.
|
||||
|
||||
The client uses TOFU (Trust On First Use) for self-signed certificates — it prompts to trust the server on first connection, then pins it for future sessions.
|
||||
## What OwnCord Already Has
|
||||
|
||||
### Voice & Video Setup (Optional)
|
||||
- Real-time channels and direct messages over WebSocket
|
||||
- Voice/video channels via LiveKit
|
||||
- Invite-only registration and role-based permissions
|
||||
- Web admin panel with logs, backups, and update tooling
|
||||
- File uploads and inline media rendering
|
||||
- TOTP 2FA support and API rate limiting
|
||||
- Desktop client auto-update with signature verification
|
||||
|
||||
**Binary deployment** — set in `config.yaml` and restart:
|
||||
```yaml
|
||||
voice:
|
||||
livekit_api_key: "my-unique-key"
|
||||
livekit_api_secret: "my-secret-min-32-characters-long!!"
|
||||
livekit_binary: "C:/path/to/livekit-server.exe" # Windows
|
||||
# livekit_binary: "/usr/local/bin/livekit-server" # Linux
|
||||
```
|
||||
OwnCord auto-starts LiveKit as a companion process.
|
||||
|
||||
**Docker deployment** — LiveKit runs as a separate container, configured via `.env` and `livekit.yaml`. See [LiveKit Setup](docs/livekit-setup.md#docker).
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
### Chat
|
||||
|
||||
- Real-time text messaging over WebSocket
|
||||
- Message editing, deletion, and replies
|
||||
- Emoji reactions with per-message counts
|
||||
- Typing indicators
|
||||
- Full-text message search (SQLite FTS5)
|
||||
- Pinned messages per channel
|
||||
- Rich link previews with Open Graph metadata
|
||||
- YouTube embed support with cached titles
|
||||
- GIF picker powered by Klipy with inline rendering and Klipy watermark
|
||||
- Inline image previews with lightbox viewer
|
||||
|
||||
### Voice & Video
|
||||
|
||||
- Voice channels powered by LiveKit SFU
|
||||
- Webcam video chat with Discord-style grid layout (fixed 16:9 aspect ratio)
|
||||
- Sidebar stream preview (hover to see live video thumbnail)
|
||||
- Mute, deafen, camera, and screenshare controls
|
||||
- Push-to-talk with global hotkey (non-consuming, works while unfocused)
|
||||
- Per-user volume control (right-click user in voice channel)
|
||||
- RNNoise ML noise suppression
|
||||
- Voice activity detection with speaker indicators (pulsing green glow)
|
||||
- Connection quality indicator with expandable transport stats
|
||||
- Voice call duration timer (MM:SS / HH:MM:SS elapsed)
|
||||
- LiveKit server runs as a companion process alongside `chatserver.exe`
|
||||
|
||||
### Direct Messages
|
||||
|
||||
- One-on-one DM conversations with any server member
|
||||
- DM preview section in sidebar with unread bubble indicators
|
||||
- Auto-reopen DM channels on incoming message
|
||||
- DM header shows `@ username` with live online status
|
||||
|
||||
### Channels & Organization
|
||||
|
||||
- Text and voice channels organized by categories
|
||||
- Create, edit, delete, and reorder channels
|
||||
- Unread message indicators
|
||||
- Quick channel switcher (Ctrl+K)
|
||||
|
||||
### File Sharing
|
||||
|
||||
- Drag-and-drop and clipboard paste uploads
|
||||
- Inline image previews with persistent caching (IndexedDB)
|
||||
- File download with native save dialog
|
||||
- Configurable max upload size
|
||||
|
||||
### Users & Permissions
|
||||
|
||||
- Invite-only registration with invite codes
|
||||
- Role-based permissions with custom roles
|
||||
- Member list with online/offline presence
|
||||
- User profiles with status (online, idle, dnd, offline)
|
||||
|
||||
### Administration
|
||||
|
||||
- Web-based admin panel at `/admin` (IP-restricted to private networks by default)
|
||||
- Dashboard with server stats and recent activity
|
||||
- User management (ban, kick, role assignment) with modals
|
||||
- Channel management (create, edit, delete)
|
||||
- Settings management (server name, MOTD, limits, security)
|
||||
- Live server log streaming via SSE with level filters,
|
||||
search, auto-scroll, pause/resume, copy, and clear
|
||||
- Audit log with search, action type filter, copy, and CSV export
|
||||
- Database backup and restore with pre-restore safety backups
|
||||
- Server update checker and one-click apply (GitHub Releases)
|
||||
- Metrics endpoint with uptime, goroutines, heap, connected users
|
||||
- Diagnostics endpoint for connectivity checks
|
||||
|
||||
### Security
|
||||
|
||||
- TLS encryption (self-signed, Let's Encrypt, or custom cert)
|
||||
- Trust-on-first-use certificate pinning in the client
|
||||
- Two-factor authentication (TOTP) with QR enrollment and backup codes
|
||||
- Ed25519-signed client auto-updates
|
||||
- Rate limiting on all endpoints
|
||||
- CSRF protection and security headers
|
||||
- Account deletion with password confirmation and data anonymization
|
||||
|
||||
### Desktop Client
|
||||
|
||||
- Native desktop app built with Tauri v2 — Windows x64, Linux x64, Linux ARM64
|
||||
- System tray integration
|
||||
- Desktop notifications with taskbar flash and sound
|
||||
- In-app auto-update with progress notification
|
||||
- Credential storage via system keychain (Windows Credential Manager / Linux Secret Service / macOS Keychain)
|
||||
- Auto-login with saved credentials (one-click connect)
|
||||
- Custom emoji picker
|
||||
- Compact mode for information-dense layouts
|
||||
- Discord-style settings panel with blurred backdrop
|
||||
- OC Neon Glow theme with custom theming system (JSON import/export)
|
||||
- Accent color picker
|
||||
- Quick-switch server overlay for multi-server users
|
||||
- Structured logging with JSONL persistence (5-day rotation)
|
||||
|
||||
|
||||
### Networking
|
||||
|
||||
For friends outside your LAN, you need to forward these ports:
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
| ---- | -------- | ------- |
|
||||
| `8443` | TCP | HTTPS, WebSocket, REST API |
|
||||
| `7881` | TCP | LiveKit signaling (voice/video) |
|
||||
| `50000-60000` | UDP | LiveKit WebRTC media (voice/video) |
|
||||
|
||||
Alternatively, use Tailscale for zero-config networking
|
||||
with no port forwarding.
|
||||
See deeper feature and architecture docs in [docs/client-architecture.md](docs/client-architecture.md) and [docs/protocol.md](docs/protocol.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
Two components: a **Go server** and a **Tauri v2 client**
|
||||
(Rust + TypeScript).
|
||||
Two main components:
|
||||
|
||||
- Go server (REST API, WebSocket hub, SQLite, admin panel)
|
||||
- Tauri v2 desktop client (Rust backend + TypeScript frontend)
|
||||
|
||||
```text
|
||||
+---------------------+ +---------------------+
|
||||
@@ -204,179 +120,103 @@ Two components: a **Go server** and a **Tauri v2 client**
|
||||
+---------------------+
|
||||
```
|
||||
|
||||
- **WebSocket** — chat messages, typing, presence, voice signaling
|
||||
- **REST API** — message history, file uploads, channel management, auth
|
||||
- **LiveKit** — voice and video via LiveKit SFU (companion process)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
OwnCord/
|
||||
├── Server/ # Go server
|
||||
│ ├── api/ # REST handlers + middleware
|
||||
│ ├── ws/ # WebSocket hub + handlers
|
||||
│ ├── db/ # SQLite queries + migrations
|
||||
│ ├── auth/ # Authentication + rate limiting
|
||||
│ ├── config/ # YAML config loading
|
||||
│ ├── updater/ # GitHub Releases update checker
|
||||
│ ├── admin/ # Web admin panel (static SPA)
|
||||
│ ├── storage/ # File upload storage
|
||||
│ ├── permissions/ # Role-based permission system
|
||||
│ └── migrations/ # Database migration files
|
||||
├── Client/
|
||||
│ └── tauri-client/ # Tauri v2 desktop client
|
||||
│ ├── src-tauri/ # Rust backend (plugins, commands)
|
||||
│ ├── src/ # TypeScript frontend
|
||||
│ │ ├── lib/ # Core services (API, WS, LiveKit, updater)
|
||||
│ │ ├── stores/ # Reactive state (auth, channels, messages, voice)
|
||||
│ │ ├── components/ # UI components (28 modules)
|
||||
│ │ ├── pages/ # Page layouts
|
||||
│ │ └── styles/ # CSS
|
||||
│ └── tests/ # Unit, integration, and E2E tests
|
||||
└── docs/ # Project documentation (Obsidian vault)
|
||||
```
|
||||
|
||||
## Building from Source
|
||||
## Build and Test
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+ and Rust (stable) — client only
|
||||
- Windows x64, Linux x64, or Linux ARM64
|
||||
- Node.js 20+
|
||||
- Rust stable (client builds)
|
||||
|
||||
### Server
|
||||
### Build from source
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
# Server (Windows)
|
||||
cd Server
|
||||
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" .
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
# Server (Linux)
|
||||
cd Server
|
||||
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" .
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
```bash
|
||||
# Client
|
||||
cd Client/tauri-client
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
Output location:
|
||||
- **Windows**: `src-tauri/target/release/bundle/nsis/` (NSIS installer)
|
||||
- **Linux**: `src-tauri/target/release/bundle/appimage/` and `bundle/deb/`
|
||||
|
||||
### Running Tests
|
||||
### Core verification commands
|
||||
|
||||
```bash
|
||||
# Server
|
||||
cd Server && go test ./...
|
||||
cd Server && go test ./... -cover # with coverage
|
||||
cd Server
|
||||
go test ./...
|
||||
|
||||
# Client
|
||||
cd Client/tauri-client
|
||||
npm test # all tests (vitest)
|
||||
npm run test:unit # unit tests only
|
||||
npm run test:integration # integration tests
|
||||
npm run test:e2e # Playwright E2E (mocked Tauri)
|
||||
npm run test:e2e:native # Playwright E2E (real Tauri exe + CDP)
|
||||
npm run test:coverage # coverage report
|
||||
|
||||
# Type checking & linting
|
||||
npm run typecheck # full typecheck
|
||||
npm run lint # ESLint check
|
||||
npm run lint:fix # ESLint auto-fix
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
For the full command set, use [docs/contributing.md](docs/contributing.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
The server generates a `config.yaml` on first run. All runtime data
|
||||
is stored in a `data/` directory alongside the executable:
|
||||
On first run, the server generates `config.yaml` and a local `data/` directory:
|
||||
|
||||
```text
|
||||
data/
|
||||
├── owncord.db # SQLite database
|
||||
├── certs/ # TLS certificates (auto-generated if self_signed)
|
||||
├── uploads/ # User-uploaded files
|
||||
└── backups/ # Database backups
|
||||
├── chatserver.db
|
||||
├── certs/
|
||||
├── uploads/
|
||||
└── backups/
|
||||
```
|
||||
|
||||
Key settings:
|
||||
Key options include TLS mode, upload limits, LiveKit settings, and admin CIDR restrictions.
|
||||
See [docs/server-configuration.md](docs/server-configuration.md).
|
||||
|
||||
| Setting | Default | Description |
|
||||
| ------- | ------- | ----------- |
|
||||
| `server.port` | `8443` | HTTPS port |
|
||||
| `server.name` | `OwnCord Server` | Display name |
|
||||
| `tls.mode` | `self_signed` | TLS mode (self_signed, acme, manual, off) |
|
||||
| `upload.max_size_mb` | `100` | Max upload size |
|
||||
| `voice.livekit_url` | `ws://localhost:7880` | LiveKit server WebSocket URL |
|
||||
| `voice.livekit_api_key` | — | LiveKit API key (required for voice) |
|
||||
| `voice.livekit_api_secret` | — | LiveKit API secret (min 32 chars, required for voice) |
|
||||
| `voice.livekit_binary` | — | Path to `livekit-server` binary (empty = don't auto-start) |
|
||||
| `voice.quality` | `medium` | Voice quality (low, medium, high) |
|
||||
| `server.admin_allowed_cidrs` | private nets | CIDRs allowed to access `/admin` |
|
||||
| `github.token` | — | Token for update checks (optional, for higher rate limits) |
|
||||
## Security and Vulnerability Reporting
|
||||
|
||||
## Auto-Updates
|
||||
- For vulnerabilities, use GitHub Security Advisories (private disclosure flow).
|
||||
- Do not open public issues for security bugs.
|
||||
- Read full policy and hardening notes in [docs/security.md](docs/security.md).
|
||||
|
||||
The client checks for updates after connecting to the server.
|
||||
Client updates are Ed25519-signed and verified before install.
|
||||
Server auto-updates use a separate minisign/Ed25519 signing key, verify `chatserver.exe.sig`, and require a signed `server-update-manifest.json` that binds the binary hash to the release version before apply.
|
||||
## Update Signing Notes (Maintainers)
|
||||
|
||||
For maintainers publishing signed releases from GitHub Actions, configure these repository secrets:
|
||||
Client and server update signing keys are intentionally separate.
|
||||
|
||||
- `TAURI_SIGNING_PRIVATE_KEY` — client updater private key
|
||||
(via `npx tauri signer generate`)
|
||||
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — client updater key password
|
||||
- `SERVER_UPDATE_SIGNING_PRIVATE_KEY` — server updater private key
|
||||
- `SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD` — server updater key password
|
||||
Required Actions secrets for release signing:
|
||||
|
||||
These are secret names only. Do not commit private key material or passphrases to the repository.
|
||||
- `TAURI_SIGNING_PRIVATE_KEY`
|
||||
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
- `SERVER_UPDATE_SIGNING_PRIVATE_KEY`
|
||||
- `SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
|
||||
When rotating the server updater key, also update [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt). For live deployments that rely on server auto-update continuity, treat key rotation as a staged rollover rather than a one-step secret swap.
|
||||
When rotating the server updater key, update [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt) and use staged rollover for live fleets.
|
||||
|
||||
## Documentation
|
||||
## Docs Index
|
||||
|
||||
- [Quick Start Guide](docs/quick-start.md)
|
||||
- [Server Configuration](docs/server-configuration.md)
|
||||
- [LiveKit Setup (Voice/Video)](docs/livekit-setup.md)
|
||||
- [Deployment Guide](docs/deployment.md)
|
||||
- [Port Forwarding](docs/port-forwarding.md)
|
||||
- [Tailscale Guide](docs/tailscale.md)
|
||||
- [REST API Reference](docs/api.md)
|
||||
- [WebSocket Protocol](docs/protocol.md)
|
||||
- [Database Schema](docs/schema.md)
|
||||
- [Client Architecture](docs/client-architecture.md)
|
||||
- [Contributing](docs/contributing.md)
|
||||
- [Security Policy](docs/security.md)
|
||||
- [docs/quick-start.md](docs/quick-start.md)
|
||||
- [docs/deployment.md](docs/deployment.md)
|
||||
- [docs/livekit-setup.md](docs/livekit-setup.md)
|
||||
- [docs/port-forwarding.md](docs/port-forwarding.md)
|
||||
- [docs/tailscale.md](docs/tailscale.md)
|
||||
- [docs/api.md](docs/api.md)
|
||||
- [docs/protocol.md](docs/protocol.md)
|
||||
- [docs/schema.md](docs/schema.md)
|
||||
- [docs/client-architecture.md](docs/client-architecture.md)
|
||||
- [docs/contributing.md](docs/contributing.md)
|
||||
- [docs/security.md](docs/security.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repo and create a feature branch from `dev`
|
||||
2. Follow existing code style and conventions
|
||||
3. Write tests for new functionality
|
||||
4. Open a PR against `dev` with a clear description
|
||||
1. Create a branch from `dev`.
|
||||
2. Keep changes focused and tested.
|
||||
3. Open a PR targeting `dev`.
|
||||
|
||||
See [Contributing Guide](docs/contributing.md) for details.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
| --------- | --------- |
|
||||
| Server | Go, chi router, LiveKit server SDK |
|
||||
| Database | SQLite (pure Go, embedded) |
|
||||
| Client | Tauri v2 (Rust + TypeScript) |
|
||||
| Voice/Video | LiveKit SFU (companion process or Docker) |
|
||||
| Build | NSIS (Windows), AppImage + deb (Linux), GitHub Actions CI |
|
||||
See [docs/contributing.md](docs/contributing.md) for the full process.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
*Built with [Claude Code](https://claude.ai/code) and [GitHub Copilot](https://github.com/features/copilot).*
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package api
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/config"
|
||||
)
|
||||
|
||||
// ─── Rate limits ────────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -123,7 +127,7 @@ const (
|
||||
|
||||
const (
|
||||
// defaultMaxBodySize is the default request body size limit (1 MiB).
|
||||
defaultMaxBodySize = 1 << 20
|
||||
defaultMaxBodySize = config.MaxMessageBytes
|
||||
|
||||
// uploadMaxBodySize is the request body size limit for file uploads (100 MiB).
|
||||
uploadMaxBodySize = 100 << 20
|
||||
|
||||
@@ -5,6 +5,7 @@ package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -322,7 +323,7 @@ func TestRevokeSession_NegativeID(t *testing.T) {
|
||||
// ─── handleLiveKitHealth via exported test helper ───────────────────────────
|
||||
|
||||
func TestLiveKitHealth_OK(t *testing.T) {
|
||||
handler := api.HandleLiveKitHealthForTest(func() (bool, error) {
|
||||
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
@@ -345,7 +346,7 @@ func TestLiveKitHealth_OK(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLiveKitHealth_Degraded_WithError(t *testing.T) {
|
||||
handler := api.HandleLiveKitHealthForTest(func() (bool, error) {
|
||||
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
||||
return false, errors.New("connection refused")
|
||||
})
|
||||
|
||||
@@ -368,7 +369,7 @@ func TestLiveKitHealth_Degraded_WithError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLiveKitHealth_Degraded_NilError(t *testing.T) {
|
||||
handler := api.HandleLiveKitHealthForTest(func() (bool, error) {
|
||||
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
||||
return false, nil
|
||||
})
|
||||
|
||||
@@ -637,7 +638,7 @@ func TestSetPinned_Unauthorized(t *testing.T) {
|
||||
// writeJSON is at 75% — testing the success path covers the rest.
|
||||
|
||||
func TestWriteJSON_BasicSuccess(t *testing.T) {
|
||||
handler := api.HandleLiveKitHealthForTest(func() (bool, error) {
|
||||
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func handleDiagnosticsConnectivity(
|
||||
clientAddr := clientIP(r)
|
||||
|
||||
lkHealthy := false
|
||||
if ok, _ := hub.LiveKitHealthCheck(); ok { //nolint:contextcheck // TODO: propagate context through this call path
|
||||
if ok, _ := hub.LiveKitHealthCheck(r.Context()); ok {
|
||||
lkHealthy = true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleMetricsForTest exposes handleMetrics for use in external tests.
|
||||
var HandleMetricsForTest = handleMetrics
|
||||
|
||||
// HandleLiveKitHealthForTest exposes handleLiveKitHealth for use in external tests.
|
||||
func HandleLiveKitHealthForTest(healthCheck func() (bool, error)) http.HandlerFunc {
|
||||
func HandleLiveKitHealthForTest(healthCheck func(context.Context) (bool, error)) http.HandlerFunc {
|
||||
// Inline the logic since handleLiveKitHealth requires a *ws.Hub.
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := healthCheck()
|
||||
ok, err := healthCheck(r.Context())
|
||||
if ok {
|
||||
writeJSON(w, http.StatusOK, livekitHealthResponse{
|
||||
Status: "ok",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
@@ -16,13 +17,15 @@ type ServerMetrics struct {
|
||||
NumGC uint32 `json:"num_gc"`
|
||||
ConnectedUsers int `json:"connected_users"`
|
||||
VoiceSessions int `json:"voice_sessions"`
|
||||
BroadcastDrops uint64 `json:"broadcast_drops"`
|
||||
LiveKitHealthy *bool `json:"livekit_healthy,omitempty"`
|
||||
}
|
||||
|
||||
// handleMetrics returns an HTTP handler that reports runtime server metrics.
|
||||
// getConnectedUsers is a callback to retrieve the current WebSocket client count.
|
||||
// getBroadcastDrops is a callback to retrieve the cumulative broadcast drop counter.
|
||||
// livekitHealthCheck is optional — if non-nil, it probes the LiveKit companion process.
|
||||
func handleMetrics(getConnectedUsers func() int, getVoiceSessions func() int, livekitHealthCheck func() (bool, error)) http.HandlerFunc {
|
||||
func handleMetrics(getConnectedUsers func() int, getVoiceSessions func() int, getBroadcastDrops func() uint64, livekitHealthCheck func(context.Context) (bool, error)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
@@ -37,10 +40,11 @@ func handleMetrics(getConnectedUsers func() int, getVoiceSessions func() int, li
|
||||
NumGC: m.NumGC,
|
||||
ConnectedUsers: getConnectedUsers(),
|
||||
VoiceSessions: getVoiceSessions(),
|
||||
BroadcastDrops: getBroadcastDrops(),
|
||||
}
|
||||
|
||||
if livekitHealthCheck != nil {
|
||||
healthy, _ := livekitHealthCheck()
|
||||
healthy, _ := livekitHealthCheck(r.Context())
|
||||
metrics.LiveKitHealthy = &healthy
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -17,7 +18,8 @@ func buildMetricsRouter(allowedCIDRs []string) http.Handler {
|
||||
Get("/api/v1/metrics", api.HandleMetricsForTest(
|
||||
func() int { return 5 },
|
||||
func() int { return 2 },
|
||||
func() (bool, error) { return true, nil },
|
||||
func() uint64 { return 0 },
|
||||
func(_ context.Context) (bool, error) { return true, nil },
|
||||
))
|
||||
return r
|
||||
}
|
||||
@@ -42,7 +44,7 @@ func TestHandleMetrics_ReturnsExpectedFields(t *testing.T) {
|
||||
requiredFields := []string{
|
||||
"uptime", "uptime_seconds", "goroutines",
|
||||
"heap_alloc_mb", "heap_sys_mb", "num_gc",
|
||||
"connected_users", "voice_sessions", "livekit_healthy",
|
||||
"connected_users", "voice_sessions", "broadcast_drops", "livekit_healthy",
|
||||
}
|
||||
for _, f := range requiredFields {
|
||||
if _, ok := resp[f]; !ok {
|
||||
@@ -93,6 +95,7 @@ func TestHandleMetrics_WithoutLiveKitHealthCheck(t *testing.T) {
|
||||
r.Get("/api/v1/metrics", api.HandleMetricsForTest(
|
||||
func() int { return 0 },
|
||||
func() int { return 0 },
|
||||
func() uint64 { return 0 },
|
||||
nil, // no livekit
|
||||
))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -786,7 +787,7 @@ func TestSecurityHeadersWithTLS_NoHSTSWithoutTLS(t *testing.T) {
|
||||
// ─── handleLiveKitHealth tests ──────────────────────────────────────────────
|
||||
|
||||
func TestLiveKitHealth_Healthy(t *testing.T) {
|
||||
h := api.HandleLiveKitHealthForTest(func() (bool, error) {
|
||||
h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
@@ -809,7 +810,7 @@ func TestLiveKitHealth_Healthy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLiveKitHealth_Unhealthy(t *testing.T) {
|
||||
h := api.HandleLiveKitHealthForTest(func() (bool, error) {
|
||||
h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
||||
return false, fmt.Errorf("connection refused")
|
||||
})
|
||||
|
||||
@@ -835,7 +836,7 @@ func TestLiveKitHealth_Unhealthy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLiveKitHealth_UnhealthyNoError(t *testing.T) {
|
||||
h := api.HandleLiveKitHealthForTest(func() (bool, error) {
|
||||
h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
||||
return false, nil
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -190,7 +191,8 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
|
||||
Get("/api/v1/metrics", handleMetrics(
|
||||
func() int { return hub.ClientCount() },
|
||||
func() int { return hub.VoiceSessionCount() },
|
||||
func() (bool, error) { return hub.LiveKitHealthCheck() },
|
||||
func() uint64 { return hub.BroadcastDropCount() },
|
||||
func(ctx context.Context) (bool, error) { return hub.LiveKitHealthCheck(ctx) },
|
||||
))
|
||||
|
||||
// Admin panel: static files + REST API (Phase 6).
|
||||
@@ -266,7 +268,7 @@ type livekitHealthResponse struct {
|
||||
|
||||
func handleLiveKitHealth(hub *ws.Hub) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := hub.LiveKitHealthCheck() //nolint:contextcheck // TODO: propagate context through this call path
|
||||
ok, err := hub.LiveKitHealthCheck(r.Context())
|
||||
if ok {
|
||||
writeJSON(w, http.StatusOK, livekitHealthResponse{
|
||||
Status: "ok",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
// MaxMessageBytes is the maximum size of a single inbound message or
|
||||
// small HTTP response body that the server will read into memory (1 MiB).
|
||||
// Used by the WebSocket read-limit and the updater's capped response reader.
|
||||
MaxMessageBytes = 1 << 20
|
||||
)
|
||||
+6
-1
@@ -330,6 +330,11 @@ func getOutboundIP() string {
|
||||
return "localhost"
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
addr := conn.LocalAddr().(*net.UDPAddr)
|
||||
addr, ok := conn.LocalAddr().(*net.UDPAddr)
|
||||
if !ok {
|
||||
slog.Warn("getOutboundIP: unexpected LocalAddr type, falling back to localhost",
|
||||
"type", fmt.Sprintf("%T", conn.LocalAddr()))
|
||||
return "localhost"
|
||||
}
|
||||
return addr.IP.String()
|
||||
}
|
||||
|
||||
@@ -25,14 +25,19 @@ import (
|
||||
|
||||
"aead.dev/minisign"
|
||||
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/syncutil"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://api.github.com"
|
||||
cacheTTL = 1 * time.Hour
|
||||
defaultBaseURL = "https://api.github.com"
|
||||
cacheTTL = 1 * time.Hour
|
||||
|
||||
// maxFetchBytes caps the response body read for checksum/signature files.
|
||||
// Prevents a malicious or corrupted release asset from exhausting memory.
|
||||
maxFetchBytes = config.MaxMessageBytes
|
||||
errorCacheTTL = 5 * time.Minute
|
||||
checksumAsset = "checksums.sha256"
|
||||
signatureAsset = windowsServerBinary + ".sig"
|
||||
@@ -673,7 +678,7 @@ func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) {
|
||||
|
||||
// Cap reads at 1 MiB — checksum and signature files are tiny text;
|
||||
// this prevents a malicious or corrupted release asset from exhausting memory.
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes))
|
||||
}
|
||||
|
||||
// FindClientAssets scans the cached release assets for the Tauri NSIS
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/owncord/server/syncutil"
|
||||
)
|
||||
|
||||
const sendBufSize = 256
|
||||
const sendBufSize = 256 // per-client outbound send-channel capacity
|
||||
|
||||
// SessionCheckInterval is the number of messages processed between periodic
|
||||
// session-expiry checks in readPump. Exported so tests can trigger the check
|
||||
|
||||
+15
-5
@@ -2,6 +2,7 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
@@ -39,8 +40,9 @@ type Hub struct {
|
||||
registry *HandlerRegistry
|
||||
permChecker *permissions.Checker
|
||||
|
||||
seq uint64 // atomic monotonic sequence counter
|
||||
replayBuf *EventRingBuffer // recent broadcast events for reconnection replay
|
||||
seq uint64 // atomic monotonic sequence counter
|
||||
replayBuf *EventRingBuffer // recent broadcast events for reconnection replay
|
||||
broadcastDrops atomic.Uint64 // counts messages dropped due to full broadcast channel
|
||||
|
||||
// Settings cache — avoids per-connection DB queries for server_name/motd.
|
||||
settingsMu syncutil.RWMutex
|
||||
@@ -63,7 +65,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub {
|
||||
clients: make(map[int64]*Client),
|
||||
db: database,
|
||||
limiter: limiter,
|
||||
broadcast: make(chan broadcastMsg, 256),
|
||||
broadcast: make(chan broadcastMsg, 1024),
|
||||
register: make(chan *Client, 32),
|
||||
unregister: make(chan *Client, 32),
|
||||
stop: make(chan struct{}),
|
||||
@@ -122,11 +124,11 @@ func (h *Hub) SetLiveKit(lk *LiveKitClient) {
|
||||
// It tries the SDK client first (ListRooms), and falls back to an HTTP probe
|
||||
// if a managed process is configured. Returns false with a reason if LiveKit
|
||||
// is not configured or unreachable.
|
||||
func (h *Hub) LiveKitHealthCheck() (bool, error) {
|
||||
func (h *Hub) LiveKitHealthCheck(ctx context.Context) (bool, error) {
|
||||
if h.livekit == nil {
|
||||
return false, fmt.Errorf("not configured")
|
||||
}
|
||||
return h.livekit.HealthCheck()
|
||||
return h.livekit.HealthCheck(ctx)
|
||||
}
|
||||
|
||||
// SetLiveKitProcess sets the LiveKit process manager on the hub.
|
||||
@@ -353,6 +355,7 @@ func (h *Hub) BroadcastToChannel(channelID int64, msg []byte) {
|
||||
select {
|
||||
case h.broadcast <- broadcastMsg{channelID: channelID, msg: msg}:
|
||||
default:
|
||||
h.broadcastDrops.Add(1)
|
||||
slog.Warn("hub: broadcast channel full, dropping message",
|
||||
"channel_id", channelID, "msg_len", len(msg))
|
||||
}
|
||||
@@ -364,6 +367,7 @@ func (h *Hub) BroadcastToAll(msg []byte) {
|
||||
select {
|
||||
case h.broadcast <- broadcastMsg{channelID: 0, msg: msg}:
|
||||
default:
|
||||
h.broadcastDrops.Add(1)
|
||||
slog.Warn("hub: broadcast channel full, dropping global message",
|
||||
"msg_len", len(msg))
|
||||
}
|
||||
@@ -442,6 +446,12 @@ func (h *Hub) ClientCount() int {
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// BroadcastDropCount returns the cumulative number of messages dropped due to a
|
||||
// full broadcast channel. Safe to call from any goroutine.
|
||||
func (h *Hub) BroadcastDropCount() uint64 {
|
||||
return h.broadcastDrops.Load()
|
||||
}
|
||||
|
||||
// VoiceSessionCount returns the number of clients currently in a voice channel.
|
||||
func (h *Hub) VoiceSessionCount() int {
|
||||
h.mu.RLock()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
@@ -796,7 +797,7 @@ func TestHub_SweepRevokedSessions_EmptyTokenHashSkipped(t *testing.T) {
|
||||
|
||||
func TestHub_LiveKitHealthCheck_NilReturnsError(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
ok, err := hub.LiveKitHealthCheck()
|
||||
ok, err := hub.LiveKitHealthCheck(context.Background())
|
||||
if ok {
|
||||
t.Error("expected ok=false when LiveKit is nil")
|
||||
}
|
||||
|
||||
@@ -210,8 +210,8 @@ func (c *LiveKitClient) CountVideoTracks(channelID int64) (int, error) {
|
||||
|
||||
// HealthCheck verifies connectivity to the LiveKit server by listing rooms.
|
||||
// Returns true if the server responds successfully.
|
||||
func (c *LiveKitClient) HealthCheck() (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
func (c *LiveKitClient) HealthCheck(ctx context.Context) (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.roomSvc.ListRooms(ctx, &livekit.ListRoomsRequest{})
|
||||
|
||||
@@ -245,10 +245,10 @@ func (p *LiveKitProcess) IsRunning() bool {
|
||||
|
||||
// HealthCheck probes the LiveKit HTTP endpoint to verify it is accepting
|
||||
// connections. Returns true if the server responds (any status code).
|
||||
func (p *LiveKitProcess) HealthCheck() (bool, error) {
|
||||
func (p *LiveKitProcess) HealthCheck(ctx context.Context) (bool, error) {
|
||||
httpURL := wsToHTTP(p.cfg.LiveKitURL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, httpURL, nil)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -720,7 +721,7 @@ func TestHealthCheck_Success(t *testing.T) {
|
||||
|
||||
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
|
||||
|
||||
ok, err := proc.HealthCheck()
|
||||
ok, err := proc.HealthCheck(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
@@ -741,7 +742,7 @@ func TestHealthCheck_ServerDown(t *testing.T) {
|
||||
|
||||
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
|
||||
|
||||
ok, err := proc.HealthCheck()
|
||||
ok, err := proc.HealthCheck(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreachable server, got nil")
|
||||
}
|
||||
@@ -767,7 +768,7 @@ func TestHealthCheck_NonOKStatus(t *testing.T) {
|
||||
tlsCfg := &config.TLSConfig{}
|
||||
proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir())
|
||||
|
||||
ok, err := proc.HealthCheck()
|
||||
ok, err := proc.HealthCheck(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
|
||||
+6
-1
@@ -12,6 +12,7 @@ import (
|
||||
"nhooyr.io/websocket"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
@@ -20,6 +21,10 @@ const (
|
||||
authDeadline = 10 * time.Second
|
||||
writeTimeout = 10 * time.Second
|
||||
settingsCacheTTL = 30 * time.Second
|
||||
|
||||
// wsReadLimitBytes is the maximum size of a single inbound WebSocket
|
||||
// message. Must match the client-side upload cap.
|
||||
wsReadLimitBytes = config.MaxMessageBytes
|
||||
)
|
||||
|
||||
// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth,
|
||||
@@ -37,7 +42,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun
|
||||
slog.Warn("ws upgrade failed", "err", err)
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(1 << 20) // 1 MB — match client-side limit
|
||||
conn.SetReadLimit(wsReadLimitBytes) // match client-side upload cap
|
||||
|
||||
c, lastSeq, err := hub.upgradeAndAuth(conn, database, r)
|
||||
if err != nil {
|
||||
|
||||
+4
-2
@@ -6,8 +6,10 @@ Production deployment guide for OwnCord server on Windows and Linux.
|
||||
|
||||
- **Windows 10+** (x64) or **Linux** (x64)
|
||||
- **Go 1.25+** (only if building from source)
|
||||
- **LiveKit Server** binary (for voice/video) -- see [LiveKit Setup](livekit-setup.md)
|
||||
- Ports available: `8443` (default), `7880` (LiveKit), `80` (if using ACME/Let's Encrypt)
|
||||
- **LiveKit Server** binary (only if enabling voice/video) -- see [LiveKit Setup](livekit-setup.md)
|
||||
- Required port: `8443` (OwnCord HTTPS/WebSocket)
|
||||
- Additional ports for voice/video: `7880/TCP`, `7881/TCP`, `50000-60000/UDP`
|
||||
- Additional port for ACME TLS: `80/TCP`
|
||||
|
||||
## Building from Source
|
||||
|
||||
|
||||
+38
-22
@@ -1,32 +1,48 @@
|
||||
# Port Forwarding Guide
|
||||
|
||||
How to make your OwnCord server accessible to friends outside your local network.
|
||||
How to make your OwnCord server reachable from outside your LAN.
|
||||
|
||||
## Why
|
||||
## Before You Start
|
||||
|
||||
Friends outside your LAN need a way to reach your server. Port forwarding tells your router to send incoming traffic on a specific port to your server machine.
|
||||
If you want a simpler remote-access path, use [Tailscale](tailscale.md) and skip manual forwarding.
|
||||
|
||||
## Steps
|
||||
## Required Ports
|
||||
|
||||
1. **Find your router's admin page** -- usually `192.168.1.1` or `192.168.0.1`. Check your gateway IP with `ipconfig` (Windows) or `ip route` (Linux).
|
||||
2. **Find the port forwarding section** -- may be listed under "NAT", "Virtual Servers", or "Firewall" depending on your router.
|
||||
3. **Add a rule for the server:**
|
||||
- External port: `8443`
|
||||
- Internal IP: your server machine's local IP
|
||||
- Internal port: `8443`
|
||||
- Protocol: TCP
|
||||
4. **Add a rule for voice chat** (if using voice/video):
|
||||
- External port: `3478`
|
||||
- Internal IP: your server machine's local IP
|
||||
- Internal port: `3478`
|
||||
- Protocol: UDP
|
||||
5. **Find your public IP** at a site like `whatismyip.com`.
|
||||
6. **Share your public IP and port** with friends: `your.public.ip:8443`
|
||||
### Always required
|
||||
|
||||
## Troubleshooting
|
||||
| Port | Protocol | Purpose |
|
||||
| ---- | -------- | ------- |
|
||||
| `8443` | TCP | OwnCord HTTPS + WebSocket |
|
||||
|
||||
Windows Firewall may block incoming connections. `chatserver.exe` should prompt on first run to allow access. If not, manually add a firewall rule for port 8443 (TCP) and 3478 (UDP).
|
||||
### Required only for voice/video
|
||||
|
||||
## Dynamic IP
|
||||
| Port | Protocol | Purpose |
|
||||
| ---- | -------- | ------- |
|
||||
| `7880` | TCP | LiveKit signaling |
|
||||
| `7881` | TCP | LiveKit TCP fallback |
|
||||
| `50000-60000` | UDP | LiveKit media |
|
||||
|
||||
If your public IP changes frequently, consider a Dynamic DNS service (e.g., No-IP, DuckDNS) so friends can use a stable hostname instead of a raw IP address.
|
||||
## Router Steps
|
||||
|
||||
1. Open your router admin page (often `192.168.1.1` or `192.168.0.1`).
|
||||
2. Find the port forwarding section (sometimes called NAT, virtual server, or firewall rules).
|
||||
3. Set static/reserved LAN IP for your server machine.
|
||||
4. Add forwarding rule for `8443/TCP` to that LAN IP.
|
||||
5. If using voice/video, add `7880/TCP`, `7881/TCP`, and `50000-60000/UDP`.
|
||||
6. Save and apply rules.
|
||||
|
||||
## Connect Address to Share
|
||||
|
||||
Share `https://<your-public-ip>:8443` (or your DNS name) with users.
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
- Confirm the server is listening on `8443`.
|
||||
- Confirm router rules point to the correct LAN IP.
|
||||
- Confirm OS firewall allows forwarded ports.
|
||||
- Confirm ISP is not blocking inbound ports.
|
||||
- Test from a different network (mobile hotspot), not from the same LAN.
|
||||
|
||||
## Dynamic Public IP
|
||||
|
||||
If your public IP changes, use dynamic DNS so users connect with a stable hostname.
|
||||
|
||||
+59
-57
@@ -1,89 +1,91 @@
|
||||
# Quick Start Guide
|
||||
|
||||
Get OwnCord up and running in minutes.
|
||||
Get OwnCord running with the fewest possible steps.
|
||||
|
||||
## Choose Your Setup Path
|
||||
|
||||
| Goal | Best path |
|
||||
| ---- | --------- |
|
||||
| Fastest local/LAN setup | Prebuilt binaries |
|
||||
| Linux server with easiest operations | Docker |
|
||||
| Custom dev build | Build from source |
|
||||
|
||||
## Platform Support (Current Releases)
|
||||
|
||||
| Component | Windows x64 | Linux x64 | Linux ARM64 |
|
||||
| --------- | ----------- | --------- | ----------- |
|
||||
| Server binary | Yes | Yes | Not published yet |
|
||||
| Desktop client | Yes | Yes | Yes |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| | Windows x64 | Linux x64 | Linux ARM64 |
|
||||
|-|:-----------:|:---------:|:-----------:|
|
||||
| Server | ✅ | ✅ | ✅ |
|
||||
| Client | ✅ | ✅ | ✅ |
|
||||
- Go 1.25+ (only if building server from source)
|
||||
- Node.js 20+ and Rust (only if building client from source)
|
||||
- Docker + Compose v2 (Docker path only)
|
||||
- LiveKit (optional, required for voice/video)
|
||||
|
||||
- **Go 1.25+** (only if building the server from source)
|
||||
- **Node.js 20+** + **Rust / Cargo** (only if building the client from source)
|
||||
- **Docker + Compose v2** (alternative to building the server — Linux only)
|
||||
- **LiveKit Server** (optional, for voice/video) -- see [LiveKit Setup](livekit-setup.md)
|
||||
## Option A: Prebuilt binaries (recommended)
|
||||
|
||||
## Step 1: Download
|
||||
1. Download from [GitHub Releases](https://github.com/J3vb/OwnCord/releases).
|
||||
2. Start the server:
|
||||
- Windows: `chatserver.exe`
|
||||
- Linux: `./chatserver`
|
||||
3. Open `https://localhost:8443/admin`.
|
||||
4. Create the Owner account.
|
||||
5. Create invite codes and share them.
|
||||
|
||||
### Option A — Pre-built binaries (recommended)
|
||||
|
||||
Download from [GitHub Releases](https://github.com/J3vb/OwnCord/releases):
|
||||
|
||||
| Platform | Server | Client |
|
||||
|----------|--------|--------|
|
||||
| Windows x64 | `chatserver.exe` | `OwnCord_x.x.x_x64-setup.exe` |
|
||||
| Linux x64 | `chatserver-linux-amd64.tar.gz` | `OwnCord_x.x.x_x86_64.AppImage` or `_amd64.deb` |
|
||||
| Linux ARM64 | _(included in server tar)_ | `OwnCord_x.x.x_aarch64.AppImage` or `_arm64.deb` |
|
||||
|
||||
### Option B — Docker (Linux server only)
|
||||
## Option B: Docker (Linux server)
|
||||
|
||||
```bash
|
||||
cd Server
|
||||
cp .env.example .env # set LIVEKIT_API_KEY + LIVEKIT_API_SECRET
|
||||
cp livekit.yaml.example livekit.yaml # set node_ip + matching keys
|
||||
cp .env.example .env
|
||||
cp livekit.yaml.example livekit.yaml
|
||||
# Edit both files before start
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
See [Deployment Guide — Docker](deployment.md#docker-linux) for full details.
|
||||
Then open `https://localhost:8443/admin` and create the Owner account.
|
||||
|
||||
### Option C — Build from source
|
||||
Full Docker details: [Deployment Guide](deployment.md#docker-linux).
|
||||
|
||||
## Option C: Build from source
|
||||
|
||||
```bash
|
||||
# Server (Windows)
|
||||
cd Server && go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" .
|
||||
cd Server
|
||||
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" .
|
||||
|
||||
# Server (Linux)
|
||||
cd Server && CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" .
|
||||
cd Server
|
||||
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" .
|
||||
|
||||
# Client
|
||||
cd Client/tauri-client && npm install && npm run tauri build
|
||||
cd Client/tauri-client
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
## Step 2: Run the Server
|
||||
## What Happens on First Server Start
|
||||
|
||||
**Windows/Linux binary:** Run `chatserver.exe` (Windows) or `./chatserver` (Linux). On first run:
|
||||
- `config.yaml` is created with defaults.
|
||||
- `data/` is created for DB, certs, uploads, and backups.
|
||||
- A self-signed TLS certificate is generated.
|
||||
- SQLite schema and migrations are applied.
|
||||
|
||||
1. `config.yaml` is created in the working directory with default settings
|
||||
2. `data/` directory is created for the database, TLS certs, uploads, and backups
|
||||
3. A self-signed TLS certificate is generated automatically
|
||||
4. SQLite database is created and all migrations are applied
|
||||
5. All user statuses are reset to offline (clean slate)
|
||||
## Client Connection Notes
|
||||
|
||||
The server starts on `https://0.0.0.0:8443`.
|
||||
- The default server address is `https://<server-ip>:8443`.
|
||||
- The desktop client uses TOFU certificate pinning:
|
||||
- First connection prompts for trust.
|
||||
- Future connections require the same cert fingerprint.
|
||||
|
||||
See [Server Configuration](server-configuration.md) for the full config key reference and environment variable overrides.
|
||||
## If Remote Users Cannot Connect
|
||||
|
||||
## Step 3: Admin Setup
|
||||
|
||||
Open `https://localhost:8443/admin` in a browser. The first-run setup page will prompt you to create the Owner account (username + password). This user gets the Owner role with full server control.
|
||||
|
||||
## Step 4: Create Invites
|
||||
|
||||
In the admin panel, go to invite management and generate invite codes for your friends.
|
||||
|
||||
## Step 5: Connect Clients
|
||||
|
||||
Friends install OwnCord, enter your server address (IP or domain + port 8443), and redeem their invite code to register.
|
||||
|
||||
The client uses TOFU (Trust On First Use) for self-signed certificates -- it will prompt to trust the server's certificate on first connection, then pin it for future sessions.
|
||||
|
||||
## Networking
|
||||
|
||||
If friends are outside your local network, see the [Port Forwarding Guide](port-forwarding.md) or use [Tailscale](tailscale.md) for zero-config networking.
|
||||
1. Use [Tailscale](tailscale.md) for the simplest remote setup.
|
||||
2. Or configure [Port Forwarding](port-forwarding.md).
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Server Configuration](server-configuration.md) -- customize ports, TLS, uploads, voice
|
||||
- [Deployment Guide](deployment.md) -- production hardening, backups, monitoring, Windows service setup
|
||||
- [LiveKit Setup](livekit-setup.md) -- enable voice and video chat
|
||||
- [Server Configuration](server-configuration.md)
|
||||
- [Deployment Guide](deployment.md)
|
||||
- [LiveKit Setup](livekit-setup.md)
|
||||
|
||||
+24
-14
@@ -1,23 +1,33 @@
|
||||
# Tailscale Guide (Zero-Config Alternative)
|
||||
# Tailscale Guide (Zero-Config Remote Access)
|
||||
|
||||
Use Tailscale for secure, zero-config networking without port forwarding.
|
||||
Use Tailscale when you want remote access without port forwarding.
|
||||
|
||||
## What is Tailscale
|
||||
## Why Tailscale
|
||||
|
||||
Tailscale is a mesh VPN that creates encrypted tunnels between your devices using WireGuard. No port forwarding, no dynamic DNS, and it works behind CGNAT. Free for personal use.
|
||||
Tailscale creates an encrypted private network between your devices using WireGuard.
|
||||
It works behind CGNAT and strict home routers, so setup is usually faster than manual forwarding.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install Tailscale** on the server machine and each client machine: https://tailscale.com/download
|
||||
2. **Sign in** with the same Tailscale account (or share the machine using Tailscale's sharing feature)
|
||||
3. **Find the server's Tailscale IP** -- shown in the Tailscale app, typically `100.x.y.z`
|
||||
4. **Disable TLS in config** -- set `tls.mode` to `"off"` in `config.yaml` since Tailscale already encrypts all traffic with WireGuard
|
||||
5. **Connect clients** using the Tailscale IP: `100.x.y.z:8443`
|
||||
1. Install Tailscale on the server machine and client machines: https://tailscale.com/download
|
||||
2. Sign in and confirm all devices are in the same tailnet (or shared access is granted).
|
||||
3. Get the server Tailscale IP (usually `100.x.y.z`) from the Tailscale app.
|
||||
4. Keep OwnCord on port `8443`.
|
||||
5. Connect clients to `https://<tailscale-ip>:8443`.
|
||||
|
||||
## TLS Recommendation
|
||||
|
||||
- Recommended: keep `tls.mode: self_signed` (default).
|
||||
- Optional advanced setup: set `tls.mode: off` only if every client is strictly inside trusted Tailscale access and you accept plaintext inside the tailnet.
|
||||
|
||||
## Voice/Video with Tailscale
|
||||
|
||||
- Tailscale handles device-to-device reachability, but LiveKit still needs correct runtime config.
|
||||
- Follow [livekit-setup.md](livekit-setup.md) for LiveKit key/secret and port behavior.
|
||||
|
||||
## Benefits
|
||||
|
||||
- No port forwarding needed
|
||||
- Works behind CGNAT and strict firewalls
|
||||
- Encrypted by default (WireGuard)
|
||||
- Stable IPs that don't change
|
||||
- Easy to add/remove friends via the Tailscale admin console
|
||||
- No router port forwarding.
|
||||
- Works behind CGNAT.
|
||||
- Stable private IPs.
|
||||
- Encrypted transport by default.
|
||||
|
||||
Reference in New Issue
Block a user