/* eslint-disable react-refresh/only-export-components */ import { prepareClientCacheVersion } from "@/lib/client-cache-version"; import { StrictMode, Suspense, lazy, useState, useRef, useEffect } from "react"; import { createRoot } from "react-dom/client"; import "./ui/index.css"; import { ThemeProvider } from "@/components/theme-provider"; import "./ui/i18n/i18n"; import { isElectron } from "@/lib/electron"; import { Toaster } from "@/components/sonner"; import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth"; import { getUserInfo, getCurrentToken, appReadyPromise } from "@/main-axios"; import { applyAccentColor, applyFontSize, applyUiFont } from "@/lib/theme"; import { installElectronWheelZoomGuard } from "@/lib/electron-wheel-zoom"; import type { FontSizeId, UiFontId } from "@/types/ui-types"; import { useServiceWorker } from "@/hooks/use-service-worker"; import { useTranslation } from "react-i18next"; import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext"; import { ConnectionDefaultsProvider } from "@/contexts/ConnectionDefaultsContext"; const AppShell = lazy(() => import("@/AppShell").then((m) => ({ default: m.AppShell })), ); // Full-screen apps opened via query params (e.g. from external links or Electron) const TerminalApp = lazy(() => import("@/features/terminal/TerminalApp").then((m) => ({ default: m.default, })), ); const FileManagerApp = lazy(() => import("@/features/file-manager/FileManagerApp").then((m) => ({ default: m.default, })), ); const TunnelApp = lazy(() => import("@/features/tunnel/TunnelApp").then((m) => ({ default: m.default })), ); const HostMetricsApp = lazy(() => import("@/features/host-metrics/HostMetricsApp").then((m) => ({ default: m.default, })), ); const ProxmoxStatsApp = lazy(() => import("@/features/proxmox-stats/ProxmoxStatsApp").then((m) => ({ default: m.default, })), ); const DockerApp = lazy(() => import("@/features/docker/DockerApp").then((m) => ({ default: m.default })), ); const GuacamoleApp = lazy(() => import("@/features/guacamole/GuacamoleApp").then((m) => ({ default: m.default, })), ); // --- tmux-monitor --- const TmuxMonitorApp = lazy(() => import("@/features/tmux-monitor/TmuxMonitorApp").then((m) => ({ default: m.default, })), ); const HomepageApp = lazy(() => import("@/features/homepage/HomepageApp").then((m) => ({ default: m.default, })), ); const ElectronVersionCheck = lazy(() => import("@/user/ElectronVersionCheck").then((module) => ({ default: module.ElectronVersionCheck, })), ); // Anonymous guest view for shared terminal/RDP/VNC/Telnet sessions (?view=shared&token=). // Rendered outside FullscreenAppGate since guests never have a JWT/cookie to verify. const SharedSessionView = lazy( () => import("@/features/session-sharing/SharedSessionView"), ); type Phase = "verifying" | "idle-auth" | "fading-in" | "idle-app" | "fading-out"; function FullscreenApp() { const searchParams = new URLSearchParams(window.location.search); const view = searchParams.get("view"); const hostId = searchParams.get("hostId"); const tmuxSession = searchParams.get("tmuxSession"); const path = searchParams.get("path"); switch (view) { case "terminal": return ( ); case "file-manager": return ( ); case "tunnel": return ; case "host-metrics": case "server-stats": return ; case "proxmox-stats": return ; case "docker": return ; case "rdp": case "vnc": case "telnet": return ( ); case "tmux-monitor": // --- tmux-monitor --- case "tmux_monitor": // tab type spelling, so copied links also resolve return ; case "homepage": return ; default: return null; } } function FullscreenAppGate() { const { t } = useTranslation(); const [ready, setReady] = useState(false); const [authFailed, setAuthFailed] = useState(false); useEffect(() => { let cancelled = false; appReadyPromise .then(() => getUserInfo()) .then(async () => { if (isElectron()) { try { const token = await getCurrentToken(); if (token) localStorage.setItem("jwt", token); } catch { // WebSocket connections can still fall back to cookie auth. } } if (!cancelled) setReady(true); }) .catch(() => { if (!cancelled) setAuthFailed(true); }); return () => { cancelled = true; }; }, []); if (authFailed) { return ; } if (!ready) { return (

