/** * BootCheckGate — pre-router gate rendered before the rest of the app mounts. * * Responsibilities: * 1. First-ever launch: prompt user to pick Local or Cloud core mode. * 2. Subsequent launches: run version / reachability check and block until * the result is `match`. * * Visual language follows ServiceBlockingGate.tsx (bg-stone-950/80 overlay, * bg-stone-900 panel, ocean-500 / coral-500 semantics). */ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck'; import { useT } from '../../lib/i18n/I18nContext'; import { bootCheckTransport } from '../../services/bootCheckService'; import { clearCoreRpcTokenCache, clearCoreRpcUrlCache, testCoreRpcConnection, } from '../../services/coreRpcClient'; import { type CoreMode, resetCoreMode, setCoreMode } from '../../store/coreModeSlice'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { clearStoredCoreMode, clearStoredCoreToken, isLocalOrPrivateNetworkHost, storeCoreMode, storeCoreToken, storeRpcUrl, } from '../../utils/configPersistence'; import { isTauri } from '../../utils/tauriCommands/common'; const log = debug('boot-check'); const logError = debug('boot-check:error'); // --------------------------------------------------------------------------- // Internal types // --------------------------------------------------------------------------- type Phase = | 'picker' // mode not set — show mode selector | 'checking' // boot check in flight | 'result'; // check finished with a non-match result // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- interface PanelProps { children: React.ReactNode; } function Panel({ children }: PanelProps) { return (
{children}
); } // --------------------------------------------------------------------------- // Picker (first-ever launch) // --------------------------------------------------------------------------- interface PickerProps { onConfirm: (mode: CoreMode) => void; } type TestStatus = | { kind: 'idle' } | { kind: 'testing' } | { kind: 'ok' } | { kind: 'auth' } | { kind: 'unreachable'; reason: string }; // Desktop release artifact URL surfaced on the web build's mode picker so // users without a remote core have a clear path to install the app instead // of being trapped on the cloud-only form. const DESKTOP_DOWNLOAD_URL = 'https://github.com/tinyhumansai/openhuman/releases/latest'; function ModePicker({ onConfirm }: PickerProps) { const { t } = useT(); // Web build cannot spawn a local sidecar, so the only viable choice is // cloud. Default the selection accordingly and hide the local option in // the render path below. const isDesktop = isTauri(); const [selected, setSelected] = useState<'local' | 'cloud'>(isDesktop ? 'local' : 'cloud'); const [cloudUrl, setCloudUrl] = useState(''); const [cloudToken, setCloudToken] = useState(''); const [urlError, setUrlError] = useState(null); const [tokenError, setTokenError] = useState(null); const [testStatus, setTestStatus] = useState({ kind: 'idle' }); /** * Validate the cloud URL + token inputs against a live core before we * commit the mode. We hit the public `core.ping` (auth-bypass) to confirm * reachability, then re-issue the same JSON-RPC envelope with the bearer * token to confirm `/rpc` accepts it. This catches the two most common * paste-time mistakes — wrong URL, wrong/missing token — with one click, * before the user lands on the unreachable result screen. * * Tokens are never logged: only `tokenLen` is emitted via the existing * picker debug line, and any error messages from the network/JSON parse * paths are passed through verbatim without the bearer value. */ const validateInputs = (): { url: string; token: string } | null => { const trimmedUrl = cloudUrl.trim(); if (!trimmedUrl) { setUrlError(t('bootCheck.invalidUrl')); return null; } try { const parsed = new URL(trimmedUrl); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { setUrlError(t('bootCheck.urlMustStartWith')); return null; } if (parsed.protocol === 'http:' && !isLocalOrPrivateNetworkHost(parsed.hostname)) { setUrlError( 'HTTP core URLs are only allowed for localhost or private network hosts. Use HTTPS for public hosts.' ); return null; } } catch { setUrlError(t('bootCheck.validUrlRequired')); return null; } setUrlError(null); const trimmedToken = cloudToken.trim(); if (!trimmedToken) { setTokenError(t('bootCheck.tokenRequired')); return null; } setTokenError(null); return { url: trimmedUrl, token: trimmedToken }; }; const handleTestConnection = async () => { const validated = validateInputs(); if (!validated) return; setTestStatus({ kind: 'testing' }); log( '[boot-check] picker — testing cloud connection url=%s tokenLen=%d', validated.url, validated.token.length ); try { const response = await testCoreRpcConnection(validated.url, validated.token); if (response.status === 401 || response.status === 403) { log('[boot-check] picker — test failed: auth (status=%d)', response.status); setTestStatus({ kind: 'auth' }); return; } if (!response.ok) { log('[boot-check] picker — test failed: HTTP %d', response.status); setTestStatus({ kind: 'unreachable', reason: `HTTP ${response.status} from /rpc` }); return; } // Drain the body — response.ok with JSON-RPC error is still reachable. try { await response.json(); } catch { // Non-JSON body is unusual but doesn't disprove reachability. } log('[boot-check] picker — test succeeded'); setTestStatus({ kind: 'ok' }); } catch (err) { const reason = err instanceof Error ? err.message : 'Connection failed'; logError('[boot-check] picker — test errored: %o', err); setTestStatus({ kind: 'unreachable', reason }); } }; const handleContinue = () => { if (selected === 'local') { log('[boot-check] picker — user selected local mode'); onConfirm({ kind: 'local' }); return; } const validated = validateInputs(); if (!validated) return; log( '[boot-check] picker — user selected cloud mode url=%s tokenLen=%d', validated.url, validated.token.length ); onConfirm({ kind: 'cloud', url: validated.url, token: validated.token }); }; return (

