mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
355 lines
10 KiB
TypeScript
355 lines
10 KiB
TypeScript
/* 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 } from "@/lib/theme";
|
|
import { installElectronWheelZoomGuard } from "@/lib/electron-wheel-zoom";
|
|
import type { FontSizeId } from "@/types/ui-types";
|
|
import { useServiceWorker } from "@/hooks/use-service-worker";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
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 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,
|
|
})),
|
|
);
|
|
|
|
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 (
|
|
<TerminalApp
|
|
hostId={hostId || undefined}
|
|
tmuxSession={tmuxSession || undefined}
|
|
/>
|
|
);
|
|
case "file-manager":
|
|
return (
|
|
<FileManagerApp
|
|
hostId={hostId || undefined}
|
|
initialPath={path || undefined}
|
|
/>
|
|
);
|
|
case "tunnel":
|
|
return <TunnelApp hostId={hostId || undefined} />;
|
|
case "host-metrics":
|
|
case "server-stats":
|
|
return <HostMetricsApp hostId={hostId || undefined} />;
|
|
case "docker":
|
|
return <DockerApp hostId={hostId || undefined} />;
|
|
case "rdp":
|
|
case "vnc":
|
|
case "telnet":
|
|
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} />;
|
|
case "homepage":
|
|
return <HomepageApp />;
|
|
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 <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 />;
|
|
}
|
|
|
|
function App() {
|
|
const stored = getStoredAuth();
|
|
const [phase, setPhase] = useState<Phase>(
|
|
stored?.loggedIn ? "verifying" : "idle-auth",
|
|
);
|
|
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | 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);
|
|
|
|
useEffect(() => {
|
|
const savedAccent = localStorage.getItem("termix-accent");
|
|
if (savedAccent) applyAccentColor(savedAccent);
|
|
const savedSize = localStorage.getItem(
|
|
"termix-font-size",
|
|
) as FontSizeId | null;
|
|
applyFontSize(savedSize ?? "lg");
|
|
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);
|
|
} 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(() => {
|
|
clearStoredAuth();
|
|
setPhase("idle-auth");
|
|
});
|
|
}, [phase]);
|
|
|
|
function handleLogin(u: string) {
|
|
setAuthUsername(u);
|
|
fadingInFromLoginRef.current = true;
|
|
setPhase("fading-in");
|
|
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
|
if (isElectron()) {
|
|
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
|
|
}
|
|
}
|
|
|
|
function handleLogout() {
|
|
clearStoredAuth();
|
|
setPhase("fading-out");
|
|
timerRef.current = setTimeout(() => {
|
|
setAuthUsername("");
|
|
setPhase("idle-auth");
|
|
}, 450);
|
|
}
|
|
|
|
function handleChangeServer() {
|
|
localStorage.setItem("termix_show_server_config", "true");
|
|
handleLogout();
|
|
}
|
|
|
|
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 (
|
|
<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 (
|
|
<>
|
|
{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>
|
|
)}
|
|
|
|
{showApp && (
|
|
<div
|
|
className="fixed inset-0 z-10 transition-opacity duration-[450ms] ease-in-out"
|
|
style={{
|
|
opacity: appOpacity,
|
|
pointerEvents: phase === "idle-app" ? "auto" : "none",
|
|
}}
|
|
>
|
|
<Suspense fallback={null}>
|
|
<AppShell
|
|
username={authUsername}
|
|
onLogout={handleLogout}
|
|
onChangeServer={handleChangeServer}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
)}
|
|
|
|
{showAuth && (
|
|
<div
|
|
className="fixed inset-0 z-20 transition-opacity duration-[450ms] ease-in-out"
|
|
style={{
|
|
opacity: authOpacity,
|
|
pointerEvents: phase === "idle-auth" ? "auto" : "none",
|
|
}}
|
|
>
|
|
<Auth onLogin={handleLogin} />
|
|
</div>
|
|
)}
|
|
|
|
<Toaster position="bottom-right" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function RootApp() {
|
|
const [showVersionCheck, setShowVersionCheck] = useState(true);
|
|
|
|
useServiceWorker();
|
|
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
const isFullscreen = searchParams.has("view");
|
|
|
|
if (isFullscreen) {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<FullscreenAppGate />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
if (isElectron() && showVersionCheck) {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<ElectronVersionCheck onContinue={() => setShowVersionCheck(false)} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
return <App />;
|
|
}
|
|
|
|
installElectronWheelZoomGuard();
|
|
|
|
prepareClientCacheVersion().finally(() => {
|
|
createRoot(document.getElementById("root")!).render(
|
|
<StrictMode>
|
|
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
|
<RootApp />
|
|
</ThemeProvider>
|
|
</StrictMode>,
|
|
);
|
|
});
|