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:
J3vb
2026-04-03 23:18:06 +02:00
parent dcbcc09777
commit c3a8aa477c
36 changed files with 1087 additions and 686 deletions
-42
View File
@@ -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
+1
View File
@@ -2949,6 +2949,7 @@ dependencies = [
"futures-util",
"keyring",
"log",
"rfd",
"ring",
"rustls",
"serde",
+1
View File
@@ -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"] }
+13 -5
View File
@@ -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";
+27 -3
View File
@@ -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 ───────────────────────────────
+84 -15
View File
@@ -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.
+98 -47
View File
@@ -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);
+432 -131
View File
@@ -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,
+19 -7
View File
@@ -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);
+16 -5
View File
@@ -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);
}
});
}
+115 -275
View File
@@ -1,20 +1,27 @@
![stability-experimental](https://img.shields.io/badge/stability-experimental-orange.svg?style=for-the-badge)
![Go](https://img.shields.io/badge/go-%2300ADD8.svg?style=for-the-badge&logo=go&logoColor=white)
![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)
![NPM](https://img.shields.io/badge/NPM-%23CB3837.svg?style=for-the-badge&logo=npm&logoColor=white)
# 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).*
+6 -2
View File
@@ -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 -4
View File
@@ -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
})
+1 -1
View File
@@ -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
}
+6 -3
View File
@@ -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",
+6 -2
View File
@@ -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
}
+5 -2
View File
@@ -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
))
+4 -3
View File
@@ -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
})
+4 -2
View File
@@ -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",
+8
View File
@@ -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
View File
@@ -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()
}
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+2 -1
View File
@@ -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")
}
+2 -2
View File
@@ -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{})
+2 -2
View File
@@ -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)
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.