Files
OwnCord/Client/src/main.ts
T
J3vb 9c9b8be669 feat(b2-2): protocol epoch and negotiation (slim) (#1438)
* feat(b2-2): declare protocol_epoch in the schema and generate both constants

protocol/schema.json gains protocol_epoch (1). genprotocol emits
ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go
constant to the schema so a stale regeneration fails the required check.

* feat(b2-2): check the client's protocol epoch in the auth handshake

The auth payload gains epoch (absent = 0). Outside [minClientEpoch,
ProtocolEpoch] the server answers one auth_error with code
protocol_epoch_unsupported, the client/server/min epochs, and a message
naming which side to update, then closes 1008 like every other handshake
failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep
connecting; the epoch-1 fixtures are unchanged.

* feat(b2-2): send the protocol epoch and offer the update on a refused connect

ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended
on purpose). On auth_error code protocol_epoch_unsupported with a newer
server the dispatcher records the host in ui.store.updateRequiredHost and
main.ts mounts the UpdateNotifier on the connect page, so a refused client
gets the same Update Now banner it would have had on the main page.

* feat(b2-2): withhold client releases newer than the server's protocol epoch

The signed server-update manifest gains protocol_epoch (release.yml reads it
from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the
manifest and reads it; the client-update endpoint answers 204 when the
release's epoch is newer than ws.ProtocolEpoch or the manifest does not
verify. Releases without a manifest are epoch 0 and advertised as before.
Docs: protocol.md Compatibility section, api.md, deployment.md, protocol
README, CHANGELOG Unreleased.

* docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it

* ci: prove the protocol_epoch manifest read on every PR, not only at tag time

* fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal

Codex P1: on a first login or startup auto-login no overlay exists before
auth_ok, so a refusal never re-rendered the connect page and the one-time
read of updateRequiredHost missed it. The connect page now subscribes to
it, and a later refusal replaces the banner.

Codex P2: a refusal on reconnect went through the generic logout and
deleted the stored credential although the token is still valid.
clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it
(the skip-auto-login flag is still set and, being sessionStorage, does not
survive the relaunch the update triggers).
2026-08-29 07:23:06 +02:00

927 lines
39 KiB
TypeScript

// OwnCord Tauri v2 Client — Entry Point
import "@styles/tokens.css";
import "@styles/base.css";
import "@styles/login.css";
import "@styles/app.css";
import "@styles/theme-neon-glow.css";
import { installGlobalErrorHandlers, safeMount } from "@lib/safe-render";
import { createRouter } from "@lib/router";
import { createApiClient } from "@lib/api";
import { createWsClient, normalizeHostForCertCompare } from "@lib/ws";
import { wireDispatcher, wireConnectionStatus } from "@lib/dispatcher";
import { authStore, clearAuth } from "@stores/auth.store";
import { setTransientError, uiStore, setUpdateRequiredHost } from "@stores/ui.store";
import { voiceStore, leaveVoiceChannel } from "@stores/voice.store";
import { createConnectPage } from "@pages/ConnectPage";
import { applyStoredAppearance } from "@lib/appearance";
import { restoreTheme } from "@lib/themes";
import { initPtt } from "@lib/ptt";
import { createNavigationGuard } from "@lib/navigation-guard";
import { createConnectedOverlay } from "@components/ConnectedOverlay";
import { createUpdateNotifier } from "@components/UpdateNotifier";
import type { MountableComponent } from "@lib/safe-render";
import type { ConnectedOverlayControl } from "@components/ConnectedOverlay";
import { createLogger, applyStoredLogLevel } from "@lib/logger";
import { initLogPersistence, flushLogs } from "@lib/logPersistence";
import {
saveCredential,
loadCredential,
deleteCredential,
createUserUpdateCredentialSaver,
} from "@lib/credentials";
import { initWindowState } from "@lib/window-state";
import { initDeepLinks } from "@lib/deep-link";
import { jumpToMessage } from "@lib/message-navigation";
import { createCertMismatchModal, createCertFirstUseModal } from "@components/CertMismatchModal";
import { reconnectAfterCertAccept } from "@lib/cert-reconnect";
import { createProfileManager, createTauriBackend } from "@lib/profiles";
import type { CertTofuEvent } from "@lib/ws";
import { saveUserStatus } from "@lib/userStatus";
import { getActivePresenceSender } from "@lib/presence";
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
// just console noise. Honors the level saved on the Logs settings tab; when
// unset, dev builds keep full debug output and production defaults to info.
applyStoredLogLevel(import.meta.env.DEV ? "debug" : "info");
const log = createLogger("main");
// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded
// lazily so it stays out of the startup path. When a voice session exists the
// module is necessarily already loaded, so this import resolves from the
// module cache in a microtask.
function voiceSessionLeave(sendWsLeave: boolean): void {
void import("@lib/livekitSession")
.then(({ leaveVoice }) => leaveVoice(sendWsLeave))
.catch((e) => log.warn("Failed to leave voice session", e));
}
// Disable the default browser context menu globally.
document.addEventListener("contextmenu", (e) => {
e.preventDefault();
});
// F12 or Ctrl+Shift+I opens WebView2 DevTools in development builds only.
// 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) => {
// 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;
}
if (import.meta.env.DEV && (e.key === "F12" || (e.ctrlKey && e.shiftKey && e.key === "I"))) {
e.preventDefault();
void import("@tauri-apps/api/core").then(({ invoke }) => {
void invoke("open_devtools");
});
}
});
// Open external links (target="_blank") in the user's default browser.
document.addEventListener("click", (e) => {
const link = (e.target as HTMLElement).closest("a[target='_blank']");
if (link === null) return;
e.preventDefault();
const href = (link as HTMLAnchorElement).href;
if (href && (href.startsWith("http://") || href.startsWith("https://"))) {
void openUrl(href);
}
});
// Install global error handlers first
installGlobalErrorHandlers();
// Apply stored theme/font/compact preferences before first render
applyStoredAppearance();
// Restore saved theme (body class) before first render
restoreTheme();
// Start push-to-talk listener (Rust-side polling, non-consuming)
void initPtt();
const appEl = document.getElementById("app");
if (!appEl) {
throw new Error("Missing #app element");
}
// Create core services
const router = createRouter("connect");
// REST traffic is tunneled through the Rust HTTP TOFU proxy (src/lib/httpProxy.ts
// → src-tauri/src/http_proxy.rs), which pins the server certificate to the same
// trust-on-first-use fingerprint as the WS proxy. No cert is ever blindly
// accepted; the bearer token never rides an unpinned TLS connection.
const api = createApiClient({ host: "" }, () => {
log.warn("Session expired (401), clearing auth");
// 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();
// Single writer for the UX-facing connection status (docs/architecture/ux §3):
// live controls read ui.store.connectionStatus reactively instead of wiring
// their own ws.onStateChange. Lifecycle plumbing that needs the exact internal
// transition (the connected overlay below) stays on ws.onStateChange.
wireConnectionStatus(ws);
const profileManager = createProfileManager(createTauriBackend());
let dispatcherCleanup: (() => void) | null = null;
// Tears down the session-scoped WS listeners registered in wirePostAuth
// (user_update, onStateChange, ready). dispatcherCleanup only clears
// dispatcher-registered handlers, so these need their own teardown to avoid
// accumulating across login/logout/retry cycles.
let sessionCleanup: (() => void) | null = null;
let connectedOverlay: ConnectedOverlayControl | null = null;
let lastConnectHost = "";
let lastConnectToken = "";
// Re-run the connect page's health checks (set while the connect page is
// mounted, cleared otherwise) — refreshes a server's status after its
// certificate is trusted for the first time.
let rerunConnectHealth: (() => void) | null = null;
// Set while the connect page is mounted so an owncord:// deep link can pre-fill
// its register form; the pending value covers links that arrive before it mounts.
let applyInviteToConnectPage: ((code: string, host?: string) => void) | null = null;
let pendingInviteLink: { code: string; host?: string } | null = null;
// Shared guard so the first-use and mismatch cert modals never stack.
let certModalActive = false;
// 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
// page's health check (the first TLS contact), before login.
ws.onCertFirstUse((evt: CertTofuEvent) => {
if (certModalActive) return;
certModalActive = true;
const modal = createCertFirstUseModal({
host: evt.host,
fingerprint: evt.fingerprint,
onAccept: () => {
modal.destroy?.();
certModalActive = false;
void (async () => {
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 — 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 &&
evt.host === normalizeHostForCertCompare(lastConnectHost)
) {
ws.connect({ host: lastConnectHost, token: lastConnectToken });
}
} catch (err) {
log.error("Failed to trust first-use certificate", err);
}
})();
},
onReject: () => {
modal.destroy?.();
certModalActive = false;
},
});
modal.mount(document.body);
});
ws.onCertMismatch((evt: CertTofuEvent) => {
if (certModalActive) return;
certModalActive = true;
const modal = createCertMismatchModal({
host: evt.host,
storedFingerprint: evt.storedFingerprint ?? "Unknown",
newFingerprint: evt.fingerprint,
onAccept: () => {
modal.destroy?.();
certModalActive = false;
void (async () => {
try {
await ws.acceptCertFingerprint(evt.host, evt.fingerprint);
if (
lastConnectHost &&
lastConnectToken &&
evt.host === normalizeHostForCertCompare(lastConnectHost)
) {
reconnectAfterCertAccept(ws, router, lastConnectHost, lastConnectToken);
}
} catch (err) {
log.error("Failed to accept cert fingerprint", err);
}
})();
},
onReject: () => {
modal.destroy?.();
certModalActive = false;
// 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);
});
// Register the global cert-tofu listener now so first-use / mismatch prompts
// 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 path the in-app StatusPicker uses (UserBar.ts): persist through
// saveUserStatus() — lib/userStatus.ts's documented single source of truth —
// before sending the wire message, not just a raw ws.send. Without this the
// tray's choice never reaches loadUserStatus(), so notifications.ts's DND
// gate, autoIdle's "never touch a manual DND/invisible" guard, and
// restoreSavedPresence() on the next reconnect all silently disagree with
// what the tray just set (OC-0037). The send itself goes through the
// session's shared PresenceSender (registered by MainPage.ts via
// setActivePresenceSender) rather than a raw ws.send: the server enforces a
// single 1-update/10s budget per user regardless of which surface sent the
// frame, and a raw send here would open a second, uncoordinated budget that
// silently drops whichever frame the server sees second (OC-0176). When no
// session is mounted the optional call is a no-op, matching the old raw
// ws.send's "safe no-op when disconnected" behavior, 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") {
// The tray's legacy "offline" spelling maps to "invisible" the same way
// userStatus.ts migrates an old client's stored "offline" value (see its
// doc comment) — the local pref and the wire message must agree.
const mapped = status === "offline" ? "invisible" : status;
saveUserStatus(mapped);
getActivePresenceSender()?.send(mapped);
}
});
// Current page component reference for cleanup
let currentPage: { destroy?(): void } | null = null;
/** Run health checks for a list of profiles and update the connect page. */
function runHealthChecks(
connectPage: {
updateHealthStatus(
host: string,
status: {
status: string;
latencyMs: number | null;
version: string | null;
onlineUsers: number | null;
},
): void;
},
profiles: readonly { host: string }[],
): void {
for (const profile of profiles) {
void (async () => {
try {
connectPage.updateHealthStatus(profile.host, {
status: "checking",
latencyMs: null,
version: null,
onlineUsers: null,
});
const start = performance.now();
const health = await api.getHealth(profile.host, 3000);
const elapsed = Math.round(performance.now() - start);
connectPage.updateHealthStatus(profile.host, {
status: elapsed > 1500 ? "slow" : "online",
latencyMs: elapsed,
version: health.version ?? null,
onlineUsers: health.online_users ?? null,
});
} catch (err) {
// Record why the check failed (TLS/cert-pin/network) — otherwise a
// "can't connect" report has no logged cause to diagnose.
log.warn("health check failed", { host: profile.host, error: String(err) });
connectPage.updateHealthStatus(profile.host, {
status: "offline",
latencyMs: null,
version: null,
onlineUsers: null,
});
}
})();
}
}
// Guards the async MainPage mount below against the destroy-before-mount race:
// a stale mount is discarded when a newer navigation supersedes it.
const navGuard = createNavigationGuard();
// Render the appropriate page based on router state
async function renderPage(pageId: "connect" | "main"): Promise<void> {
const isCurrentNavigation = navGuard.begin();
log.info("Navigating to page", { pageId });
// Destroy previous page
currentPage?.destroy?.();
currentPage = null;
appEl!.textContent = "";
// Only valid while the connect page is mounted (re-set in its render branch).
rerunConnectHealth = null;
applyInviteToConnectPage = null;
// Shared helper for post-auth WS connect + overlay flow
function wirePostAuth(
host: string,
token: string,
username: string,
password?: string,
rememberPassword = true,
): void {
log.info("Post-auth wiring", { host, username });
// Tear down any prior session wiring so listeners and the connected
// overlay never stack across a retry (a second wirePostAuth without an
// intervening logout).
sessionCleanup?.();
sessionCleanup = null;
dispatcherCleanup?.();
dispatcherCleanup = null;
connectedOverlay?.destroy();
connectedOverlay = null;
api.setConfig({ token });
// Store token in authStore so the dispatcher's auth_ok handler has it
authStore.setState((prev) => ({ ...prev, token }));
lastConnectHost = host;
lastConnectToken = token;
ws.connect({ host, token });
dispatcherCleanup = wireDispatcher(ws, api);
log.info("Dispatcher wired, connecting WS");
// Session-scoped WS listeners — collected so they're all removed together
// on logout/disconnect (or the next wirePostAuth).
const sessionUnsubs: Array<() => void> = [];
// BUG-135: Only persist credentials when the user opted in.
if (rememberPassword) {
saveCredential(host, username, token, password)
.then((ok) => {
if (!ok) {
log.warn("Credential save failed — auto-login will not work for this server");
setTransientError("Could not save credentials — auto-login won't work");
}
})
.catch(() => {
// saveCredential already catches internally; this is defence-in-depth
});
}
// Update saved credentials when the current user changes their username.
// Guarded by the same remember-password opt-out as the initial save
// above (BUG-135), and passes the session's password through so a later
// save doesn't wipe out the one saved at login for an opted-in user.
sessionUnsubs.push(
ws.on("user_update", createUserUpdateCredentialSaver(host, rememberPassword, password)),
);
const unsubState = ws.onStateChange((wsState) => {
log.debug("WS state change", { state: wsState });
if (wsState === "connected") {
// Stop listening once connected so a later transition can't fire this
// handler again.
unsubState();
// Pre-warm the lazily-loaded MainPage chunk (and the LiveKit stack
// behind it) so navigating past the connected overlay doesn't wait
// on a dynamic import.
void import("@pages/MainPage");
} else if (wsState === "disconnected") {
// Terminal non-connected transition (auth_error, cert-mismatch reject,
// or intentional disconnect before ever connecting): drop the handler
// so it doesn't linger and fire on a later connect.
unsubState();
}
});
sessionUnsubs.push(unsubState);
// Build the connected overlay from the auth_ok payload itself, not
// authStore: ws.ts fires onStateChange("connected") synchronously BEFORE
// dispatching the auth_ok message that carries server_name/motd
// (setState() then dispatch() in the same handleMessage() call), so
// authStore.setAuth() — run by the dispatcher's own auth_ok handler —
// has not applied yet at that point. Reading straight from the payload
// sidesteps the race instead of racing it (OC-0063).
const unsubAuthOk = ws.on("auth_ok", (payload) => {
unsubAuthOk();
// Ensure exactly one overlay exists at a time.
connectedOverlay?.destroy();
connectedOverlay = createConnectedOverlay({
serverName: payload.server_name ?? host,
username: payload.user.username ?? username,
motd: payload.motd ?? "",
onReady: () => {
connectedOverlay?.destroy();
connectedOverlay = null;
router.navigate("main");
},
});
appEl!.appendChild(connectedOverlay.element);
connectedOverlay.show();
const unsubReady = ws.on("ready", () => {
unsubReady();
connectedOverlay?.markReady();
});
sessionUnsubs.push(unsubReady);
});
sessionUnsubs.push(unsubAuthOk);
sessionCleanup = () => {
for (const unsub of sessionUnsubs) unsub();
sessionUnsubs.length = 0;
};
}
// Track partial auth state for TOTP flow
let pendingTotpHost = "";
let pendingTotpPartialToken = "";
let pendingTotpUsername = "";
if (pageId === "connect") {
// Helper to get the profile list for the ConnectPage
function getProfileList(): readonly {
name: string;
host: string;
id?: string;
username?: string;
}[] {
const saved = profileManager.getAll();
if (saved.length > 0) return saved;
// Fallback: show a default local server entry
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,
autoConnect: boolean,
): void {
const existing = profileManager.getAll().find((p) => p.host === host);
if (existing) {
// Update username, rememberPassword preference, and lastConnected
profileManager.updateProfile(existing.id, { username, rememberPassword });
profileManager.setLastConnected(existing.id);
} else {
const created = profileManager.addProfile({
name: host.split(":")[0] ?? host,
host,
username,
autoConnect: false,
rememberPassword,
color: "#5865F2",
});
profileManager.setLastConnected(created.id);
}
// Re-find: the profile may have just been created above.
const profile = profileManager.getAll().find((p) => p.host === host);
if (profile) {
if (autoConnect) {
profileManager.setAutoLogin(profile.id);
} else if (profile.autoConnect) {
// Only clear when this profile is the current holder — setAutoLogin(null)
// clears auto-login on every profile, not just this one.
profileManager.setAutoLogin(null);
}
}
persistProfiles();
}
const connectPage = createConnectPage(
{
async onLogin(host, username, password) {
api.setConfig({ host });
const result = await api.login(username, password);
if (result.requires_2fa) {
pendingTotpHost = host;
pendingTotpPartialToken = result.partial_token ?? "";
pendingTotpUsername = username;
connectPage.showTotp();
return;
}
if (result.token) {
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? password : undefined;
ensureProfileExists(host, username, remember, connectPage.getAutoConnect());
wirePostAuth(host, result.token, username, savedPassword, remember);
}
},
async onRegister(host, username, password, inviteCode) {
api.setConfig({ host });
const result = await api.register(username, password, inviteCode);
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? password : undefined;
ensureProfileExists(host, username, remember, connectPage.getAutoConnect());
wirePostAuth(host, result.token, username, savedPassword, remember);
},
async onTotpSubmit(code) {
if (!pendingTotpPartialToken) {
log.error("TOTP submit without pending partial token");
return;
}
const result = await api.verifyTotp(code, pendingTotpPartialToken);
if (result.token) {
// Clear the sensitive partial token now that it has been
// exchanged for a real session token. A rejected code must NOT
// clear it here — the TOTP *code* is single-use (the server
// 401s a replay), but the partial token is the short-lived 2FA
// challenge itself and stays valid for a retry. LoginForm keeps
// the TOTP overlay open across a failed verify for exactly that
// retry; clearing this unconditionally (the old `finally`) made
// every retry hit the guard above and silently do nothing.
pendingTotpPartialToken = "";
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? connectPage.getPassword() : undefined;
ensureProfileExists(
pendingTotpHost,
pendingTotpUsername,
remember,
connectPage.getAutoConnect(),
);
wirePostAuth(
pendingTotpHost,
result.token,
pendingTotpUsername,
savedPassword,
remember,
);
}
},
onAddProfile(name, host) {
profileManager.addProfile({
name,
host,
username: "",
autoConnect: false,
rememberPassword: false,
color: "#5865F2",
});
persistProfiles();
connectPage.refreshProfiles(getProfileList());
// Check health for the new profile
runHealthChecks(connectPage, getProfileList());
},
onDeleteProfile(profileId) {
profileManager.removeProfile(profileId);
persistProfiles();
connectPage.refreshProfiles(getProfileList());
},
onToggleAutoLogin(profileId, enabled) {
profileManager.setAutoLogin(enabled ? profileId : null);
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(),
);
let autoLoginCancelled = false;
safeMount(connectPage, appEl!);
// A server refused this client's protocol epoch as too old: offer the
// update on the connect page itself. The main page's notifier never
// mounts on a refusal, so without this the user would have to fetch the
// installer by hand. Subscribed, not read once: on a first login or a
// startup auto-login this page is already mounted when the refusal
// arrives and nothing re-renders it (no overlay exists before auth_ok, so
// the isAuthenticated subscriber below does not navigate).
let updateNotifier: MountableComponent | null = null;
const offerUpdate = (host: string | null): void => {
if (!host) return;
setUpdateRequiredHost(null);
// A later refusal (another server tried from this same page) replaces
// the banner rather than being ignored.
updateNotifier?.destroy?.();
const notifier = createUpdateNotifier({ serverUrl: `https://${host}` });
notifier.mount(appEl!);
updateNotifier = notifier;
};
const unsubUpdateRequired = uiStore.subscribeSelector((s) => s.updateRequiredHost, offerUpdate);
offerUpdate(uiStore.getState().updateRequiredHost);
// Periodic health check — re-run every 15s so offline servers update when they come back
const healthCheckInterval = setInterval(() => {
runHealthChecks(connectPage, getProfileList());
}, 15_000);
// Wrap destroy to clear the interval
currentPage = {
destroy() {
clearInterval(healthCheckInterval);
unsubUpdateRequired();
updateNotifier?.destroy?.();
connectPage.destroy?.();
},
};
// Expose a health-refresh hook so trusting a first-use certificate can
// re-check the now-reachable server without a full page navigation.
rerunConnectHealth = () => runHealthChecks(connectPage, getProfileList());
// Route deep-link invites into this connect page. Apply any that arrived
// before it mounted.
applyInviteToConnectPage = (code, host) => connectPage.applyInviteLink(code, host);
if (pendingInviteLink !== null) {
connectPage.applyInviteLink(pendingInviteLink.code, pendingInviteLink.host);
pendingInviteLink = null;
}
// Load saved profiles and kick off health checks
void (async () => {
try {
await profileManager.loadProfiles();
const profiles = getProfileList();
connectPage.refreshProfiles(profiles);
runHealthChecks(connectPage, profiles);
} catch (err) {
log.warn("Failed to load profiles, using defaults", err);
runHealthChecks(connectPage, getProfileList());
}
// Consume any pending skip-auto-login flag on THIS mount regardless of
// which branch below returns early. It is the single source of truth
// for "an explicit logout just happened, don't auto-login" (set by the
// isAuthenticated subscriber further down), and every connect-page
// mount — quick-switch included — must clear it here or it survives in
// sessionStorage and goes on to suppress an unrelated, later
// clearAuth("server_shutdown") auto-login that deliberately does NOT
// re-set it (OC-0028).
const skipAutoLogin = sessionStorage.getItem("owncord:skip-auto-login") !== null;
sessionStorage.removeItem("owncord:skip-auto-login");
// Quick-switch: if the user switched servers via the overlay, auto-select
// the target server profile so they can reconnect with one click.
const quickSwitchTarget = sessionStorage.getItem("owncord:quick-switch-target");
if (quickSwitchTarget !== null) {
sessionStorage.removeItem("owncord:quick-switch-target");
const targetProfile = profileManager.getAll().find((p) => p.host === quickSwitchTarget);
connectPage.selectServer(
quickSwitchTarget,
targetProfile?.username ?? undefined,
targetProfile?.autoConnect === true,
);
return; // Skip auto-login when switching servers
}
// A logout that deleted this host's credential is on its way out as a
// fire-and-forget delete_credential. Auto-login below would read the
// same account back concurrently, and the credential commands run off
// the IPC thread now (`#[tauri::command(async)]`), so a read that wins
// that race signs the user straight back into the server they just left.
// Suppressing the attempt removes the race instead of relying on the
// delete being dispatched early enough to win it — and an auto-login
// immediately after an explicit logout is wrong regardless of timing.
if (skipAutoLogin) {
return;
}
// Auto-login: if a profile has autoConnect enabled, try to reconnect
// using the stored token (password is no longer returned from the
// credential store over IPC for security).
const autoProfile = profileManager.getAutoConnectProfile();
if (autoProfile) {
try {
const cred = await loadCredential(autoProfile.host);
if (cred?.username && cred?.token && !autoLoginCancelled) {
// Pass autoConnect so the checkbox still reads correctly if the
// user cancels and lands back on the form.
connectPage.selectServer(autoProfile.host, cred.username, autoProfile.autoConnect);
connectPage.showAutoConnecting(autoProfile.name);
if (autoLoginCancelled) return;
// Use stored token directly for reconnection. Preserve the
// profile's existing rememberPassword rather than forcing it to
// false (autoConnect profiles always have it true — see
// setAutoLogin), and skip wirePostAuth's credential re-save: the
// password is never returned over IPC here, so saving with
// rememberPassword defaulted to true would call saveCredential
// with password undefined, which rewrites the whole stored
// blob and silently destroys any password the user opted to
// remember (save_credential only carries the password key
// `if let Some(...)`, so a None wipes it — see credentials.rs).
api.setConfig({ host: autoProfile.host });
ensureProfileExists(
autoProfile.host,
cred.username,
autoProfile.rememberPassword,
autoProfile.autoConnect,
);
wirePostAuth(autoProfile.host, cred.token, cred.username, undefined, false);
return;
}
} catch (err) {
if (!autoLoginCancelled) {
const message = err instanceof Error ? err.message : "Auto-login failed";
log.warn("Auto-login failed", { host: autoProfile.host, error: message });
connectPage.showError(`Auto-login failed: ${message}`);
}
}
}
})();
} else {
// MainPage (and the LiveKit voice stack it statically imports) loads
// lazily so it stays out of the startup path. The chunk is pre-warmed as
// soon as the WS connect succeeds, so this normally resolves from the
// module cache.
const { createMainPage } = await import("@pages/MainPage");
// A newer navigation may have superseded this one while the chunk loaded;
// mounting now would fight the page that navigation rendered.
if (!isCurrentNavigation()) return;
const mainPage = createMainPage({ ws, api });
safeMount(mainPage, appEl!);
currentPage = mainPage;
}
}
// Listen for navigation changes
router.onNavigate((pageId) => {
void renderPage(pageId);
});
// Handle logout / disconnect
authStore.subscribeSelector(
(s) => s.isAuthenticated,
(isAuthenticated) => {
// The router only reaches "main" from the connected overlay's own
// onReady, 800ms after `ready` arrives — so a session that ends between
// auth_ok and ready (a ban, an auth_error on an intervening reconnect,
// a server_restart shutdown) flips isAuthenticated false while the
// router is still "connect". Gate on connectedOverlay too so that case
// still tears down: otherwise the overlay (position:fixed, opaque,
// z-index 200, appended straight to #app in wirePostAuth's auth_ok
// handler) is orphaned over the connect page with no remaining owner —
// its only other teardown paths are its own onReady timer (never armed
// without `ready`), the next wirePostAuth, onAutoLoginCancel, and the
// invite deep-link handler, none of which this path takes (OC-0157).
if (!isAuthenticated && (router.getCurrentPage() === "main" || connectedOverlay !== null)) {
// Leave voice channel before disconnecting so other clients see it
// immediately. Gated on clearAuth's logoutWasInVoice snapshot rather
// than the live voiceStore: clearAuth applies state (including this
// isAuthenticated flip) synchronously and already reset voiceStore in
// that same call, before this subscriber ever runs (store
// notifications are microtask-deferred) — voiceStore here would always
// read "idle".
if (authStore.getState().logoutWasInVoice === true) {
voiceSessionLeave(false); // false: we send voice_leave below
ws.send({ type: "voice_leave", payload: {} });
leaveVoiceChannel();
}
dispatcherCleanup?.();
dispatcherCleanup = null;
sessionCleanup?.();
sessionCleanup = null;
connectedOverlay?.destroy();
connectedOverlay = null;
ws.disconnect();
lastConnectToken = "";
lastConnectHost = "";
// Clear stored credential on logout — but keep it when the server
// kicked us by shutting down: the token is still valid, and deleting
// the credential would break auto-login every time the server restarts.
const host = api.getConfig().host;
const reason = authStore.getState().logoutReason;
if (host && reason !== "server_shutdown") {
// A protocol-epoch refusal keeps the credential too: the token is
// still valid, and the update the connect page offers relaunches
// straight into auto-login with it (sessionStorage — and so the
// skip flag below — does not survive that relaunch).
if (reason !== "protocol_epoch") void deleteCredential(host);
// Whenever this session must not turn around and auto-login with the
// credential (removed, or just refused), say so. A server_shutdown
// keeps the credential precisely so auto-login still works on
// restart, so it deliberately does not set this.
sessionStorage.setItem("owncord:skip-auto-login", "1");
}
router.navigate("connect");
}
},
);
// Send voice_leave on window close (best-effort — server readPump defer is the safety net)
window.addEventListener("beforeunload", () => {
const voice = voiceStore.getState();
if (voice.currentChannelId !== null) {
voiceSessionLeave(false); // false: we send voice_leave below
ws.send({ type: "voice_leave", payload: {} });
}
// Flush any buffered log entries to disk before the window closes.
void flushLogs();
});
// Initial render (fire-and-forget — the initial page is "connect", whose
// render branch is synchronous)
void renderPage(router.getCurrentPage());
// Initialize window state persistence (fire-and-forget)
void initWindowState();
// Route owncord:// invite deep links into the register form. OwnCord invites
// are registration invites, so a link can only pre-fill + open the register
// form — it can't complete a join by itself.
function handleInviteDeepLink(code: string, host?: string): void {
pendingInviteLink = { code, host };
if (router.getCurrentPage() === "main") {
// 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;
}
if (lastConnectHost !== "") {
// wirePostAuth already ran — a login/auto-login/register is connecting,
// or reached auth_ok (isAuthenticated flipped true) but the connected
// overlay's ready countdown hasn't called router.navigate("main") yet, so
// the branch above never triggered. The authStore subscriber only tears
// down once the router IS "main", so it won't fire for this window
// either: left alone, the overlay's timer fires router.navigate("main")
// regardless, mounting MainPage on top of whatever this handler does to
// authStore below. Tear the in-flight session down directly, the same
// way onAutoLoginCancel does, before applying the invite below.
sessionCleanup?.();
sessionCleanup = null;
dispatcherCleanup?.();
dispatcherCleanup = null;
connectedOverlay?.destroy();
connectedOverlay = null;
ws.disconnect();
lastConnectHost = "";
lastConnectToken = "";
if (authStore.getState().isAuthenticated) {
clearAuth();
}
}
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.
if (pendingInviteLink !== null && applyInviteToConnectPage !== null) {
applyInviteToConnectPage(code, host);
pendingInviteLink = null;
}
}
// Route owncord://message/<channelId>/<messageId> permalinks to the main
// page's jumper. Before the main page mounts (or when the channel isn't
// visible to this user) the jump is a logged no-op — a link into a server the
// user is not signed into has nothing to open.
function handleMessageDeepLink(channelId: number, messageId: number): void {
jumpToMessage(channelId, messageId);
}
void initDeepLinks(handleInviteDeepLink, handleMessageDeepLink);
// Initialize log persistence to disk (fire-and-forget)
void initLogPersistence();
log.info("OwnCord client initialized");