fix(client): drop the previous server's bearer token on a host switch

api.setConfig spread the new config over the old, so switching hosts
carried the previous server's session token forward and the login request
to the next server went out holding a live credential for the first one.
The token is now dropped in the shared setConfig when host changes
without an accompanying token, covering login, register and auto-connect
at once.

Also fixes a packaged-build-only failure: the CSP omitted blob: from
img-src, so avatar upload validation (which measures the image via
URL.createObjectURL) always failed in release and never in dev.

Smaller connection and IPC fixes: ws_disconnect now bumps the connection
generation instead of nulling the sender slot, so an in-flight handshake
cannot install after a disconnect; a dead LiveKit proxy listener
deregisters itself instead of being reused forever; httpProxy no longer
caches an origin the Rust side may have torn down; logPersistence stopped
looping on its own flush-failure logs; ConnectPage subscribes to
transientError instead of reading it once; cert-mismatch accept/reject
only act when the event host matches the live session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-08 12:56:51 +02:00
co-authored by Claude Opus 5
parent 1c193afa16
commit 8917c2807a
13 changed files with 360 additions and 38 deletions
@@ -68,7 +68,7 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
/// On macOS it is stored in the system Keychain. The write is read back before /// On macOS it is stored in the system Keychain. The write is read back before
/// this returns — see [`crate::secret_store`] for what happens when it does not /// this returns — see [`crate::secret_store`] for what happens when it does not
/// come back. /// come back.
#[tauri::command] #[tauri::command(async)]
pub fn save_credential( pub fn save_credential(
app: AppHandle, app: AppHandle,
host: String, host: String,
@@ -96,7 +96,7 @@ pub fn save_credential(
/// Load a credential from the system credential store. /// Load a credential from the system credential store.
/// ///
/// Returns `None` when no credential exists for the given host. /// Returns `None` when no credential exists for the given host.
#[tauri::command] #[tauri::command(async)]
pub fn load_credential(app: AppHandle, host: String) -> Result<Option<CredentialData>, String> { pub fn load_credential(app: AppHandle, host: String) -> Result<Option<CredentialData>, String> {
require_non_empty(&host, "host")?; require_non_empty(&host, "host")?;
@@ -142,7 +142,7 @@ fn parse_credential_blob(json_str: &str) -> Result<CredentialData, String> {
/// Delete a credential from the system credential store. /// Delete a credential from the system credential store.
/// ///
/// Deleting a non-existent credential is not treated as an error. /// Deleting a non-existent credential is not treated as an error.
#[tauri::command] #[tauri::command(async)]
pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> { pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
require_non_empty(&host, "host")?; require_non_empty(&host, "host")?;
secret_store::delete(&app, &login_account(&host)) secret_store::delete(&app, &login_account(&host))
@@ -165,7 +165,7 @@ pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
/// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also /// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also
/// unavailable this returns an error rather than reporting a success that would /// unavailable this returns an error rather than reporting a success that would
/// leave peers rejecting the user's voice announce after a restart. /// leave peers rejecting the user's voice announce after a restart.
#[tauri::command] #[tauri::command(async)]
pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> { pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> {
require_non_empty(&host, "host")?; require_non_empty(&host, "host")?;
require_non_empty(&key, "key")?; require_non_empty(&key, "key")?;
@@ -178,7 +178,7 @@ pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<()
/// Load the identity private key for `host`. /// Load the identity private key for `host`.
/// ///
/// Returns `None` when no identity key exists for the given host. /// Returns `None` when no identity key exists for the given host.
#[tauri::command] #[tauri::command(async)]
pub fn load_identity_key(app: AppHandle, host: String) -> Result<Option<String>, String> { pub fn load_identity_key(app: AppHandle, host: String) -> Result<Option<String>, String> {
require_non_empty(&host, "host")?; require_non_empty(&host, "host")?;
secret_store::get(&app, &identity_account(&host)) secret_store::get(&app, &identity_account(&host))
@@ -188,7 +188,7 @@ pub fn load_identity_key(app: AppHandle, host: String) -> Result<Option<String>,
/// Delete the identity private key for `host`. /// Delete the identity private key for `host`.
/// ///
/// Deleting a non-existent key is not treated as an error. /// Deleting a non-existent key is not treated as an error.
#[tauri::command] #[tauri::command(async)]
pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> { pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> {
require_non_empty(&host, "host")?; require_non_empty(&host, "host")?;
secret_store::delete(&app, &identity_account(&host)) secret_store::delete(&app, &identity_account(&host))
@@ -217,7 +217,7 @@ pub struct CredentialStoreProbe {
/// announce: it distinguishes "the credential store is fine" from "writes are /// announce: it distinguishes "the credential store is fine" from "writes are
/// accepted and dropped" without touching any real credential. The probe /// accepted and dropped" without touching any real credential. The probe
/// account is removed again whatever the outcome. /// account is removed again whatever the outcome.
#[tauri::command] #[tauri::command(async)]
pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe { pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe {
// Underscores are not legal in DNS hostnames, so this cannot collide with a // Underscores are not legal in DNS hostnames, so this cannot collide with a
// real `{host}` or `identity:{host}` account. // real `{host}` or `identity:{host}` account.
@@ -31,7 +31,7 @@ use log::{debug, error, info, warn};
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::Arc; use std::sync::Arc;
use rustls::pki_types::ServerName; use rustls::pki_types::ServerName;
use tauri::Runtime; use tauri::{Manager, Runtime};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -65,6 +65,21 @@ impl LiveKitProxyState {
}), }),
} }
} }
/// Clear the running-proxy state, but only if it still points at `port`.
/// Mirrors HttpProxyState::remove_if_port_matches; used by run_proxy_loop's
/// accept-error exit path so a dead listener doesn't keep being handed
/// back by start_livekit_proxy's reuse branch, and doesn't race a newer
/// proxy that may have already replaced it.
async fn clear_if_port_matches(&self, port: u16) {
let mut inner = self.inner.lock().await;
if inner.port == Some(port) {
inner.port = None;
inner.remote_host.clear();
inner.pinned_fingerprint.clear();
inner.shutdown_tx = None;
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -224,7 +239,14 @@ pub async fn start_livekit_proxy<R: Runtime>(
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let host = remote_host.clone(); let host = remote_host.clone();
let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint.clone(), shutdown_rx)); let loop_handle = tokio::spawn(run_proxy_loop(
app.clone(),
listener,
host,
port,
fingerprint.clone(),
shutdown_rx,
));
// Watch the loop so a panic is logged instead of vanishing silently. // Watch the loop so a panic is logged instead of vanishing silently.
tokio::spawn(async move { tokio::spawn(async move {
match loop_handle.await { match loop_handle.await {
@@ -266,9 +288,11 @@ pub async fn stop_livekit_proxy(
/// Maximum consecutive accept errors before the proxy loop exits. /// Maximum consecutive accept errors before the proxy loop exits.
const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5; const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5;
async fn run_proxy_loop( async fn run_proxy_loop<R: Runtime>(
app: tauri::AppHandle<R>,
listener: TcpListener, listener: TcpListener,
remote_host: String, remote_host: String,
port: u16,
pinned_fingerprint: String, pinned_fingerprint: String,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) { ) {
@@ -300,6 +324,20 @@ async fn run_proxy_loop(
"[livekit_proxy] {} consecutive accept errors, stopping proxy loop", "[livekit_proxy] {} consecutive accept errors, stopping proxy loop",
MAX_CONSECUTIVE_ACCEPT_ERRORS MAX_CONSECUTIVE_ACCEPT_ERRORS
); );
// Deregister the dead proxy BEFORE the break drops
// `listener`, so a future start_livekit_proxy
// rebinds a fresh port instead of handing back
// this closed one forever (the reuse branch keys
// only on host+pin, not liveness). Mirrors
// http_proxy.rs's identical fix.
if let Some(state) = app.try_state::<LiveKitProxyState>() {
state.clear_if_port_matches(port).await;
} else {
warn!(
"[livekit_proxy] state unmanaged; cannot deregister dead proxy for {}",
remote_host
);
}
break; break;
} }
} }
@@ -677,4 +715,42 @@ mod tests {
"a silent peer must produce an error, not a usable TLS stream" "a silent peer must produce an error, not a usable TLS stream"
); );
} }
// ── LiveKitProxyState::clear_if_port_matches ────────────────────────────
//
// B4_conn_ipc-7: run_proxy_loop's accept-error exit path drops the
// listener without deregistering it, so ProxyInner.port stays set and
// start_livekit_proxy's reuse branch (unchanged host+pin) hands the dead
// port back forever. Mirrors http_proxy.rs's
// remove_if_port_matches_removes_only_matching_entry test.
#[tokio::test]
async fn clear_if_port_matches_clears_only_a_matching_entry() {
let state = LiveKitProxyState::new();
{
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
let mut inner = state.inner.lock().await;
inner.port = Some(4242);
inner.remote_host = "example.com:8443".to_string();
inner.pinned_fingerprint = "aa:bb".to_string();
inner.shutdown_tx = Some(tx);
}
// A stale loop reporting a port that no longer matches the live
// listener must leave the current entry alone.
state.clear_if_port_matches(9999).await;
assert_eq!(
state.inner.lock().await.port,
Some(4242),
"mismatched port must not clear a newer proxy's state"
);
// A loop reporting its own still-current port must clear it so the
// next start_livekit_proxy rebinds instead of reusing the dead listener.
state.clear_if_port_matches(4242).await;
let inner = state.inner.lock().await;
assert_eq!(inner.port, None, "matching port must deregister the dead proxy");
assert!(inner.remote_host.is_empty());
assert!(inner.pinned_fingerprint.is_empty());
}
} }
+33 -2
View File
@@ -335,8 +335,13 @@ pub async fn ws_send(
/// Disconnect the proxy WebSocket. /// Disconnect the proxy WebSocket.
#[tauri::command] #[tauri::command]
pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> { pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> {
let mut tx_lock = state.tx.lock().await; // begin_connection() both clears the sender slot (dropping it closes the
*tx_lock = None; // dropping the sender closes the channel → write task ends // channel so the write task ends) AND bumps the generation counter, so a
// handshake still pending from before this disconnect fails install_sender
// instead of installing itself afterward — reusing the same invalidation
// path a superseding connect() already has. The returned generation is
// unused: nothing will ever install under it.
state.begin_connection().await;
Ok(()) Ok(())
} }
@@ -589,4 +594,30 @@ mod tests {
assert_eq!(got, None, "rx.recv() must yield None so the write task exits"); assert_eq!(got, None, "rx.recv() must yield None so the write task exits");
} }
// B4_conn_ipc-9: ws_disconnect must invalidate an in-flight ws_connect
// attempt, not just null the sender slot. A handshake can pend for up to
// CONNECT_TIMEOUT (10s) past a disconnect (JS calls connect fire-and-
// forget — logout during "connecting" is a real interleaving), and
// install_sender checks generation alone, so a manual `*tx_lock = None`
// leaves a "cancelled" connection free to install itself afterward and
// spawn its worker tasks against a socket JS believes closed.
#[tokio::test]
async fn disconnect_invalidates_an_in_flight_connect_attempt() {
let state = WsState::new();
// A's handshake is in flight: generation claimed, sender not yet
// installed (mirrors the pending window before install_sender runs).
let gen_a = state.begin_connection().await;
// ws_disconnect fires while A is still mid-handshake — this is
// ws_disconnect's real body (state.begin_connection().await).
state.begin_connection().await;
// A's handshake finally completes and tries to install its sender.
// It must be rejected: JS already believes the connection is closed.
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
assert!(
!state.install_sender(gen_a, tx_a).await,
"a handshake pending during disconnect must not be able to install after it"
);
}
} }
@@ -24,7 +24,7 @@
], ],
"withGlobalTauri": false, "withGlobalTauri": false,
"security": { "security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:" "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' blob: https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
} }
}, },
"bundle": { "bundle": {
+14 -1
View File
@@ -211,7 +211,20 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
log.error("setConfig rejected invalid host", { host: newConfig.host }); log.error("setConfig rejected invalid host", { host: newConfig.host });
throw new Error("Invalid host format"); throw new Error("Invalid host format");
} }
config = { ...config, ...newConfig }; // Switching to a different host without an accompanying new token must
// not carry the previous host's bearer token forward — otherwise the
// login/register request to the new host rides a still-live session
// token for the old one. Callers that only rotate the token (post-auth)
// never pass `host`, so this never touches a same-host token refresh.
if (
newConfig.host !== undefined &&
newConfig.host !== config.host &&
newConfig.token === undefined
) {
config = { ...config, ...newConfig, token: undefined };
} else {
config = { ...config, ...newConfig };
}
}, },
/** Get current config (for debugging). Token is redacted. */ /** Get current config (for debugging). Token is redacted. */
+10 -9
View File
@@ -15,26 +15,28 @@ import { createLogger } from "./logger";
const log = createLogger("http-proxy"); const log = createLogger("http-proxy");
/** host → resolved loopback origin (e.g. "http://127.0.0.1:49812"). */
const origins = new Map<string, string>();
/** host → in-flight start so concurrent callers don't race the tunnel. */ /** host → in-flight start so concurrent callers don't race the tunnel. */
const pending = new Map<string, Promise<string>>(); const pending = new Map<string, Promise<string>>();
/** /**
* Ensure a tunnel exists for `host` and return its loopback origin * Ensure a tunnel exists for `host` and return its loopback origin
* (no trailing slash). Idempotent and concurrency-safe per host. * (no trailing slash). Concurrency-safe per host.
*
* Always invokes start_http_proxy — never caches the resolved origin here.
* Only the Rust side knows whether its listener is still alive: after 5
* consecutive accept errors run_proxy_loop deregisters itself so the next
* start_http_proxy rebinds a fresh port (http_proxy.rs). A JS-side cache
* would keep pointing every REST call at that dead tunnel until app restart.
* The Rust reuse branch dedups an unchanged host cheaply, so the repeat
* invoke is inexpensive — mirroring livekitSession.ts's ensureLiveKitProxy.
*/ */
export async function ensureHttpProxy(host: string): Promise<string> { export async function ensureHttpProxy(host: string): Promise<string> {
const cached = origins.get(host);
if (cached) return cached;
const inFlight = pending.get(host); const inFlight = pending.get(host);
if (inFlight) return inFlight; if (inFlight) return inFlight;
const start = (async () => { const start = (async () => {
const port = await invoke<number>("start_http_proxy", { remoteHost: host }); const port = await invoke<number>("start_http_proxy", { remoteHost: host });
const origin = `http://127.0.0.1:${port}`; const origin = `http://127.0.0.1:${port}`;
origins.set(host, origin);
log.debug("tunnel ready", { host, origin }); log.debug("tunnel ready", { host, origin });
return origin; return origin;
})(); })();
@@ -47,9 +49,8 @@ export async function ensureHttpProxy(host: string): Promise<string> {
} }
} }
/** Stop the tunnel for `host` and drop its cached origin (best-effort). */ /** Stop the tunnel for `host` (best-effort). */
export async function stopHttpProxy(host: string): Promise<void> { export async function stopHttpProxy(host: string): Promise<void> {
origins.delete(host);
pending.delete(host); pending.delete(host);
try { try {
await invoke("stop_http_proxy", { remoteHost: host }); await invoke("stop_http_proxy", { remoteHost: host });
@@ -107,6 +107,12 @@ async function rotateOldFiles(): Promise<void> {
/** Handle a log entry by serializing it and buffering for disk write. */ /** Handle a log entry by serializing it and buffering for disk write. */
function onLogEntry(entry: LogEntry): void { function onLogEntry(entry: LogEntry): void {
if (!initialized) return; if (!initialized) return;
// Break the self-sustaining loop: a persistently failing flush logs
// through this module's own logger (flush failed / rotation failed),
// which would otherwise re-enter here and re-arm scheduleFlush every 2s
// forever. The entry still reaches console/the in-memory ring buffer —
// it just never gets queued for its own persistence.
if (entry.component === "logPersistence") return;
buffer.push(JSON.stringify(entry)); buffer.push(JSON.stringify(entry));
scheduleFlush(); scheduleFlush();
} }
+92 -12
View File
@@ -38,6 +38,7 @@ import { createProfileManager, createTauriBackend } from "@lib/profiles";
import type { CertTofuEvent } from "@lib/ws"; import type { CertTofuEvent } from "@lib/ws";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { listen } from "@tauri-apps/api/event";
// Gate the log level before anything logs: debug entries are serialized and // Gate the log level before anything logs: debug entries are serialized and
// persisted to disk, so in production the level must filter real work, not // persisted to disk, so in production the level must filter real work, not
@@ -66,7 +67,10 @@ document.addEventListener("contextmenu", (e) => {
// F5 and Ctrl+R are blocked to prevent accidental page reloads which cause // F5 and Ctrl+R are blocked to prevent accidental page reloads which cause
// ghost voice state (user appears in channel with no LiveKit connection). // ghost voice state (user appears in channel with no LiveKit connection).
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "F5" || (e.ctrlKey && e.key === "r")) { // KeyboardEvent.key carries the shifted/CapsLock-cased character, so
// Ctrl+Shift+R (and Ctrl+R with CapsLock on) would fall through this guard
// as "R" without the lowercase compare.
if (e.key === "F5" || (e.ctrlKey && e.key.toLowerCase() === "r")) {
e.preventDefault(); e.preventDefault();
return; return;
} }
@@ -114,7 +118,12 @@ const router = createRouter("connect");
// accepted; the bearer token never rides an unpinned TLS connection. // accepted; the bearer token never rides an unpinned TLS connection.
const api = createApiClient({ host: "" }, () => { const api = createApiClient({ host: "" }, () => {
log.warn("Session expired (401), clearing auth"); log.warn("Session expired (401), clearing auth");
setTransientError("Your session expired — sign in again."); // A 401 on a request made before any session existed (e.g. a failed login
// attempt) is not a session "expiring" — the login form's own catch block
// already surfaces that failure. Only warn about a session that was live.
if (authStore.getState().isAuthenticated) {
setTransientError("Your session expired — sign in again.");
}
clearAuth(); clearAuth();
}); });
const ws = createWsClient(); const ws = createWsClient();
@@ -145,6 +154,14 @@ let pendingInviteLink: { code: string; host?: string } | null = null;
// Shared guard so the first-use and mismatch cert modals never stack. // Shared guard so the first-use and mismatch cert modals never stack.
let certModalActive = false; let certModalActive = false;
/** Normalize a host for comparison against a cert-tofu event's host, mirroring
* `tofu::cert_store_key`'s trailing-":443" strip (src-tauri/src/tofu.rs).
* Duplicated from ws.ts's private normalizer of the same name — ws.ts does
* not export it, and this batch's owned-files list does not include ws.ts. */
function normalizeHostForCertCompare(host: string): string {
return host.replace(/:443$/, "");
}
// First-use certificate confirmation (F4/F8). The Rust proxy REJECTS the first // First-use certificate confirmation (F4/F8). The Rust proxy REJECTS the first
// connection to a server until the user confirms its fingerprint, so no // connection to a server until the user confirms its fingerprint, so no
// credential is ever sent to an unconfirmed host. This fires during the connect // credential is ever sent to an unconfirmed host. This fires during the connect
@@ -163,9 +180,16 @@ ws.onCertFirstUse((evt: CertTofuEvent) => {
try { try {
await ws.acceptCertFingerprint(evt.host, evt.fingerprint); await ws.acceptCertFingerprint(evt.host, evt.fingerprint);
// Refresh server health so the now-trusted host becomes reachable, // Refresh server health so the now-trusted host becomes reachable,
// and resume a pending connect if one was in flight. // and resume a pending connect if one was in flight — but only when
// it was pending for THIS host. Accepting a first-use cert for one
// profile must not force-reconnect (or churn) a session already
// live for a different host.
rerunConnectHealth?.(); rerunConnectHealth?.();
if (lastConnectHost && lastConnectToken) { if (
lastConnectHost &&
lastConnectToken &&
evt.host === normalizeHostForCertCompare(lastConnectHost)
) {
ws.connect({ host: lastConnectHost, token: lastConnectToken }); ws.connect({ host: lastConnectHost, token: lastConnectToken });
} }
} catch (err) { } catch (err) {
@@ -194,7 +218,11 @@ ws.onCertMismatch((evt: CertTofuEvent) => {
void (async () => { void (async () => {
try { try {
await ws.acceptCertFingerprint(evt.host, evt.fingerprint); await ws.acceptCertFingerprint(evt.host, evt.fingerprint);
if (lastConnectHost && lastConnectToken) { if (
lastConnectHost &&
lastConnectToken &&
evt.host === normalizeHostForCertCompare(lastConnectHost)
) {
reconnectAfterCertAccept(ws, router, lastConnectHost, lastConnectToken); reconnectAfterCertAccept(ws, router, lastConnectHost, lastConnectToken);
} }
} catch (err) { } catch (err) {
@@ -205,9 +233,14 @@ ws.onCertMismatch((evt: CertTofuEvent) => {
onReject: () => { onReject: () => {
modal.destroy?.(); modal.destroy?.();
certModalActive = false; certModalActive = false;
ws.disconnect(); // Only tear down the live session when the mismatch is FOR that
clearAuth(); // session's host — a rotated cert on an unrelated saved profile must
router.navigate("connect"); // not disconnect and log out an unrelated authenticated session.
if (evt.host === normalizeHostForCertCompare(lastConnectHost)) {
ws.disconnect();
clearAuth();
router.navigate("connect");
}
}, },
}); });
modal.mount(document.body); modal.mount(document.body);
@@ -217,6 +250,19 @@ ws.onCertMismatch((evt: CertTofuEvent) => {
// are received during the connect page's health checks, before any WS connect. // are received during the connect page's health checks, before any WS connect.
void ws.startCertListener(); void ws.startCertListener();
// Route the tray's Status submenu (Online/Idle/Do Not Disturb/Offline) into
// the same presence_update wire message the in-app StatusPicker sends
// (UserBar.ts/MainPage.ts's applyPresence) — the Rust side only emitted
// "status-change" with nothing in the webview listening for it. ws.send is a
// safe no-op (logged) when there is no live session, so no auth guard is
// needed here.
void listen<string>("status-change", (e) => {
const status = e.payload;
if (status === "online" || status === "idle" || status === "dnd" || status === "offline") {
ws.send({ type: "presence_update", payload: { status } });
}
});
// Current page component reference for cleanup // Current page component reference for cleanup
let currentPage: { destroy?(): void } | null = null; let currentPage: { destroy?(): void } | null = null;
@@ -402,6 +448,17 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
return [{ name: "Local Server", host: "localhost:8443" }]; return [{ name: "Local Server", host: "localhost:8443" }];
} }
// Persist a profile mutation, surfacing a failure instead of letting it
// silently revert on next launch: profiles.ts awaits invoke("save_settings"),
// which rejects when the store write fails (read-only file, disk full),
// and `void`-ing that rejection at each call site (as this used to) left
// the in-memory store as the only record of the change.
function persistProfiles(): void {
void profileManager.saveProfiles().catch(() => {
setTransientError("Could not save server profiles");
});
}
// Auto-save a profile for a host after successful login (if not already saved) // Auto-save a profile for a host after successful login (if not already saved)
function ensureProfileExists(host: string, username: string, rememberPassword: boolean): void { function ensureProfileExists(host: string, username: string, rememberPassword: boolean): void {
const existing = profileManager.getAll().find((p) => p.host === host); const existing = profileManager.getAll().find((p) => p.host === host);
@@ -420,7 +477,7 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
}); });
profileManager.setLastConnected(created.id); profileManager.setLastConnected(created.id);
} }
void profileManager.saveProfiles(); persistProfiles();
} }
const connectPage = createConnectPage( const connectPage = createConnectPage(
@@ -483,23 +540,37 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
rememberPassword: false, rememberPassword: false,
color: "#5865F2", color: "#5865F2",
}); });
void profileManager.saveProfiles(); persistProfiles();
connectPage.refreshProfiles(getProfileList()); connectPage.refreshProfiles(getProfileList());
// Check health for the new profile // Check health for the new profile
runHealthChecks(connectPage, getProfileList()); runHealthChecks(connectPage, getProfileList());
}, },
onDeleteProfile(profileId) { onDeleteProfile(profileId) {
profileManager.removeProfile(profileId); profileManager.removeProfile(profileId);
void profileManager.saveProfiles(); persistProfiles();
connectPage.refreshProfiles(getProfileList()); connectPage.refreshProfiles(getProfileList());
}, },
onToggleAutoLogin(profileId, enabled) { onToggleAutoLogin(profileId, enabled) {
profileManager.setAutoLogin(enabled ? profileId : null); profileManager.setAutoLogin(enabled ? profileId : null);
void profileManager.saveProfiles(); persistProfiles();
connectPage.refreshProfiles(getProfileList()); connectPage.refreshProfiles(getProfileList());
}, },
onAutoLoginCancel() { onAutoLoginCancel() {
autoLoginCancelled = true; autoLoginCancelled = true;
// Every read of this flag below runs before the overlay carrying
// this Cancel button is ever painted, so by the time a click
// reaches here the session is already in flight (wirePostAuth has
// called ws.connect and registered listeners). Tear it down the
// same way the logout path does.
sessionCleanup?.();
sessionCleanup = null;
dispatcherCleanup?.();
dispatcherCleanup = null;
connectedOverlay?.destroy();
connectedOverlay = null;
ws.disconnect();
lastConnectHost = "";
lastConnectToken = "";
}, },
}, },
getProfileList(), getProfileList(),
@@ -672,6 +743,15 @@ void initWindowState();
// form — it can't complete a join by itself. // form — it can't complete a join by itself.
function handleInviteDeepLink(code: string, host?: string): void { function handleInviteDeepLink(code: string, host?: string): void {
pendingInviteLink = { code, host }; pendingInviteLink = { code, host };
if (authStore.getState().isAuthenticated) {
// Let the logout path do the teardown instead of navigating behind a
// live session: clearAuth() fires while the router is still on "main",
// so the authStore subscriber above runs its full teardown (voice leave,
// dispatcher/session cleanup, ws.disconnect) and navigates to "connect"
// itself — whose render branch consumes pendingInviteLink below.
clearAuth();
return;
}
router.navigate("connect"); router.navigate("connect");
// If the connect page was already mounted, navigate() may not re-render it — // If the connect page was already mounted, navigate() may not re-render it —
// apply directly. Otherwise the connect render branch consumes the pending link. // apply directly. Otherwise the connect render branch consumes the pending link.
@@ -211,6 +211,7 @@ export function createConnectPage(
> | null = null; > | null = null;
let settingsOverlayLoading = false; let settingsOverlayLoading = false;
let unsubSettingsOpen: (() => void) | null = null; let unsubSettingsOpen: (() => void) | null = null;
let unsubTransientError: (() => void) | null = null;
// The settings overlay (whose tabs pull in the LiveKit stack) is created // The settings overlay (whose tabs pull in the LiveKit stack) is created
// lazily on first open so it stays out of the startup path. Once created it // lazily on first open so it stays out of the startup path. Once created it
@@ -254,6 +255,20 @@ export function createConnectPage(
); );
if (uiStore.getState().settingsOpen) ensureSettingsOverlay(); if (uiStore.getState().settingsOpen) ensureSettingsOverlay();
// Surface a transient error for as long as this page is mounted — not
// just one already pending at mount time. A WS auth_error, a cert-
// mismatch reject, or a background credential-save warning can all set
// this while the connect page is already up; a one-time getState() read
// here would silently drop them.
unsubTransientError = uiStore.subscribeSelector(
(s) => s.transientError,
(msg) => {
if (msg) {
loginForm.showError(msg);
setTransientError(null);
}
},
);
// Show any pending auth error (e.g. "already connected from another client") // Show any pending auth error (e.g. "already connected from another client")
const pendingError = uiStore.getState().transientError; const pendingError = uiStore.getState().transientError;
if (pendingError) { if (pendingError) {
@@ -270,9 +285,17 @@ export function createConnectPage(
abortController.abort(); abortController.abort();
unsubSettingsOpen?.(); unsubSettingsOpen?.();
unsubSettingsOpen = null; unsubSettingsOpen = null;
unsubTransientError?.();
unsubTransientError = null;
settingsOverlay?.destroy?.(); settingsOverlay?.destroy?.();
settingsOverlay = null; settingsOverlay = null;
// Any transient error set while this page was mounted (shown or not)
// must not resurface at the next mount — which only happens after a
// later logout, where it would misleadingly read as a fresh login
// failure rather than whatever set it during this session.
setTransientError(null);
if (container && root) { if (container && root) {
container.removeChild(root); container.removeChild(root);
} }
@@ -317,6 +317,34 @@ describe("API Client", () => {
expect(headers["Authorization"]).toBeUndefined(); expect(headers["Authorization"]).toBeUndefined();
expect(headers["Content-Type"]).toBe("application/json"); expect(headers["Content-Type"]).toBe("application/json");
}); });
// B4_conn_ipc-2: a host switch must not carry the previous host's bearer
// token forward — otherwise a login/register request to a new server
// rides a still-live session token for the old one.
it("setConfig drops the previous token when switching to a different host without a new token", async () => {
mockFetch.mockResolvedValue(jsonResponse({}));
// `api` (beforeEach) already holds token "test-token" for "localhost:8443".
api.setConfig({ host: "evil.example.com:8443" });
await api.getMe();
const headers = fetchCallOpts().headers as Record<string, string>;
expect(headers["Authorization"]).toBeUndefined();
});
it("setConfig keeps the token when the host is unchanged", async () => {
mockFetch.mockResolvedValue(jsonResponse({}));
api.setConfig({ host: "localhost:8443" });
await api.getMe();
const headers = fetchCallOpts().headers as Record<string, string>;
expect(headers["Authorization"]).toBe("Bearer test-token");
});
it("setConfig keeps a token provided alongside a host change", async () => {
mockFetch.mockResolvedValue(jsonResponse({}));
api.setConfig({ host: "new.example.com:8443", token: "fresh-token" });
await api.getMe();
const headers = fetchCallOpts().headers as Record<string, string>;
expect(headers["Authorization"]).toBe("Bearer fresh-token");
});
}); });
describe("user endpoints", () => { describe("user endpoints", () => {
@@ -486,6 +486,44 @@ describe("ConnectPage", () => {
page.destroy?.(); page.destroy?.();
}); });
// B4_conn_ipc-6: a WS auth_error, cert-mismatch reject, or credential-save
// warning can set transientError AFTER this page is already mounted — a
// one-time getState() read at mount would silently drop it.
it("shows a transient error set after mount, not just one already pending at mount time", () => {
const page = createConnectPage(makeCallbacks(), testProfiles);
page.mount(container);
// No error yet at mount time.
expect(container.querySelector(".error-banner")!.classList.contains("visible")).toBe(false);
setTransientError("Your session expired — sign in again.");
uiStore.flush();
const errorBanner = container.querySelector(".error-banner")!;
expect(errorBanner.classList.contains("visible")).toBe(true);
expect(errorBanner.textContent).toBe("Your session expired — sign in again.");
expect(uiStore.getState().transientError).toBeNull();
page.destroy?.();
});
// B4_conn_ipc-15: a transient error set while this page was mounted (e.g. a
// background credential-save failure fired after a successful login moved
// on to MainPage) must not survive to resurface at the NEXT mount — which
// only happens after a later logout, where it would misleadingly read as a
// fresh login failure.
it("clears a transient error on destroy so it cannot resurface as a bogus login error later", () => {
const page = createConnectPage(makeCallbacks(), testProfiles);
page.mount(container);
// Set while mounted but not yet observed (no flush before destroy) —
// mirrors a background failure landing just as the page is torn down.
setTransientError("Could not save credentials — auto-login won't work");
page.destroy?.();
expect(uiStore.getState().transientError).toBeNull();
});
// --- TOTP overlay interactions --- // --- TOTP overlay interactions ---
it("TOTP submit calls onTotpSubmit with 6-digit code", async () => { it("TOTP submit calls onTotpSubmit with 6-digit code", async () => {
@@ -25,12 +25,21 @@ describe("ensureHttpProxy", () => {
}); });
}); });
it("caches the origin per host (one start per host)", async () => { // B4_conn_ipc-11: the origin must NOT be cached in JS. Only Rust knows
// whether its listener is still alive (it deregisters itself after 5
// consecutive accept errors), so a JS-side cache would keep pointing every
// REST call at a dead tunnel forever. Every call re-invokes start_http_proxy;
// the Rust reuse branch dedups an unchanged, still-live host cheaply.
it("does not cache the origin — every call re-invokes so a self-terminated tunnel can be rebound", async () => {
invokeMock.mockResolvedValue(40000); invokeMock.mockResolvedValue(40000);
const a = await ensureHttpProxy("host-b.example:8443"); const a = await ensureHttpProxy("host-b.example:8443");
// Simulates the Rust side rebinding a fresh port after the old tunnel's
// accept loop gave up (fd exhaustion, etc.) and deregistered itself.
invokeMock.mockResolvedValue(40001);
const b = await ensureHttpProxy("host-b.example:8443"); const b = await ensureHttpProxy("host-b.example:8443");
expect(a).toBe(b); expect(a).toBe("http://127.0.0.1:40000");
expect(invokeMock).toHaveBeenCalledTimes(1); expect(b).toBe("http://127.0.0.1:40001");
expect(invokeMock).toHaveBeenCalledTimes(2);
}); });
it("de-duplicates concurrent starts for the same host", async () => { it("de-duplicates concurrent starts for the same host", async () => {
@@ -343,6 +343,23 @@ describe("log persistence", () => {
expect(mockWriteTextFile).toHaveBeenCalledTimes(1); expect(mockWriteTextFile).toHaveBeenCalledTimes(1);
}); });
// B4_conn_ipc-12: a persistently failing flush logs through this
// module's own logger (createLogger("logPersistence")). In production
// that log re-enters onLogEntry via the real logger's listener pipeline
// (mocked apart here — see the vi.mock("@lib/logger") above), so
// onLogEntry must refuse to buffer/reschedule its own entries or a
// failing write re-arms the 2s flush timer forever.
it("does not buffer or re-arm the flush timer for its own log entries", async () => {
const { getListener } = captureListener();
const { initLogPersistence } = await freshImport();
await initLogPersistence();
getListener()!(makeEntry({ component: "logPersistence", message: "flush failed" }));
await vi.advanceTimersByTimeAsync(2000);
expect(mockWriteTextFile).not.toHaveBeenCalled();
});
it("does not flush when buffer is empty", async () => { it("does not flush when buffer is empty", async () => {
captureListener(); captureListener();
const { initLogPersistence, flushLogs } = await freshImport(); const { initLogPersistence, flushLogs } = await freshImport();