{isDesktop ? t('bootCheck.chooseCoreMode') : t('bootCheck.connectToCore')}

{isDesktop ? t('bootCheck.desktopDescription') : t('bootCheck.webDescription')}

{!isDesktop && (
{t('bootCheck.preferDesktop')}{' '} {t('bootCheck.downloadDesktop')} .
)}
{/* Local option — desktop only; web builds cannot spawn a sidecar. */} {isDesktop && ( )} {/* Cloud option — always available; the only option on the web build. */} {isDesktop && ( )} {selected === 'cloud' && (
{ setCloudUrl(e.target.value); setUrlError(null); setTestStatus({ kind: 'idle' }); }} className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none" /> {urlError &&

{urlError}

}
{ setCloudToken(e.target.value); setTokenError(null); setTestStatus({ kind: 'idle' }); }} className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none" /> {tokenError &&

{tokenError}

}

{t('bootCheck.storedLocally')} Authorization: Bearer … on every RPC.

{testStatus.kind === 'ok' && ( {t('bootCheck.connectedOk')} )} {testStatus.kind === 'auth' && ( {t('bootCheck.authFailed')} )} {testStatus.kind === 'unreachable' && ( {t('bootCheck.unreachablePrefix')} {testStatus.reason} )}
)}
); } // --------------------------------------------------------------------------- // Spinner / checking // --------------------------------------------------------------------------- function CheckingScreen() { const { t } = useT(); return (

{t('bootCheck.checkingCore')}

); } // --------------------------------------------------------------------------- // Result screens // --------------------------------------------------------------------------- interface ResultScreenProps { result: BootCheckResult; onRetry: () => void; onSwitchMode: () => void; onQuit: () => void; actionBusy: boolean; actionError: string | null; onAction: () => void; } function ResultScreen({ result, onRetry, onSwitchMode, onQuit, actionBusy, actionError, onAction, }: ResultScreenProps) { const { t } = useT(); if (result.kind === 'match') return null; if (result.kind === 'unreachable') { return (

{t('bootCheck.cannotReach')}

{result.reason || t('bootCheck.cannotReachDesc')}

{actionError &&

{actionError}

}
); } if (result.kind === 'daemonDetected') { return (

{t('bootCheck.legacyDetected')}

{t('bootCheck.legacyDescription')}

{actionError &&

{actionError}

}
); } if (result.kind === 'outdatedLocal') { return (

{t('bootCheck.localNeedsRestart')}

{t('bootCheck.localNeedsRestartDesc')}

{actionError &&

{actionError}

}
); } if (result.kind === 'outdatedCloud') { return (

{t('bootCheck.cloudNeedsUpdate')}

{t('bootCheck.cloudNeedsUpdateDesc')}

{actionError &&

{actionError}

}
); } // noVersionMethod — treat like outdated, user picks which flavor of action return (

{t('bootCheck.versionCheckFailed')}

{t('bootCheck.versionCheckFailedDesc')}

