mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
741 lines
27 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|