Files
openhuman/app/src/components/BootCheckGate/BootCheckGate.tsx
T
2026-05-15 16:11:40 -07:00

741 lines
27 KiB
TypeScript

/**
* 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 (
<div className="fixed inset-0 z-[10000] bg-stone-950/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-xl rounded-2xl border border-stone-700/50 bg-stone-900 p-6 shadow-2xl">
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<string | null>(null);
const [tokenError, setTokenError] = useState<string | null>(null);
const [testStatus, setTestStatus] = useState<TestStatus>({ 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 (
<Panel>
<h2 className="text-xl font-semibold text-white">
{isDesktop ? t('bootCheck.chooseCoreMode') : t('bootCheck.connectToCore')}
</h2>
<p className="mt-2 text-sm text-stone-300">
{isDesktop ? t('bootCheck.desktopDescription') : t('bootCheck.webDescription')}
</p>
{!isDesktop && (
<div
className="mt-4 rounded-xl border border-stone-700 bg-stone-800/60 p-3 text-xs text-stone-300"
data-testid="web-download-cta">
{t('bootCheck.preferDesktop')}{' '}
<a
href={DESKTOP_DOWNLOAD_URL}
target="_blank"
rel="noopener noreferrer"
className="text-ocean-400 underline hover:text-ocean-300">
{t('bootCheck.downloadDesktop')}
</a>
.
</div>
)}
<div className="mt-5 flex flex-col gap-3">
{/* Local option — desktop only; web builds cannot spawn a sidecar. */}
{isDesktop && (
<button
type="button"
onClick={() => setSelected('local')}
className={`rounded-xl border p-4 text-left transition-colors ${
selected === 'local'
? 'border-ocean-500 bg-ocean-500/10 text-white'
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
}`}>
<div className="font-medium">{t('bootCheck.localRecommended')}</div>
<div className="mt-0.5 text-xs text-stone-400">{t('bootCheck.localDescription')}</div>
</button>
)}
{/* Cloud option — always available; the only option on the web build. */}
{isDesktop && (
<button
type="button"
onClick={() => setSelected('cloud')}
className={`rounded-xl border p-4 text-left transition-colors ${
selected === 'cloud'
? 'border-ocean-500 bg-ocean-500/10 text-white'
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
}`}>
<div className="font-medium">{t('bootCheck.cloudMode')}</div>
<div className="mt-0.5 text-xs text-stone-400">{t('bootCheck.cloudDescription')}</div>
</button>
)}
{selected === 'cloud' && (
<div className="mt-1 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-stone-300">
{t('bootCheck.coreRpcUrl')}
</label>
<input
type="url"
placeholder={t('bootCheck.rpcUrlPlaceholder')}
value={cloudUrl}
onChange={e => {
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 && <p className="text-xs text-coral-400">{urlError}</p>}
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-stone-300">
{t('bootCheck.authToken')} (
<code className="text-[10px]">OPENHUMAN_CORE_TOKEN</code>)
</label>
<input
type="password"
autoComplete="off"
spellCheck={false}
placeholder={t('bootCheck.bearerTokenPlaceholder')}
value={cloudToken}
onChange={e => {
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 && <p className="text-xs text-coral-400">{tokenError}</p>}
<p className="text-[11px] text-stone-500">
{t('bootCheck.storedLocally')} <code>Authorization: Bearer </code> on every RPC.
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleTestConnection}
disabled={testStatus.kind === 'testing'}
className="rounded-lg border border-stone-600 px-3 py-1.5 text-xs text-stone-100 hover:bg-stone-800 disabled:opacity-60">
{testStatus.kind === 'testing'
? t('bootCheck.testing')
: t('bootCheck.testConnection')}
</button>
{testStatus.kind === 'ok' && (
<span className="text-xs text-emerald-400" data-testid="test-status-ok">
{t('bootCheck.connectedOk')}
</span>
)}
{testStatus.kind === 'auth' && (
<span className="text-xs text-coral-400" data-testid="test-status-auth">
{t('bootCheck.authFailed')}
</span>
)}
{testStatus.kind === 'unreachable' && (
<span className="text-xs text-coral-400" data-testid="test-status-unreachable">
{t('bootCheck.unreachablePrefix')} {testStatus.reason}
</span>
)}
</div>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={handleContinue}
className="rounded-lg bg-ocean-500 px-5 py-2 text-sm font-medium text-white hover:bg-ocean-600">
{t('common.continue')}
</button>
</div>
</Panel>
);
}
// ---------------------------------------------------------------------------
// Spinner / checking
// ---------------------------------------------------------------------------
function CheckingScreen() {
const { t } = useT();
return (
<Panel>
<div className="flex flex-col items-center gap-4 py-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-600 border-t-ocean-500" />
<p className="text-sm text-stone-300">{t('bootCheck.checkingCore')}</p>
</div>
</Panel>
);
}
// ---------------------------------------------------------------------------
// 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 (
<Panel>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.cannotReach')}</h2>
<p className="mt-2 text-sm text-stone-300">
{result.reason || t('bootCheck.cannotReachDesc')}
</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={onRetry}
disabled={actionBusy}
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60">
{t('common.retry')}
</button>
<button
type="button"
onClick={onSwitchMode}
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800">
{t('bootCheck.switchMode')}
</button>
<button
type="button"
onClick={onQuit}
className="rounded-lg bg-coral-500 px-4 py-2 text-sm font-medium text-white hover:bg-coral-600">
{t('bootCheck.quit')}
</button>
</div>
</Panel>
);
}
if (result.kind === 'daemonDetected') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.legacyDetected')}</h2>
<p className="mt-2 text-sm text-stone-300">{t('bootCheck.legacyDescription')}</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-coral-500 px-4 py-2 text-sm font-medium text-white hover:bg-coral-600 disabled:opacity-60">
{actionBusy ? t('bootCheck.removing') : t('bootCheck.removeContinue')}
</button>
<button
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60">
{t('bootCheck.switchMode')}
</button>
</div>
</Panel>
);
}
if (result.kind === 'outdatedLocal') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.localNeedsRestart')}</h2>
<p className="mt-2 text-sm text-stone-300">{t('bootCheck.localNeedsRestartDesc')}</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-ocean-500 px-4 py-2 text-sm font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
{actionBusy ? t('bootCheck.restarting') : t('bootCheck.restartCore')}
</button>
<button
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60">
{t('bootCheck.switchMode')}
</button>
</div>
</Panel>
);
}
if (result.kind === 'outdatedCloud') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.cloudNeedsUpdate')}</h2>
<p className="mt-2 text-sm text-stone-300">{t('bootCheck.cloudNeedsUpdateDesc')}</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-ocean-500 px-4 py-2 text-sm font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
{actionBusy ? t('bootCheck.updating') : t('bootCheck.updateCloudCore')}
</button>
<button
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60">
{t('bootCheck.switchMode')}
</button>
</div>
</Panel>
);
}
// noVersionMethod — treat like outdated, user picks which flavor of action
return (
<Panel>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.versionCheckFailed')}</h2>
<p className="mt-2 text-sm text-stone-300">{t('bootCheck.versionCheckFailedDesc')}</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-ocean-500 px-4 py-2 text-sm font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
{actionBusy ? t('bootCheck.working') : t('bootCheck.restartUpdateCore')}
</button>
<button
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60">
{t('bootCheck.switchMode')}
</button>
</div>
</Panel>
);
}
// ---------------------------------------------------------------------------
// 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<Phase>(() =>
coreMode.kind === 'unset' ? 'picker' : 'checking'
);
const [result, setResult] = useState<BootCheckResult | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(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 (
<>
<ModePicker onConfirm={handlePickerConfirm} />
</>
);
}
// Check in flight.
if (phase === 'checking') {
return <CheckingScreen />;
}
// Match — pass through.
if (result?.kind === 'match') {
return <>{children}</>;
}
// Non-match result.
return (
<>
<ResultScreen
result={result ?? { kind: 'unreachable', reason: 'Unknown error' }}
onRetry={handleRetry}
onSwitchMode={handleSwitchMode}
onQuit={handleQuit}
actionBusy={actionBusy}
actionError={actionError}
onAction={handleAction}
/>
</>
);
}