Files
Termix/src/main.tsx
T

425 lines
14 KiB
TypeScript
Raw Normal View History

+7
2025-11-05 10:36:16 -06:00
/* eslint-disable react-refresh/only-export-components */
2026-05-06 15:12:07 -05:00
import { prepareClientCacheVersion } from "@/lib/client-cache-version";
2026-05-11 01:23:11 -05:00
import { StrictMode, Suspense, lazy, useState, useRef, useEffect } from "react";
2025-09-12 14:42:00 -05:00
import { createRoot } from "react-dom/client";
2026-05-11 01:23:11 -05:00
import "./ui/index.css";
2025-09-12 14:42:00 -05:00
import { ThemeProvider } from "@/components/theme-provider";
2026-05-11 01:23:11 -05:00
import "./ui/i18n/i18n";
2026-05-06 15:12:07 -05:00
import { isElectron } from "@/lib/electron";
2026-05-11 01:23:11 -05:00
import { Toaster } from "@/components/sonner";
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
2026-05-28 22:29:20 -05:00
import { getUserInfo, getCurrentToken, appReadyPromise } from "@/main-axios";
2026-05-11 01:23:11 -05:00
import { applyAccentColor, applyFontSize } from "@/lib/theme";
+8
2026-07-19 12:29:52 -05:00
import { installElectronWheelZoomGuard } from "@/lib/electron-wheel-zoom";
2026-05-11 01:23:11 -05:00
import type { FontSizeId } from "@/types/ui-types";
import { useServiceWorker } from "@/hooks/use-service-worker";
2026-05-28 22:29:20 -05:00
import { useTranslation } from "react-i18next";
2026-05-06 15:12:07 -05:00
2026-05-11 01:23:11 -05:00
const AppShell = lazy(() =>
import("@/AppShell").then((m) => ({ default: m.AppShell })),
2026-05-06 15:12:07 -05:00
);
2026-05-11 01:23:11 -05:00
// Full-screen apps opened via query params (e.g. from external links or Electron)
2026-06-04 15:16:53 -04:00
const TerminalApp = lazy(() =>
import("@/features/terminal/TerminalApp").then((m) => ({
default: m.default,
})),
2026-05-06 15:12:07 -05:00
);
2026-06-04 15:16:53 -04:00
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 })),
);
+2
2026-06-16 15:59:53 -05:00
const HostMetricsApp = lazy(() =>
import("@/features/host-metrics/HostMetricsApp").then((m) => ({
2026-06-04 15:16:53 -04:00
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,
})),
2026-05-06 15:12:07 -05:00
);
+2
2026-06-16 15:59:53 -05:00
// --- tmux-monitor ---
const TmuxMonitorApp = lazy(() =>
import("@/features/tmux-monitor/TmuxMonitorApp").then((m) => ({
default: m.default,
})),
);
2026-05-11 01:23:11 -05:00
2026-06-29 13:28:26 -05:00
const HomepageApp = lazy(() =>
import("@/features/homepage/HomepageApp").then((m) => ({
default: m.default,
})),
);
2026-05-06 15:12:07 -05:00
const ElectronVersionCheck = lazy(() =>
2026-05-11 01:23:11 -05:00
import("@/user/ElectronVersionCheck").then((module) => ({
2026-05-06 15:12:07 -05:00
default: module.ElectronVersionCheck,
})),
);
2026-01-24 19:49:42 -06:00
+2
2026-07-26 18:47:27 -05:00
// Anonymous guest view for shared terminal/RDP/VNC/Telnet sessions (?view=shared&token=<linkToken>).
// Rendered outside FullscreenAppGate since guests never have a JWT/cookie to verify.
const SharedSessionView = lazy(
() => import("@/features/session-sharing/SharedSessionView"),
);
2026-05-28 22:29:20 -05:00
type Phase =
+3
2026-08-06 14:41:39 -05:00
"verifying" | "idle-auth" | "fading-in" | "idle-app" | "fading-out";
2026-05-11 01:23:11 -05:00
function FullscreenApp() {
2026-01-24 19:49:42 -06:00
const searchParams = new URLSearchParams(window.location.search);
const view = searchParams.get("view");
const hostId = searchParams.get("hostId");
+2
2026-06-16 15:59:53 -05:00
const tmuxSession = searchParams.get("tmuxSession");
2026-06-21 15:55:41 -05:00
const path = searchParams.get("path");
2026-01-24 19:49:42 -06:00
switch (view) {
case "terminal":
+2
2026-06-16 15:59:53 -05:00
return (
<TerminalApp
hostId={hostId || undefined}
tmuxSession={tmuxSession || undefined}
/>
);
2026-01-24 19:49:42 -06:00
case "file-manager":
2026-06-21 15:55:41 -05:00
return (
<FileManagerApp
hostId={hostId || undefined}
initialPath={path || undefined}
/>
);
2026-01-24 19:49:42 -06:00
case "tunnel":
return <TunnelApp hostId={hostId || undefined} />;
+2
2026-06-16 15:59:53 -05:00
case "host-metrics":
2026-01-24 19:49:42 -06:00
case "server-stats":
+2
2026-06-16 15:59:53 -05:00
return <HostMetricsApp hostId={hostId || undefined} />;
2026-01-24 19:49:42 -06:00
case "docker":
return <DockerApp hostId={hostId || undefined} />;
2026-03-14 20:05:05 -05:00
case "rdp":
case "vnc":
case "telnet":
+2
2026-06-16 15:59:53 -05:00
return (
<GuacamoleApp
hostId={hostId || undefined}
protocol={view as "rdp" | "vnc" | "telnet"}
/>
);
case "tmux-monitor": // --- tmux-monitor ---
case "tmux_monitor": // tab type spelling, so copied links also resolve
return <TmuxMonitorApp hostId={hostId || undefined} />;
2026-06-29 13:28:26 -05:00
case "homepage":
return <HomepageApp />;
2026-01-24 19:49:42 -06:00
default:
2026-05-11 01:23:11 -05:00
return null;
2026-01-24 19:49:42 -06:00
}
2026-05-11 01:23:11 -05:00
}
2025-08-07 02:20:27 -05:00
2026-06-29 13:28:26 -05:00
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 <FullscreenApp />;
}
if (!ready) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
return <FullscreenApp />;
}
2026-05-11 01:23:11 -05:00
function App() {
const stored = getStoredAuth();
const [phase, setPhase] = useState<Phase>(
2026-05-28 22:29:20 -05:00
stored?.loggedIn ? "verifying" : "idle-auth",
2026-05-11 01:23:11 -05:00
);
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
+2
2026-07-26 18:47:27 -05:00
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
2026-05-11 01:23:11 -05:00
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2026-06-29 13:28:26 -05:00
// 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);
+2
2026-07-26 18:47:27 -05:00
// Dedupes concurrent handleLogout() calls within the same tick -- see
// handleLogout for why phase state alone isn't sufficient for this.
const loggingOutRef = useRef(false);
2025-09-12 14:42:00 -05:00
useEffect(() => {
2026-05-11 01:23:11 -05:00
const savedAccent = localStorage.getItem("termix-accent");
if (savedAccent) applyAccentColor(savedAccent);
const savedSize = localStorage.getItem(
"termix-font-size",
) as FontSizeId | null;
+3
2026-08-06 14:41:39 -05:00
applyFontSize(savedSize ?? "md");
2025-09-12 14:42:00 -05:00
return () => {
2026-05-11 01:23:11 -05:00
if (timerRef.current) clearTimeout(timerRef.current);
2025-09-12 14:42:00 -05:00
};
}, []);
2026-05-28 22:29:20 -05:00
// 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())
2026-06-29 13:28:26 -05:00
.then(async () => {
2026-05-28 22:29:20 -05:00
if (isElectron()) {
2026-06-29 13:28:26 -05:00
try {
const token = await getCurrentToken();
+2
2026-07-26 18:47:27 -05:00
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(() => {});
}
2026-06-29 13:28:26 -05:00
} catch {
// Non-fatal: WebSocket connections will fall back to cookie auth
}
2026-05-28 22:29:20 -05:00
}
2026-06-29 13:28:26 -05:00
fadingInFromLoginRef.current = false;
2026-05-28 22:29:20 -05:00
setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
})
+2
2026-07-26 18:47:27 -05:00
.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);
2026-05-28 22:29:20 -05:00
});
+2
2026-07-26 18:47:27 -05:00
}, [phase, verifyRetryCount]);
2026-05-28 22:29:20 -05:00
2026-05-11 01:23:11 -05:00
function handleLogin(u: string) {
+2
2026-07-26 18:47:27 -05:00
loggingOutRef.current = false;
2026-05-11 01:23:11 -05:00
setAuthUsername(u);
2026-06-29 13:28:26 -05:00
fadingInFromLoginRef.current = true;
2026-05-11 01:23:11 -05:00
setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
2026-05-28 22:29:20 -05:00
if (isElectron()) {
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
+2
2026-07-26 18:47:27 -05:00
const localJwt = localStorage.getItem("jwt");
if (localJwt) {
window.electronAPI
?.invoke?.("notify-local-login", localJwt)
.catch(() => {});
}
2026-05-28 22:29:20 -05:00
}
2026-05-11 01:23:11 -05:00
}
function handleLogout() {
+2
2026-07-26 18:47:27 -05:00
// 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;
2026-05-11 01:23:11 -05:00
clearStoredAuth();
setPhase("fading-out");
timerRef.current = setTimeout(() => {
setAuthUsername("");
setPhase("idle-auth");
+2
2026-07-26 18:47:27 -05:00
loggingOutRef.current = false;
2026-05-11 01:23:11 -05:00
}, 450);
}
const showApp =
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
const showAuth =
2026-06-29 13:28:26 -05:00
phase === "idle-auth" ||
(phase === "fading-in" && fadingInFromLoginRef.current) ||
phase === "fading-out";
2026-05-11 01:23:11 -05:00
const appOpacity = phase === "idle-app" ? 1 : 0;
const authOpacity = phase === "idle-auth" ? 1 : 0;
2026-05-28 22:29:20 -05:00
const { t } = useTranslation();
const isTransitioning = phase === "fading-in" || phase === "fading-out";
if (phase === "verifying") {
return (
<div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
2026-05-11 01:23:11 -05:00
return (
<>
2026-05-28 22:29:20 -05:00
{isTransitioning && (
<div className="fixed inset-0 z-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted-foreground">
{t("common.loading")}
</p>
</div>
</div>
)}
2026-05-11 01:23:11 -05:00
{showApp && (
<div
2026-05-28 22:29:20 -05:00
className="fixed inset-0 z-10 transition-opacity duration-[450ms] ease-in-out"
2026-05-11 01:23:11 -05:00
style={{
opacity: appOpacity,
pointerEvents: phase === "idle-app" ? "auto" : "none",
}}
>
<Suspense fallback={null}>
+2
2026-07-26 18:47:27 -05:00
<AppShell username={authUsername} onLogout={handleLogout} />
2026-05-11 01:23:11 -05:00
</Suspense>
</div>
)}
{showAuth && (
<div
2026-05-28 22:29:20 -05:00
className="fixed inset-0 z-20 transition-opacity duration-[450ms] ease-in-out"
2026-05-11 01:23:11 -05:00
style={{
opacity: authOpacity,
pointerEvents: phase === "idle-auth" ? "auto" : "none",
}}
>
<Auth onLogin={handleLogin} />
</div>
)}
<Toaster position="bottom-right" />
</>
);
2025-09-12 14:42:00 -05:00
}
function RootApp() {
+7
2025-11-05 10:36:16 -06:00
const [showVersionCheck, setShowVersionCheck] = useState(true);
2025-09-12 14:42:00 -05:00
2026-01-24 19:49:42 -06:00
useServiceWorker();
const searchParams = new URLSearchParams(window.location.search);
const isFullscreen = searchParams.has("view");
+2
2026-07-26 18:47:27 -05:00
// 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 (
<Suspense fallback={null}>
<SharedSessionView />
</Suspense>
);
}
2026-05-11 01:23:11 -05:00
if (isFullscreen) {
return (
<Suspense fallback={null}>
2026-06-29 13:28:26 -05:00
<FullscreenAppGate />
2026-05-11 01:23:11 -05:00
</Suspense>
);
}
2026-01-24 19:49:42 -06:00
2026-05-11 01:23:11 -05:00
if (isElectron() && showVersionCheck) {
return (
<Suspense fallback={null}>
<ElectronVersionCheck onContinue={() => setShowVersionCheck(false)} />
</Suspense>
);
}
+7
2025-11-05 10:36:16 -06:00
2026-05-11 01:23:11 -05:00
return <App />;
2025-09-12 14:42:00 -05:00
}
+8
2026-07-19 12:29:52 -05:00
installElectronWheelZoomGuard();
2026-05-06 15:12:07 -05:00
prepareClientCacheVersion().finally(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<RootApp />
</ThemeProvider>
</StrictMode>,
);
});