{actionError &&

{actionError}

}
); } // --------------------------------------------------------------------------- // Main gate // --------------------------------------------------------------------------- interface BootCheckGateProps { children: React.ReactNode; } export default function BootCheckGate({ children }: BootCheckGateProps) { const { t } = useT(); const dispatch = useAppDispatch(); const coreMode = useAppSelector(state => state.coreMode.mode); const [phase, setPhase] = useState(() => coreMode.kind === 'unset' ? 'picker' : 'checking' ); const [result, setResult] = useState(null); const [actionBusy, setActionBusy] = useState(false); const [actionError, setActionError] = useState(null); // Prevent concurrent or stale runs. const runningRef = useRef(false); // Production transport lives in services/bootCheckService so direct // Tauri/RPC imports stay localized there. const transport = bootCheckTransport; const runCheck = useCallback( async (mode: CoreMode) => { if (runningRef.current) { log('[boot-check] gate — check already running, skipping duplicate'); return; } runningRef.current = true; setPhase('checking'); setResult(null); setActionError(null); log('[boot-check] gate — starting check mode=%s', mode.kind); try { const checkResult = await runBootCheck(mode, transport); log('[boot-check] gate — check result=%s', checkResult.kind); if (checkResult.kind === 'match') { // Gate resolves — render children. setPhase('result'); setResult(checkResult); } else { setPhase('result'); setResult(checkResult); } } catch (err) { logError('[boot-check] gate — unexpected error: %o', err); setPhase('result'); setResult({ kind: 'unreachable', reason: err instanceof Error ? err.message : t('bootCheck.unexpectedError'), }); } finally { runningRef.current = false; } }, // transport is stable (constructed inline but always same shape) // eslint-disable-next-line react-hooks/exhaustive-deps [] ); // Start check automatically when mode is set and we're in checking phase. // The async setState calls inside runCheck() happen after an await, so they // do not synchronously cascade — suppress the linter warning here. useEffect(() => { if (coreMode.kind !== 'unset' && phase === 'checking') { void runCheck(coreMode); } }, [coreMode, phase, runCheck]); // ------------------------------------------------------------------ // Picker confirm — dispatches setCoreMode and kicks off check. // ------------------------------------------------------------------ const handlePickerConfirm = useCallback( (mode: CoreMode) => { log('[boot-check] gate — picker confirmed mode=%s', mode.kind); // Persist URL + token for cloud mode so getCoreRpcUrl/Token resolve // correctly on the boot-check probe (and every subsequent RPC) without // waiting for redux-persist's async rehydrate to complete. Also write // the synchronous `openhuman_core_mode` marker so a reload triggered // mid-flight (e.g. `handleIdentityFlip` → `restartApp`) recovers the // chosen mode from localStorage before redux-persist flushes. Clear // caches so any prior local-mode resolution doesn't leak into cloud. if (mode.kind === 'cloud') { storeRpcUrl(mode.url); storeCoreToken(mode.token ?? ''); storeCoreMode('cloud'); } else { storeRpcUrl(''); clearStoredCoreToken(); storeCoreMode('local'); } clearCoreRpcUrlCache(); clearCoreRpcTokenCache(); dispatch(setCoreMode(mode)); setPhase('checking'); }, [dispatch] ); // ------------------------------------------------------------------ // Switch mode — reset to picker. // ------------------------------------------------------------------ const handleSwitchMode = useCallback(() => { log('[boot-check] gate — switch mode requested'); storeRpcUrl(''); clearStoredCoreToken(); clearStoredCoreMode(); clearCoreRpcUrlCache(); clearCoreRpcTokenCache(); dispatch(resetCoreMode()); setPhase('picker'); setResult(null); setActionError(null); }, [dispatch]); // ------------------------------------------------------------------ // Quit the app. // ------------------------------------------------------------------ const handleQuit = useCallback(async () => { log('[boot-check] gate — quit requested'); try { await bootCheckTransport.invokeCmd('app_quit'); } catch (err) { logError('[boot-check] gate — app_quit failed: %o', err); } }, []); // ------------------------------------------------------------------ // Retry (unreachable state). // ------------------------------------------------------------------ const handleRetry = useCallback(() => { log('[boot-check] gate — retry requested'); if (coreMode.kind !== 'unset') { runCheck(coreMode); } }, [coreMode, runCheck]); // ------------------------------------------------------------------ // Primary action per result kind. // ------------------------------------------------------------------ const handleAction = useCallback(async () => { if (!result || actionBusy) return; setActionBusy(true); setActionError(null); try { if (result.kind === 'daemonDetected') { log('[boot-check] gate — removing legacy daemon'); await transport.callRpc('openhuman.service_stop', {}); await transport.callRpc('openhuman.service_uninstall', {}); log('[boot-check] gate — daemon removed, re-running check'); } else if (result.kind === 'outdatedLocal' || result.kind === 'noVersionMethod') { log('[boot-check] gate — restarting local core'); await transport.invokeCmd('restart_core_process', {}); log('[boot-check] gate — local core restarted'); } else if (result.kind === 'outdatedCloud') { log('[boot-check] gate — triggering cloud core update'); await transport.callRpc('openhuman.update_run', {}); log('[boot-check] gate — cloud core update triggered'); } // Re-run the full check after the action. if (coreMode.kind !== 'unset') { runCheck(coreMode); } } catch (err) { logError('[boot-check] gate — action error: %o', err); setActionError(err instanceof Error ? err.message : t('bootCheck.actionFailed')); } finally { setActionBusy(false); } // transport is stable shape // eslint-disable-next-line react-hooks/exhaustive-deps }, [result, actionBusy, coreMode, runCheck]); // ------------------------------------------------------------------ // Render // ------------------------------------------------------------------ // Unset — show picker (even if Redux persisted something; phase reflects truth). if (phase === 'picker' || coreMode.kind === 'unset') { return ( <> ); } // Check in flight. if (phase === 'checking') { return ; } // Match — pass through. if (result?.kind === 'match') { return <>{children}; } // Non-match result. return ( <> ); }