diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs index 7f578694..bada3f4f 100644 --- a/Client/tauri-client/src-tauri/src/credentials.rs +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -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 /// this returns — see [`crate::secret_store`] for what happens when it does not /// come back. -#[tauri::command] +#[tauri::command(async)] pub fn save_credential( app: AppHandle, host: String, @@ -96,7 +96,7 @@ pub fn save_credential( /// Load a credential from the system credential store. /// /// Returns `None` when no credential exists for the given host. -#[tauri::command] +#[tauri::command(async)] pub fn load_credential(app: AppHandle, host: String) -> Result, String> { require_non_empty(&host, "host")?; @@ -142,7 +142,7 @@ fn parse_credential_blob(json_str: &str) -> Result { /// Delete a credential from the system credential store. /// /// 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> { require_non_empty(&host, "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 /// unavailable this returns an error rather than reporting a success that would /// 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> { require_non_empty(&host, "host")?; 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`. /// /// 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, String> { require_non_empty(&host, "host")?; secret_store::get(&app, &identity_account(&host)) @@ -188,7 +188,7 @@ pub fn load_identity_key(app: AppHandle, host: String) -> Result, /// Delete the identity private key for `host`. /// /// 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> { require_non_empty(&host, "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 /// accepted and dropped" without touching any real credential. The probe /// account is removed again whatever the outcome. -#[tauri::command] +#[tauri::command(async)] pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe { // Underscores are not legal in DNS hostnames, so this cannot collide with a // real `{host}` or `identity:{host}` account. diff --git a/Client/tauri-client/src-tauri/src/livekit_proxy.rs b/Client/tauri-client/src-tauri/src/livekit_proxy.rs index bdbf8465..17f451a3 100644 --- a/Client/tauri-client/src-tauri/src/livekit_proxy.rs +++ b/Client/tauri-client/src-tauri/src/livekit_proxy.rs @@ -31,7 +31,7 @@ use log::{debug, error, info, warn}; use std::net::IpAddr; use std::sync::Arc; use rustls::pki_types::ServerName; -use tauri::Runtime; +use tauri::{Manager, Runtime}; use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; 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( let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); 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. tokio::spawn(async move { match loop_handle.await { @@ -266,9 +288,11 @@ pub async fn stop_livekit_proxy( /// Maximum consecutive accept errors before the proxy loop exits. const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5; -async fn run_proxy_loop( +async fn run_proxy_loop( + app: tauri::AppHandle, listener: TcpListener, remote_host: String, + port: u16, pinned_fingerprint: String, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, ) { @@ -300,6 +324,20 @@ async fn run_proxy_loop( "[livekit_proxy] {} consecutive accept errors, stopping proxy loop", 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::() { + state.clear_if_port_matches(port).await; + } else { + warn!( + "[livekit_proxy] state unmanaged; cannot deregister dead proxy for {}", + remote_host + ); + } break; } } @@ -677,4 +715,42 @@ mod tests { "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()); + } } diff --git a/Client/tauri-client/src-tauri/src/ws_proxy.rs b/Client/tauri-client/src-tauri/src/ws_proxy.rs index a4911c2f..6a028617 100644 --- a/Client/tauri-client/src-tauri/src/ws_proxy.rs +++ b/Client/tauri-client/src-tauri/src/ws_proxy.rs @@ -335,8 +335,13 @@ pub async fn ws_send( /// Disconnect the proxy WebSocket. #[tauri::command] pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> { - let mut tx_lock = state.tx.lock().await; - *tx_lock = None; // dropping the sender closes the channel → write task ends + // begin_connection() both clears the sender slot (dropping it closes the + // 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(()) } @@ -589,4 +594,30 @@ mod tests { 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::(4); + assert!( + !state.install_sender(gen_a, tx_a).await, + "a handshake pending during disconnect must not be able to install after it" + ); + } } diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index 2e450428..3c881716 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -24,7 +24,7 @@ ], "withGlobalTauri": false, "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": { diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index 230452b6..75453c26 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -211,7 +211,20 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: log.error("setConfig rejected invalid host", { host: newConfig.host }); 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. */ diff --git a/Client/tauri-client/src/lib/httpProxy.ts b/Client/tauri-client/src/lib/httpProxy.ts index 701b6e7d..4270605d 100644 --- a/Client/tauri-client/src/lib/httpProxy.ts +++ b/Client/tauri-client/src/lib/httpProxy.ts @@ -15,26 +15,28 @@ import { createLogger } from "./logger"; const log = createLogger("http-proxy"); -/** host → resolved loopback origin (e.g. "http://127.0.0.1:49812"). */ -const origins = new Map(); /** host → in-flight start so concurrent callers don't race the tunnel. */ const pending = new Map>(); /** * 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 { - const cached = origins.get(host); - if (cached) return cached; - const inFlight = pending.get(host); if (inFlight) return inFlight; const start = (async () => { const port = await invoke("start_http_proxy", { remoteHost: host }); const origin = `http://127.0.0.1:${port}`; - origins.set(host, origin); log.debug("tunnel ready", { host, origin }); return origin; })(); @@ -47,9 +49,8 @@ export async function ensureHttpProxy(host: string): Promise { } } -/** 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 { - origins.delete(host); pending.delete(host); try { await invoke("stop_http_proxy", { remoteHost: host }); diff --git a/Client/tauri-client/src/lib/logPersistence.ts b/Client/tauri-client/src/lib/logPersistence.ts index 23824c0a..bba92748 100644 --- a/Client/tauri-client/src/lib/logPersistence.ts +++ b/Client/tauri-client/src/lib/logPersistence.ts @@ -107,6 +107,12 @@ async function rotateOldFiles(): Promise { /** Handle a log entry by serializing it and buffering for disk write. */ function onLogEntry(entry: LogEntry): void { 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)); scheduleFlush(); } diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index 8f1f701e..1f30ea63 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -38,6 +38,7 @@ import { createProfileManager, createTauriBackend } from "@lib/profiles"; import type { CertTofuEvent } from "@lib/ws"; 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 // 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 // ghost voice state (user appears in channel with no LiveKit connection). 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(); return; } @@ -114,7 +118,12 @@ const router = createRouter("connect"); // accepted; the bearer token never rides an unpinned TLS connection. const api = createApiClient({ host: "" }, () => { 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(); }); 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. 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 // 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 @@ -163,9 +180,16 @@ ws.onCertFirstUse((evt: CertTofuEvent) => { try { await ws.acceptCertFingerprint(evt.host, evt.fingerprint); // 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?.(); - if (lastConnectHost && lastConnectToken) { + if ( + lastConnectHost && + lastConnectToken && + evt.host === normalizeHostForCertCompare(lastConnectHost) + ) { ws.connect({ host: lastConnectHost, token: lastConnectToken }); } } catch (err) { @@ -194,7 +218,11 @@ ws.onCertMismatch((evt: CertTofuEvent) => { void (async () => { try { await ws.acceptCertFingerprint(evt.host, evt.fingerprint); - if (lastConnectHost && lastConnectToken) { + if ( + lastConnectHost && + lastConnectToken && + evt.host === normalizeHostForCertCompare(lastConnectHost) + ) { reconnectAfterCertAccept(ws, router, lastConnectHost, lastConnectToken); } } catch (err) { @@ -205,9 +233,14 @@ ws.onCertMismatch((evt: CertTofuEvent) => { onReject: () => { modal.destroy?.(); certModalActive = false; - ws.disconnect(); - clearAuth(); - router.navigate("connect"); + // Only tear down the live session when the mismatch is FOR that + // session's host — a rotated cert on an unrelated saved profile must + // not disconnect and log out an unrelated authenticated session. + if (evt.host === normalizeHostForCertCompare(lastConnectHost)) { + ws.disconnect(); + clearAuth(); + router.navigate("connect"); + } }, }); 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. 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("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 let currentPage: { destroy?(): void } | null = null; @@ -402,6 +448,17 @@ async function renderPage(pageId: "connect" | "main"): Promise { 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) function ensureProfileExists(host: string, username: string, rememberPassword: boolean): void { const existing = profileManager.getAll().find((p) => p.host === host); @@ -420,7 +477,7 @@ async function renderPage(pageId: "connect" | "main"): Promise { }); profileManager.setLastConnected(created.id); } - void profileManager.saveProfiles(); + persistProfiles(); } const connectPage = createConnectPage( @@ -483,23 +540,37 @@ async function renderPage(pageId: "connect" | "main"): Promise { rememberPassword: false, color: "#5865F2", }); - void profileManager.saveProfiles(); + persistProfiles(); connectPage.refreshProfiles(getProfileList()); // Check health for the new profile runHealthChecks(connectPage, getProfileList()); }, onDeleteProfile(profileId) { profileManager.removeProfile(profileId); - void profileManager.saveProfiles(); + persistProfiles(); connectPage.refreshProfiles(getProfileList()); }, onToggleAutoLogin(profileId, enabled) { profileManager.setAutoLogin(enabled ? profileId : null); - void profileManager.saveProfiles(); + persistProfiles(); connectPage.refreshProfiles(getProfileList()); }, onAutoLoginCancel() { 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(), @@ -672,6 +743,15 @@ void initWindowState(); // form — it can't complete a join by itself. function handleInviteDeepLink(code: string, host?: string): void { 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"); // If the connect page was already mounted, navigate() may not re-render it — // apply directly. Otherwise the connect render branch consumes the pending link. diff --git a/Client/tauri-client/src/pages/ConnectPage.ts b/Client/tauri-client/src/pages/ConnectPage.ts index 26782830..35b8deba 100644 --- a/Client/tauri-client/src/pages/ConnectPage.ts +++ b/Client/tauri-client/src/pages/ConnectPage.ts @@ -211,6 +211,7 @@ export function createConnectPage( > | null = null; let settingsOverlayLoading = false; let unsubSettingsOpen: (() => void) | null = null; + let unsubTransientError: (() => void) | null = null; // 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 @@ -254,6 +255,20 @@ export function createConnectPage( ); 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") const pendingError = uiStore.getState().transientError; if (pendingError) { @@ -270,9 +285,17 @@ export function createConnectPage( abortController.abort(); unsubSettingsOpen?.(); unsubSettingsOpen = null; + unsubTransientError?.(); + unsubTransientError = null; settingsOverlay?.destroy?.(); 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) { container.removeChild(root); } diff --git a/Client/tauri-client/tests/unit/api.test.ts b/Client/tauri-client/tests/unit/api.test.ts index ac9a9dac..9f1c9577 100644 --- a/Client/tauri-client/tests/unit/api.test.ts +++ b/Client/tauri-client/tests/unit/api.test.ts @@ -317,6 +317,34 @@ describe("API Client", () => { expect(headers["Authorization"]).toBeUndefined(); 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; + 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; + 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; + expect(headers["Authorization"]).toBe("Bearer fresh-token"); + }); }); describe("user endpoints", () => { diff --git a/Client/tauri-client/tests/unit/connect-page.test.ts b/Client/tauri-client/tests/unit/connect-page.test.ts index 8353661f..3a15e616 100644 --- a/Client/tauri-client/tests/unit/connect-page.test.ts +++ b/Client/tauri-client/tests/unit/connect-page.test.ts @@ -486,6 +486,44 @@ describe("ConnectPage", () => { 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 --- it("TOTP submit calls onTotpSubmit with 6-digit code", async () => { diff --git a/Client/tauri-client/tests/unit/http-proxy.test.ts b/Client/tauri-client/tests/unit/http-proxy.test.ts index 6cfd5ff5..5f924967 100644 --- a/Client/tauri-client/tests/unit/http-proxy.test.ts +++ b/Client/tauri-client/tests/unit/http-proxy.test.ts @@ -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); 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"); - expect(a).toBe(b); - expect(invokeMock).toHaveBeenCalledTimes(1); + expect(a).toBe("http://127.0.0.1:40000"); + expect(b).toBe("http://127.0.0.1:40001"); + expect(invokeMock).toHaveBeenCalledTimes(2); }); it("de-duplicates concurrent starts for the same host", async () => { diff --git a/Client/tauri-client/tests/unit/log-persistence.test.ts b/Client/tauri-client/tests/unit/log-persistence.test.ts index 29a68a59..933ad3f8 100644 --- a/Client/tauri-client/tests/unit/log-persistence.test.ts +++ b/Client/tauri-client/tests/unit/log-persistence.test.ts @@ -343,6 +343,23 @@ describe("log persistence", () => { 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 () => { captureListener(); const { initLogPersistence, flushLogs } = await freshImport();