"use client"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { reloadBrowserWindow } from "@/lib/desktop/browser-reload"; import { readPersistedPowerActionState } from "@/lib/desktop/reboot-state"; import { SYSTEM_STREAM_STATUS_EVENT, type SystemStreamStatus, } from "@/lib/desktop/system-stream-status"; import type { SystemMetricsSnapshot } from "@/lib/shared/contracts/system"; import { queryKeys } from "@/lib/shared/query-keys"; export type ServerConnectionPhase = "online" | "lost" | "restored"; export type ServerRuntime = "docker" | "host" | null; // The screen shows on the first failed check. It only runs once the stream // has dropped (or the browser went offline), so that is already a second // signal: a restart that keeps Homeio away for a second or two still shows // "Reconnecting" and then "back", instead of nothing at all. const FAST_RETRY_MS = 2_000; const SLOW_RETRY_MS = 5_000; const FAST_RETRY_WINDOW_MS = 60_000; const HEALTH_TIMEOUT_MS = 5_000; const RESTORED_HOLD_MS = 1_200; // Clocks and uptime are both the server's, but sampling adds a little jitter. const RESTART_TOLERANCE_MS = 10_000; type ProcessIdentity = { pid: number; startedAtMs: number }; function identify(snapshot: SystemMetricsSnapshot | undefined): ProcessIdentity | null { if (!snapshot?.process) return null; const sampledAtMs = Date.parse(snapshot.timestamp); if (!Number.isFinite(sampledAtMs)) return null; return { pid: snapshot.process.pid, startedAtMs: sampledAtMs - snapshot.process.uptimeSeconds * 1_000, }; } function isSameProcess(before: ProcessIdentity | null, after: ProcessIdentity | null) { if (!before || !after) return false; return ( before.pid === after.pid && Math.abs(before.startedAtMs - after.startedAtMs) < RESTART_TOLERANCE_MS ); } async function isHealthy() { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); try { const response = await fetch("/api/health", { cache: "no-store", signal: controller.signal, }); return response.ok; } catch { return false; } finally { clearTimeout(timeout); } } async function fetchMetricsSnapshot(): Promise { try { const response = await fetch("/api/v1/system/metrics", { cache: "no-store" }); if (!response.ok) return undefined; const json = (await response.json()) as { data?: SystemMetricsSnapshot }; return json.data; } catch { return undefined; } } // Fetch the logo while the server still answers: once it is gone, /icon.png // is gone with it. function usePreloadedLogo() { const [logoSrc, setLogoSrc] = useState("/icon.png"); useEffect(() => { let objectUrl: string | null = null; let cancelled = false; fetch("/icon.png") .then((response) => (response.ok ? response.blob() : null)) .then((blob) => { if (!blob || cancelled) return; objectUrl = URL.createObjectURL(blob); setLogoSrc(objectUrl); }) .catch(() => undefined); return () => { cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, []); return logoSrc; } /** * Notices when Homeio stops answering without warning (a crash, a restart * from the terminal, a dropped network) and follows it until it is back. * Reboots and updates started from the desktop have their own screen. */ export function useServerConnection(enabled: boolean) { const queryClient = useQueryClient(); const logoSrc = usePreloadedLogo(); const [phase, setPhase] = useState("online"); const [isBrowserOffline, setIsBrowserOffline] = useState(false); const [lostSince, setLostSince] = useState(null); const [serverRestarted, setServerRestarted] = useState(false); const [runtime, setRuntime] = useState(null); const probingRef = useRef(false); const inFlightRef = useRef(false); const phaseRef = useRef("online"); const firstFailureAtRef = useRef(null); const identityBeforeRef = useRef(null); const timerRef = useRef | null>(null); const enabledRef = useRef(enabled); useEffect(() => { enabledRef.current = enabled; }, [enabled]); const updatePhase = useCallback((next: ServerConnectionPhase) => { phaseRef.current = next; setPhase(next); }, []); const clearTimer = useCallback(() => { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = null; }, []); const reset = useCallback(() => { clearTimer(); probingRef.current = false; firstFailureAtRef.current = null; setLostSince(null); setServerRestarted(false); updatePhase("online"); }, [clearTimer, updatePhase]); // Resolves false when the server dropped again before it could be read. const recover = useCallback(async () => { const authResponse = await fetch("/api/auth/me", { cache: "no-store" }).catch(() => null); if (!authResponse || authResponse.status >= 500) return false; const identityAfter = identify(await fetchMetricsSnapshot()); // A new process may carry a new build, and a signed-out session needs the // login page: in both cases only a reload gets the desktop right. const needsReload = authResponse?.status === 401 || !isSameProcess(identityBeforeRef.current, identityAfter); setServerRestarted(needsReload); updatePhase("restored"); timerRef.current = setTimeout(() => { if (needsReload) { reloadBrowserWindow(); return; } void queryClient.invalidateQueries(); reset(); }, RESTORED_HOLD_MS); return true; }, [queryClient, reset, updatePhase]); const probe = useCallback(async () => { if (inFlightRef.current) return; clearTimer(); if (!enabledRef.current || phaseRef.current === "restored") return; if (readPersistedPowerActionState(window.localStorage)) { reset(); return; } probingRef.current = true; inFlightRef.current = true; let healthy = await isHealthy(); if (healthy && phaseRef.current === "lost" && enabledRef.current) { healthy = await recover(); } inFlightRef.current = false; if (!enabledRef.current) return; if (healthy) { if (phaseRef.current === "online") reset(); return; } const now = Date.now(); firstFailureAtRef.current ??= now; if (phaseRef.current === "online") { const snapshot = queryClient.getQueryData(queryKeys.systemMetrics); identityBeforeRef.current = identify(snapshot); setRuntime(snapshot?.process?.runtime ?? null); setLostSince(firstFailureAtRef.current); updatePhase("lost"); } const delay = now - firstFailureAtRef.current < FAST_RETRY_WINDOW_MS ? FAST_RETRY_MS : SLOW_RETRY_MS; timerRef.current = setTimeout(() => void probe(), delay); }, [clearTimer, queryClient, recover, reset, updatePhase]); const startProbing = useCallback(() => { if (probingRef.current) return; void probe(); }, [probe]); const checkNow = useCallback(() => { if (phaseRef.current !== "lost") return; void probe(); }, [probe]); useEffect(() => { if (!enabled) { reset(); return; } setIsBrowserOffline(!navigator.onLine); if (!navigator.onLine) startProbing(); const onStreamStatus = (event: Event) => { const status = (event as CustomEvent).detail; if (status === "disconnected") { startProbing(); } else if (phaseRef.current === "lost") { void probe(); } }; const onOffline = () => { setIsBrowserOffline(true); startProbing(); }; const onOnline = () => { setIsBrowserOffline(false); if (phaseRef.current === "lost") void probe(); }; window.addEventListener(SYSTEM_STREAM_STATUS_EVENT, onStreamStatus); window.addEventListener("offline", onOffline); window.addEventListener("online", onOnline); return () => { window.removeEventListener(SYSTEM_STREAM_STATUS_EVENT, onStreamStatus); window.removeEventListener("offline", onOffline); window.removeEventListener("online", onOnline); clearTimer(); probingRef.current = false; }; }, [clearTimer, enabled, probe, reset, startProbing]); return { phase, isBrowserOffline, lostSince, serverRestarted, runtime, logoSrc, checkNow, }; }