{t("common.loading")}

); } return ; } function App() { const stored = getStoredAuth(); const [phase, setPhase] = useState( stored?.loggedIn ? "verifying" : "idle-auth", ); const [authUsername, setAuthUsername] = useState(stored?.username ?? ""); const [verifyRetryCount, setVerifyRetryCount] = useState(0); const timerRef = useRef | null>(null); // Track whether fading-in came from a fresh login (vs. session verification on page load). // When session-verified, Auth must not mount during the transition — it would trigger // silent OIDC redirect and cause an infinite refresh loop. const fadingInFromLoginRef = useRef(false); // Dedupes concurrent handleLogout() calls within the same tick -- see // handleLogout for why phase state alone isn't sufficient for this. const loggingOutRef = useRef(false); useEffect(() => { const savedAccent = localStorage.getItem("termix-accent"); if (savedAccent) applyAccentColor(savedAccent); const savedSize = localStorage.getItem( "termix-font-size", ) as FontSizeId | null; applyFontSize(savedSize ?? "md"); applyUiFont( (localStorage.getItem("termix-ui-font") as UiFontId | null) ?? "jetbrains-mono", ); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); // Verify stored session against the server before rendering AppShell. // Wait for API instances to be initialized with correct embedded/server config first. // In Electron, also repopulate localStorage["jwt"] so WebSocket connections can auth // after a session restore (the token is only written to localStorage during a fresh login). useEffect(() => { if (phase !== "verifying") return; appReadyPromise .then(() => getUserInfo()) .then(async () => { if (isElectron()) { try { const token = await getCurrentToken(); if (token) { localStorage.setItem("jwt", token); // Remote Sync's engine (main process) needs this local JWT to // authenticate against the embedded backend during sync, same // as a fresh login provides via handleLogin below -- a session // restore (the common case on every normal launch) must hand // it over too, or sync silently never runs after the first // app restart. window.electronAPI ?.invoke?.("notify-local-login", token) .catch(() => {}); } } catch { // Non-fatal: WebSocket connections will fall back to cookie auth } } fadingInFromLoginRef.current = false; setPhase("fading-in"); timerRef.current = setTimeout(() => setPhase("idle-app"), 450); }) .catch((err: unknown) => { // Only treat a genuine auth rejection (401/403) as "not logged in". // Anything else (network hiccup, backend still starting up, a // transient 5xx) is not proof the session is invalid -- clearing // stored auth here would drop the user back to Auth.tsx, which in // Electron immediately mints a brand-new auto-session, silently // swapping out the JWT/cookie from under any still-in-flight // requests and causing spurious "Session expired" toasts. const status = (err as { status?: number; response?: { status?: number } }) ?.status ?? (err as { response?: { status?: number } })?.response?.status; if (status === 401 || status === 403) { clearStoredAuth(); setPhase("idle-auth"); return; } // Transient failure: retry rather than logging out. In Electron the // embedded local backend is bundled, always-on infrastructure that // always eventually comes up (a slow cold boot just takes longer), // and Auth.tsx never shows a login form for it anyway -- so there's // no reason to ever give up and manufacture a logout here. Outside // Electron a genuinely broken backend still needs to surface the // login screen eventually, so that case keeps a retry cap. if (!isElectron() && verifyRetryCount >= 5) { clearStoredAuth(); setPhase("idle-auth"); return; } const delay = isElectron() ? Math.min(1000 * 2 ** verifyRetryCount, 10000) : 3000; timerRef.current = setTimeout(() => { setVerifyRetryCount((c) => c + 1); }, delay); }); }, [phase, verifyRetryCount]); function handleLogin(u: string) { loggingOutRef.current = false; setAuthUsername(u); fadingInFromLoginRef.current = true; setPhase("fading-in"); timerRef.current = setTimeout(() => setPhase("idle-app"), 450); if (isElectron()) { window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {}); const localJwt = localStorage.getItem("jwt"); if (localJwt) { window.electronAPI ?.invoke?.("notify-local-login", localJwt) .catch(() => {}); } } } function handleLogout() { // A single background hiccup can trigger several independent 401s at // once (e.g. a burst of unrelated polls all failing together in the // same tick), each calling this. React batches the resulting setPhase // calls, so checking `phase` here can't distinguish the first call in // a batch from the second -- both would see the same pre-update value // and both would proceed, each overwriting timerRef with a fresh // 450ms timer. A steady trickle of these could keep resetting the // countdown so the transition never actually completes, which looks // exactly like "nothing happens." loggingOutRef is synchronous and // isn't subject to batching, so it correctly dedupes within one tick. if (loggingOutRef.current) return; loggingOutRef.current = true; clearStoredAuth(); setPhase("fading-out"); timerRef.current = setTimeout(() => { setAuthUsername(""); setPhase("idle-auth"); loggingOutRef.current = false; }, 450); } const showApp = phase === "idle-app" || phase === "fading-in" || phase === "fading-out"; const showAuth = phase === "idle-auth" || (phase === "fading-in" && fadingInFromLoginRef.current) || phase === "fading-out"; const appOpacity = phase === "idle-app" ? 1 : 0; const authOpacity = phase === "idle-auth" ? 1 : 0; const { t } = useTranslation(); const isTransitioning = phase === "fading-in" || phase === "fading-out"; if (phase === "verifying") { return (

{t("common.loading")}

); } return ( <> {isTransitioning && (

{t("common.loading")}

)} {showApp && (
)} {showAuth && (
)} ); } function RootApp() { const [showVersionCheck, setShowVersionCheck] = useState(true); useServiceWorker(); const searchParams = new URLSearchParams(window.location.search); const isFullscreen = searchParams.has("view"); // Anonymous guests have no cookie/JWT at all, so this bypasses FullscreenAppGate's // auth check entirely rather than waiting on a getUserInfo() call that would always fail. if (searchParams.get("view") === "shared") { return ( ); } if (isFullscreen) { return ( ); } if (isElectron() && showVersionCheck) { return ( setShowVersionCheck(false)} /> ); } return ; } installElectronWheelZoomGuard(); prepareClientCacheVersion().finally(() => { createRoot(document.getElementById("root")!).render( , ); });