feat(boot): gate launch on core mode pick + version check (#1316)

This commit is contained in:
Steven Enamakel
2026-05-06 18:26:30 -07:00
committed by GitHub
parent ba88d8cfc3
commit 33d8aa9cac
11 changed files with 1609 additions and 24 deletions
+46 -8
View File
@@ -237,6 +237,33 @@ async fn restart_core_process(
state.inner().restart().await
}
/// Start the embedded core process on demand.
///
/// Called by the BootCheckGate (Local mode) before the version check. The
/// core no longer auto-spawns at Tauri setup — the UI is responsible for
/// driving the lifecycle so it can surface startup failures and version
/// mismatches to the user.
///
/// Idempotent: `ensure_running` is a no-op if the core is already up.
#[tauri::command]
async fn start_core_process(
state: tauri::State<'_, core_process::CoreProcessHandle>,
) -> Result<(), String> {
log::info!("[core] start_core_process: command invoked from frontend");
state.inner().ensure_running().await
}
/// Cleanly exit the application.
///
/// Called by the BootCheckGate "Quit" button when the core is unreachable and
/// the user chooses to close the app rather than switch modes.
#[tauri::command]
async fn app_quit(app: tauri::AppHandle<AppRuntime>) -> Result<(), String> {
log::info!("[app] app_quit: quit requested from frontend");
app.exit(0);
Ok(())
}
#[tauri::command]
async fn restart_app(app: tauri::AppHandle<AppRuntime>) -> Result<(), String> {
log::info!("[app] restart_app invoked from frontend");
@@ -1327,7 +1354,6 @@ pub fn run() {
return Err("webview_apis bridge failed to start — aborting setup".into());
}
let _ = daemon_mode;
let core_handle =
core_process::CoreProcessHandle::new(core_process::default_core_port());
std::env::set_var("OPENHUMAN_CORE_RPC_URL", core_handle.rpc_url());
@@ -1352,13 +1378,23 @@ pub fn run() {
}
app.manage(core_handle.clone());
tauri::async_runtime::spawn(async move {
if let Err(err) = core_handle.ensure_running().await {
log::error!("[core] failed to start embedded core: {err}");
return;
}
log::info!("[core] embedded core ready");
});
// NOTE: the core is NOT auto-spawned here. The BootCheckGate UI
// calls `start_core_process` (Local mode) after the user picks a
// mode, which lets the frontend surface startup failures and
// version mismatches before the rest of the app mounts.
//
// In daemon mode (headless) we spawn immediately so the tray
// agent is available without waiting for a UI interaction.
if daemon_mode {
let core_handle_daemon = core_handle.clone();
tauri::async_runtime::spawn(async move {
if let Err(err) = core_handle_daemon.ensure_running().await {
log::error!("[core] daemon_mode — failed to start embedded core: {err}");
return;
}
log::info!("[core] daemon_mode — embedded core ready");
});
}
// Restore last-known window position+size before showing the
// window so the user's first paint after a restart-driven flow
@@ -1665,6 +1701,8 @@ pub fn run() {
download_app_update,
install_app_update,
restart_core_process,
start_core_process,
app_quit,
restart_app,
get_active_user_id,
schedule_cef_profile_purge,
+19 -16
View File
@@ -6,6 +6,7 @@ import { PersistGate } from 'redux-persist/integration/react';
import AppRoutes from './AppRoutes';
import AppUpdatePrompt from './components/AppUpdatePrompt';
import BootCheckGate from './components/BootCheckGate/BootCheckGate';
import BottomTabBar from './components/BottomTabBar';
import CommandProvider from './components/commands/CommandProvider';
import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
@@ -49,22 +50,24 @@ function App() {
)}>
<Provider store={store}>
<PersistGate loading={<PersistRehydrationScreen />} persistor={persistor}>
<CoreStateProvider>
<SocketProvider>
<ChatRuntimeProvider>
<Router>
<CommandProvider>
<ServiceBlockingGate>
<AppShell />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<AppUpdatePrompt />
</ServiceBlockingGate>
</CommandProvider>
</Router>
</ChatRuntimeProvider>
</SocketProvider>
</CoreStateProvider>
<BootCheckGate>
<CoreStateProvider>
<SocketProvider>
<ChatRuntimeProvider>
<Router>
<CommandProvider>
<ServiceBlockingGate>
<AppShell />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<AppUpdatePrompt />
</ServiceBlockingGate>
</CommandProvider>
</Router>
</ChatRuntimeProvider>
</SocketProvider>
</CoreStateProvider>
</BootCheckGate>
</PersistGate>
</Provider>
</Sentry.ErrorBoundary>
@@ -0,0 +1,548 @@
/**
* 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 { bootCheckTransport } from '../../services/bootCheckService';
import { clearCoreRpcUrlCache } from '../../services/coreRpcClient';
import { type CoreMode, resetCoreMode, setCoreMode } from '../../store/coreModeSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { storeRpcUrl } from '../../utils/configPersistence';
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;
}
function ModePicker({ onConfirm }: PickerProps) {
const [selected, setSelected] = useState<'local' | 'cloud'>('local');
const [cloudUrl, setCloudUrl] = useState('');
const [urlError, setUrlError] = useState<string | null>(null);
const handleContinue = () => {
if (selected === 'local') {
log('[boot-check] picker — user selected local mode');
onConfirm({ kind: 'local' });
return;
}
// Basic URL validation: must be http(s)
const trimmed = cloudUrl.trim();
if (!trimmed) {
setUrlError('Please enter a core URL.');
return;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
setUrlError('URL must start with http:// or https://');
return;
}
} catch {
setUrlError('Please enter a valid URL (e.g. https://core.example.com/rpc)');
return;
}
setUrlError(null);
log('[boot-check] picker — user selected cloud mode url=%s', trimmed);
onConfirm({ kind: 'cloud', url: trimmed });
};
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Choose core mode</h2>
<p className="mt-2 text-sm text-stone-300">
OpenHuman needs a running core to operate. Choose how you want to connect.
</p>
<div className="mt-5 flex flex-col gap-3">
{/* Local option */}
<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">Local (recommended)</div>
<div className="mt-0.5 text-xs text-stone-400">
Embedded core runs on this device fastest, no configuration required.
</div>
</button>
{/* Cloud option */}
<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">Cloud</div>
<div className="mt-0.5 text-xs text-stone-400">
Connect to a remote core at a custom URL.
</div>
</button>
{selected === 'cloud' && (
<div className="mt-1 flex flex-col gap-1">
<input
type="url"
placeholder="https://core.example.com/rpc"
value={cloudUrl}
onChange={e => {
setCloudUrl(e.target.value);
setUrlError(null);
}}
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>
<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">
Continue
</button>
</div>
</Panel>
);
}
// ---------------------------------------------------------------------------
// Spinner / checking
// ---------------------------------------------------------------------------
function CheckingScreen() {
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">Checking core</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) {
if (result.kind === 'match') return null;
if (result.kind === 'unreachable') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Could not reach core</h2>
<p className="mt-2 text-sm text-stone-300">
{result.reason || 'The core process is unreachable. Try switching to a different mode.'}
</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">
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">
Switch mode
</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">
Quit
</button>
</div>
</Panel>
);
}
if (result.kind === 'daemonDetected') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Legacy background core detected</h2>
<p className="mt-2 text-sm text-stone-300">
A separately-installed OpenHuman daemon is running on this device. It must be removed
before the embedded core can take over.
</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 ? 'Removing…' : 'Remove and continue'}
</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">
Switch mode
</button>
</div>
</Panel>
);
}
if (result.kind === 'outdatedLocal') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Local core needs a restart</h2>
<p className="mt-2 text-sm text-stone-300">
The local core version does not match this app build. Restarting it will load the correct
version.
</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 ? 'Restarting…' : 'Restart core'}
</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">
Switch mode
</button>
</div>
</Panel>
);
}
if (result.kind === 'outdatedCloud') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Cloud core needs an update</h2>
<p className="mt-2 text-sm text-stone-300">
The cloud core version does not match this app build. Run the core updater to resolve the
mismatch.
</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 ? 'Updating…' : 'Update cloud core'}
</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">
Switch mode
</button>
</div>
</Panel>
);
}
// noVersionMethod — treat like outdated, user picks which flavor of action
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Core version check failed</h2>
<p className="mt-2 text-sm text-stone-300">
The core is running but does not expose a version endpoint. It may be outdated. Restart or
update the core to continue.
</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 ? 'Working…' : 'Restart / update core'}
</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">
Switch mode
</button>
</div>
</Panel>
);
}
// ---------------------------------------------------------------------------
// Main gate
// ---------------------------------------------------------------------------
interface BootCheckGateProps {
children: React.ReactNode;
}
export default function BootCheckGate({ children }: BootCheckGateProps) {
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 : 'Unexpected boot-check error',
});
} 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.
// eslint-disable-next-line react-hooks/set-state-in-effect
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);
dispatch(setCoreMode(mode));
setPhase('checking');
},
[dispatch]
);
// ------------------------------------------------------------------
// Switch mode — reset to picker.
// ------------------------------------------------------------------
const handleSwitchMode = useCallback(() => {
log('[boot-check] gate — switch mode requested');
storeRpcUrl('');
clearCoreRpcUrlCache();
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 : 'Action failed — please try again.');
} 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}
/>
</>
);
}
@@ -0,0 +1,264 @@
/**
* Component tests for BootCheckGate.
*
* Strategy:
* - Mock runBootCheck so we control the result without real RPC/invoke.
* - Use a minimal Redux store that starts with coreMode.mode = 'unset'
* (picker) or set (check flow).
* - Assert rendered text and dispatched actions for each meaningful state.
*/
import { configureStore } from '@reduxjs/toolkit';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import coreModeReducer, { type CoreModeState } from '../../../store/coreModeSlice';
import BootCheckGate from '../BootCheckGate';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockRunBootCheck = vi.fn();
vi.mock('../../../lib/bootCheck', () => ({
runBootCheck: (...args: unknown[]) => mockRunBootCheck(...args),
}));
vi.mock('../../../services/coreRpcClient', () => ({
callCoreRpc: vi.fn(),
clearCoreRpcUrlCache: vi.fn(),
}));
vi.mock('../../../utils/configPersistence', () => ({
storeRpcUrl: vi.fn(),
isValidRpcUrl: vi.fn().mockReturnValue(true),
}));
// ---------------------------------------------------------------------------
// Store factory
// ---------------------------------------------------------------------------
function makeStore(initialMode?: CoreModeState['mode']) {
return configureStore({
reducer: { coreMode: coreModeReducer },
preloadedState: {
coreMode: { mode: initialMode ?? { kind: 'unset' } } satisfies CoreModeState,
},
});
}
function renderGate(store = makeStore()) {
return render(
<Provider store={store}>
<BootCheckGate>
<div data-testid="app-content">App Content</div>
</BootCheckGate>
</Provider>
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('BootCheckGate — picker (unset mode)', () => {
it('shows the mode picker when coreMode is unset', () => {
renderGate();
expect(screen.getByText('Choose core mode')).toBeInTheDocument();
expect(screen.getByText('Local (recommended)')).toBeInTheDocument();
expect(screen.getByText('Cloud')).toBeInTheDocument();
});
it('does NOT render children while in picker', () => {
renderGate();
expect(screen.queryByTestId('app-content')).not.toBeInTheDocument();
});
it('continues with local mode when user clicks Continue', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'match' });
renderGate();
// Local is pre-selected — just click Continue
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByTestId('app-content')).toBeInTheDocument();
});
});
it('shows URL input when user selects Cloud', () => {
renderGate();
fireEvent.click(screen.getByText('Cloud'));
expect(screen.getByPlaceholderText(/https:\/\/core\.example\.com/)).toBeInTheDocument();
});
it('shows URL validation error when cloud URL is empty', () => {
renderGate();
fireEvent.click(screen.getByText('Cloud'));
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(screen.getByText('Please enter a core URL.')).toBeInTheDocument();
});
it('shows URL validation error for non-http URL', () => {
renderGate();
fireEvent.click(screen.getByText('Cloud'));
const input = screen.getByPlaceholderText(/https:\/\/core\.example\.com/);
fireEvent.change(input, { target: { value: 'ftp://invalid' } });
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(screen.getByText(/must start with http/)).toBeInTheDocument();
});
});
describe('BootCheckGate — checking state', () => {
it('shows checking spinner while boot check is in flight', async () => {
// Never resolves during this test
mockRunBootCheck.mockImplementation(() => new Promise(() => {}));
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByText('Checking core…')).toBeInTheDocument();
});
});
});
describe('BootCheckGate — match result', () => {
it('renders children once boot check returns match', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'match' });
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByTestId('app-content')).toBeInTheDocument();
});
});
});
describe('BootCheckGate — daemonDetected', () => {
it('shows daemon detection screen', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'daemonDetected' });
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByText('Legacy background core detected')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove and continue' })).toBeInTheDocument();
});
});
});
describe('BootCheckGate — outdatedLocal', () => {
it('shows outdated local screen', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'outdatedLocal' });
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByText('Local core needs a restart')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Restart core' })).toBeInTheDocument();
});
});
});
describe('BootCheckGate — outdatedCloud', () => {
it('shows outdated cloud screen', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'outdatedCloud' });
const store = makeStore({ kind: 'cloud', url: 'https://core.example.com/rpc' });
// Trigger the check by rendering with an already-set mode
mockRunBootCheck.mockResolvedValue({ kind: 'outdatedCloud' });
render(
<Provider store={store}>
<BootCheckGate>
<div data-testid="app-content">App Content</div>
</BootCheckGate>
</Provider>
);
await waitFor(() => {
expect(screen.getByText('Cloud core needs an update')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Update cloud core' })).toBeInTheDocument();
});
});
});
describe('BootCheckGate — noVersionMethod', () => {
it('shows no version method screen', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'noVersionMethod' });
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByText('Core version check failed')).toBeInTheDocument();
});
});
});
describe('BootCheckGate — unreachable', () => {
it('shows unreachable screen with quit and switch mode buttons', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'unreachable', reason: 'Connection refused' });
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByText('Could not reach core')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Quit' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Switch mode' })).toBeInTheDocument();
});
});
it('returns to picker when Switch mode is clicked', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'unreachable', reason: 'Connection refused' });
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Switch mode' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Switch mode' }));
await waitFor(() => {
expect(screen.getByText('Choose core mode')).toBeInTheDocument();
});
});
});
describe('BootCheckGate — pre-set mode (subsequent launches)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('skips picker and goes directly to checking when mode is already set', async () => {
mockRunBootCheck.mockImplementation(() => new Promise(() => {}));
const store = makeStore({ kind: 'local' });
render(
<Provider store={store}>
<BootCheckGate>
<div data-testid="app-content">App Content</div>
</BootCheckGate>
</Provider>
);
await waitFor(() => {
expect(screen.getByText('Checking core…')).toBeInTheDocument();
});
expect(screen.queryByText('Choose core mode')).not.toBeInTheDocument();
});
});
+301
View File
@@ -0,0 +1,301 @@
/**
* Unit tests for the boot-check orchestrator.
*
* Uses the injectable transport so no real Tauri IPC or HTTP calls are made.
*/
import { describe, expect, it, vi } from 'vitest';
import { type BootCheckResult, type BootCheckTransport, runBootCheck } from './index';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a minimal transport stub for tests. */
function makeTransport(overrides?: Partial<BootCheckTransport>): BootCheckTransport {
return { callRpc: vi.fn(), invokeCmd: vi.fn().mockResolvedValue(undefined), ...overrides };
}
/**
* Build a callRpc mock that answers specific methods.
*
* `responses` maps method-name → resolved value (or Error to reject with).
*/
function rpcResponder(responses: Record<string, unknown>): BootCheckTransport['callRpc'] {
return vi.fn(async (method: string) => {
if (method in responses) {
const val = responses[method];
if (val instanceof Error) throw val;
return val;
}
throw new Error(`Unexpected RPC call: ${method}`);
}) as BootCheckTransport['callRpc'];
}
// ---------------------------------------------------------------------------
// Local mode tests
// ---------------------------------------------------------------------------
describe('runBootCheck — local mode', () => {
it('returns match when ping succeeds, no daemon, versions match', async () => {
const appVersion = (await import('../../utils/config')).APP_VERSION;
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': { version_info: { version: appVersion } },
}),
});
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result).toEqual({ kind: 'match' });
});
it('returns daemonDetected when service_status shows installed=true', async () => {
const appVersion = (await import('../../utils/config')).APP_VERSION;
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'openhuman.service_status': { installed: true, running: false },
'openhuman.update_version': { version_info: { version: appVersion } },
}),
});
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result).toEqual({ kind: 'daemonDetected' });
});
it('returns daemonDetected when service_status shows running=true', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'openhuman.service_status': { installed: false, running: true },
'openhuman.update_version': { version_info: { version: 'x' } },
}),
});
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result).toEqual({ kind: 'daemonDetected' });
});
it('returns outdatedLocal when core version differs from app version', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': { version_info: { version: '0.0.0-different' } },
}),
});
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result).toEqual({ kind: 'outdatedLocal' });
});
it('returns noVersionMethod when update_version returns -32601', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': new Error('JSON-RPC error -32601 Method not found'),
}),
});
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result).toEqual({ kind: 'noVersionMethod' });
});
it('returns noVersionMethod on "method not found" text variant', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': new Error('method not found'),
}),
});
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result).toEqual({ kind: 'noVersionMethod' });
});
it('returns unreachable when start_core_process invoke fails', async () => {
const transport = makeTransport({
invokeCmd: vi.fn().mockRejectedValue(new Error('process launch failed')),
callRpc: vi.fn(),
});
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result.kind).toBe('unreachable');
});
it('returns unreachable when ping never succeeds', async () => {
// Provide a fast-cycling callRpc that always fails ping
const callRpc = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
const transport = makeTransport({ callRpc });
// Override setTimeout to avoid real waiting — tick forward immediately
vi.useFakeTimers();
const promise = runBootCheck({ kind: 'local' }, transport);
// Drain all pending micro-tasks + setTimeout callbacks
await vi.runAllTimersAsync();
const result = await promise;
vi.useRealTimers();
expect(result.kind).toBe('unreachable');
});
});
// ---------------------------------------------------------------------------
// Cloud mode tests
// ---------------------------------------------------------------------------
describe('runBootCheck — cloud mode', () => {
it('returns match when cloud core version matches', async () => {
const appVersion = (await import('../../utils/config')).APP_VERSION;
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.update_version': { version_info: { version: appVersion } },
}),
});
const result = await runBootCheck(
{ kind: 'cloud', url: 'https://core.example.com/rpc' },
transport
);
expect(result).toEqual({ kind: 'match' });
});
it('returns outdatedCloud when version differs', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.update_version': { version_info: { version: '0.0.0-old' } },
}),
});
const result = await runBootCheck(
{ kind: 'cloud', url: 'https://core.example.com/rpc' },
transport
);
expect(result).toEqual({ kind: 'outdatedCloud' });
});
it('returns noVersionMethod when cloud core returns -32601', async () => {
const transport = makeTransport({
callRpc: rpcResponder({ 'openhuman.update_version': new Error('-32601 Method not found') }),
});
const result = await runBootCheck(
{ kind: 'cloud', url: 'https://core.example.com/rpc' },
transport
);
expect(result).toEqual({ kind: 'noVersionMethod' });
});
it('returns unreachable on network failure', async () => {
const transport = makeTransport({
callRpc: vi.fn().mockRejectedValue(new Error('Network unreachable')),
});
const result = await runBootCheck(
{ kind: 'cloud', url: 'https://unreachable.example.com/rpc' },
transport
);
expect(result.kind).toBe('unreachable');
});
});
// ---------------------------------------------------------------------------
// Unset mode guard
// ---------------------------------------------------------------------------
describe('runBootCheck — unset mode', () => {
it('returns unreachable when called with unset mode', async () => {
const transport = makeTransport();
const result: BootCheckResult = await runBootCheck({ kind: 'unset' }, transport);
expect(result.kind).toBe('unreachable');
});
});
// ---------------------------------------------------------------------------
// Edge-case branches surfaced by the diff-coverage gate
// ---------------------------------------------------------------------------
describe('runBootCheck — error and edge branches', () => {
it('treats service_status throw as "no daemon" and continues', async () => {
const appVersion = (await import('../../utils/config')).APP_VERSION;
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'openhuman.service_status': new Error('rpc transport blew up'),
'openhuman.update_version': { version_info: { version: appVersion } },
}),
});
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result.kind).toBe('match');
});
it('treats empty version_info.version as outdatedLocal', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': { version_info: { version: '' } },
}),
});
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result.kind).toBe('outdatedLocal');
});
it('returns unreachable when start_core_process Tauri command fails', async () => {
const transport: BootCheckTransport = {
callRpc: vi.fn(),
invokeCmd: vi.fn().mockRejectedValue(new Error('tauri command not registered')),
};
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result.kind).toBe('unreachable');
if (result.kind === 'unreachable') {
expect(result.reason).toContain('Failed to start local core');
}
});
it('returns unreachable when local version check throws repeatedly', async () => {
let pingCalls = 0;
const transport: BootCheckTransport = {
callRpc: vi.fn(async (method: string) => {
if (method === 'openhuman.ping') {
pingCalls += 1;
if (pingCalls === 1) return {};
throw new Error('subsequent failure');
}
if (method === 'openhuman.service_status') {
return { installed: false, running: false };
}
if (method === 'openhuman.update_version') {
// Generic transport error (no -32601), should map to 'unreachable'.
throw new Error('connection reset');
}
throw new Error(`Unexpected RPC: ${method}`);
}) as BootCheckTransport['callRpc'],
invokeCmd: vi.fn().mockResolvedValue(undefined),
};
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result.kind).toBe('unreachable');
});
it('refuses to persist an invalid cloud URL', async () => {
const transport = makeTransport();
const result = await runBootCheck({ kind: 'cloud', url: 'not a url' }, transport);
expect(result.kind).toBe('unreachable');
if (result.kind === 'unreachable') {
expect(result.reason).toContain('valid URL');
}
expect(transport.callRpc).not.toHaveBeenCalled();
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* Boot-check orchestrator.
*
* Runs before the main app mounts to verify that the active core mode is
* reachable and version-compatible. The caller (BootCheckGate) supplies the
* current CoreMode from Redux and renders the appropriate recovery UI based on
* the returned BootCheckResult.
*
* Design constraints:
* - Pure logic — no React, no Redux imports.
* - Injectable transport (callRpc / invokeCmd) for hermetic unit tests.
* - All branches emit [boot-check] prefixed debug logs.
*/
import debug from 'debug';
import { clearCoreRpcUrlCache } from '../../services/coreRpcClient';
import type { CoreMode } from '../../store/coreModeSlice';
import { APP_VERSION } from '../../utils/config';
import { storeRpcUrl } from '../../utils/configPersistence';
const log = debug('boot-check');
const logError = debug('boot-check:error');
// ---------------------------------------------------------------------------
// Result types
// ---------------------------------------------------------------------------
export type BootCheckResult =
| { kind: 'match' }
| { kind: 'daemonDetected' }
| { kind: 'outdatedLocal' }
| { kind: 'outdatedCloud' }
| { kind: 'noVersionMethod' }
| { kind: 'unreachable'; reason: string };
// ---------------------------------------------------------------------------
// Transport interface (injectable for tests)
// ---------------------------------------------------------------------------
export interface BootCheckTransport {
/** Call a JSON-RPC method on the active core endpoint. */
callRpc: <T>(method: string, params?: Record<string, unknown>) => Promise<T>;
/** Invoke a Tauri command. */
invokeCmd: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
}
// The production transport lives in `app/src/services/bootCheckService.ts`
// so this module stays free of direct Tauri IPC / RPC imports per the
// project's IPC localization guideline.
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Returns true if err looks like a JSON-RPC -32601 "Method not found". */
function isMethodNotFound(err: unknown): boolean {
if (!err) return false;
const msg = err instanceof Error ? err.message : String(err);
return (
msg.includes('-32601') ||
msg.toLowerCase().includes('method not found') ||
msg.toLowerCase().includes('methodnotfound')
);
}
/**
* Poll `openhuman.ping` with exponential back-off until the core responds or
* we exhaust the budget.
*
* Returns true when the core is reachable, false on timeout.
*/
async function waitForCore(
callRpc: BootCheckTransport['callRpc'],
maxMs = 10_000
): Promise<boolean> {
const startedAt = Date.now();
let delay = 200;
while (Date.now() - startedAt < maxMs) {
const elapsedAtStart = Date.now() - startedAt;
try {
log('[boot-check] ping attempt elapsed=%dms', elapsedAtStart);
await callRpc('openhuman.ping', {});
log('[boot-check] ping succeeded elapsed=%dms', elapsedAtStart);
return true;
} catch {
const remaining = maxMs - (Date.now() - startedAt);
if (remaining <= 0) break;
const sleepMs = Math.min(delay, remaining);
await new Promise(r => setTimeout(r, sleepMs));
delay = Math.min(delay * 2, 1000);
}
}
logError('[boot-check] ping timed out after %dms', Date.now() - startedAt);
return false;
}
/**
* Check `openhuman.service_status`. Returns true when a separate
* background daemon (distinct from our embedded core) is detected.
*/
async function isDaemonRunning(callRpc: BootCheckTransport['callRpc']): Promise<boolean> {
try {
const result = await callRpc<{ installed?: boolean; running?: boolean }>(
'openhuman.service_status',
{}
);
const detected = Boolean(result?.installed || result?.running);
log(
'[boot-check] service_status detected=%s installed=%s running=%s',
detected,
result?.installed,
result?.running
);
return detected;
} catch (err) {
log('[boot-check] service_status error (non-fatal): %o', err);
return false;
}
}
/**
* Fetch the running core version and compare it to the app build version.
*
* Returns:
* 'match' — versions are equal
* 'outdated' — version mismatch
* 'noVersionMethod' — core responded but doesn't know the method
* 'unreachable' — network-level failure
*/
type VersionCheckResult = 'match' | 'outdated' | 'noVersionMethod' | 'unreachable';
async function checkVersion(callRpc: BootCheckTransport['callRpc']): Promise<VersionCheckResult> {
try {
const result = await callRpc<{ version_info?: { version?: string } }>(
'openhuman.update_version',
{}
);
const coreVersion = result?.version_info?.version ?? '';
log('[boot-check] version_check app=%s core=%s', APP_VERSION, coreVersion);
if (!coreVersion) {
// Response received but no version field — treat like outdated.
logError('[boot-check] update_version returned no version field');
return 'outdated';
}
return coreVersion === APP_VERSION ? 'match' : 'outdated';
} catch (err) {
if (isMethodNotFound(err)) {
log('[boot-check] update_version method not found (-32601)');
return 'noVersionMethod';
}
logError('[boot-check] update_version call failed: %o', err);
return 'unreachable';
}
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
/**
* Run the boot-check for a given core mode.
*
* Local mode:
* 1. Invoke `start_core_process` Tauri command to spawn the embedded core.
* 2. Poll `openhuman.ping` until reachable (≤10 s).
* 3. Check for a legacy daemon via `service_status`.
* 4. Version-check via `update_version`.
*
* Cloud mode:
* 1. Store the URL override and bust the RPC URL cache.
* 2. Version-check via `update_version`.
*/
export async function runBootCheck(
mode: CoreMode,
transport: BootCheckTransport
): Promise<BootCheckResult> {
const { callRpc, invokeCmd } = transport;
if (mode.kind === 'unset') {
// Should never be called with unset — gate should show picker instead.
logError('[boot-check] runBootCheck called with mode=unset (bug in caller)');
return { kind: 'unreachable', reason: 'No core mode selected' };
}
// ------------------------------------------------------------------
// Local mode
// ------------------------------------------------------------------
if (mode.kind === 'local') {
log('[boot-check] local mode — starting core process');
try {
await invokeCmd<void>('start_core_process', {});
log('[boot-check] start_core_process invoked successfully');
} catch (err) {
logError('[boot-check] start_core_process failed: %o', err);
return {
kind: 'unreachable',
reason: `Failed to start local core: ${err instanceof Error ? err.message : String(err)}`,
};
}
// Wait for the embedded core to be reachable.
const reachable = await waitForCore(callRpc);
if (!reachable) {
logError('[boot-check] local core unreachable after retries');
return { kind: 'unreachable', reason: 'Local core did not respond in time' };
}
// Check for a legacy background daemon that should be removed.
const daemonDetected = await isDaemonRunning(callRpc);
if (daemonDetected) {
log('[boot-check] legacy daemon detected');
return { kind: 'daemonDetected' };
}
// Version check.
const versionResult = await checkVersion(callRpc);
if (versionResult === 'match') {
log('[boot-check] local mode — version match, boot complete');
return { kind: 'match' };
}
if (versionResult === 'noVersionMethod') {
log('[boot-check] local mode — noVersionMethod');
return { kind: 'noVersionMethod' };
}
if (versionResult === 'unreachable') {
logError('[boot-check] local mode — version check unreachable');
return { kind: 'unreachable', reason: 'Could not reach core version endpoint' };
}
log('[boot-check] local mode — version outdated');
return { kind: 'outdatedLocal' };
}
// ------------------------------------------------------------------
// Cloud mode
// ------------------------------------------------------------------
let safeUrl: string | null = null;
let safeOrigin: string | null = null;
try {
const parsed = new URL(mode.url);
safeOrigin = parsed.origin;
safeUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
} catch {
// safeUrl/safeOrigin stay null
}
if (!safeUrl) {
logError('[boot-check] cloud mode — invalid URL, refusing to persist');
return { kind: 'unreachable', reason: 'Configured cloud URL is not a valid URL' };
}
log('[boot-check] cloud mode — origin=%s', safeOrigin ?? '<invalid-origin>');
storeRpcUrl(safeUrl);
clearCoreRpcUrlCache();
log('[boot-check] cloud RPC URL stored and cache cleared');
const versionResult = await checkVersion(callRpc);
if (versionResult === 'match') {
log('[boot-check] cloud mode — version match, boot complete');
return { kind: 'match' };
}
if (versionResult === 'noVersionMethod') {
log('[boot-check] cloud mode — noVersionMethod');
return { kind: 'noVersionMethod' };
}
if (versionResult === 'unreachable') {
logError('[boot-check] cloud mode — core unreachable');
return { kind: 'unreachable', reason: 'Could not reach cloud core' };
}
log('[boot-check] cloud mode — version outdated');
return { kind: 'outdatedCloud' };
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Unit tests for the boot-check service-backed transport.
*
* Validates that bootCheckTransport delegates correctly to callCoreRpc and
* @tauri-apps/api/core invoke, since these are the production wiring used by
* BootCheckGate.
*/
import { describe, expect, it, vi } from 'vitest';
const callCoreRpcMock = vi.fn();
vi.mock('./coreRpcClient', () => ({ callCoreRpc: (req: unknown) => callCoreRpcMock(req) }));
const invokeMock = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({
invoke: (cmd: string, args?: Record<string, unknown>) => invokeMock(cmd, args),
}));
describe('bootCheckTransport', () => {
it('callRpc forwards method+params to callCoreRpc', async () => {
callCoreRpcMock.mockResolvedValueOnce({ ok: true });
const { bootCheckTransport } = await import('./bootCheckService');
const result = await bootCheckTransport.callRpc<{ ok: boolean }>('openhuman.ping', { x: 1 });
expect(result).toEqual({ ok: true });
expect(callCoreRpcMock).toHaveBeenCalledWith({ method: 'openhuman.ping', params: { x: 1 } });
});
it('invokeCmd forwards cmd+args to Tauri invoke', async () => {
invokeMock.mockResolvedValueOnce(42);
const { bootCheckTransport } = await import('./bootCheckService');
const result = await bootCheckTransport.invokeCmd<number>('start_core_process', {});
expect(result).toBe(42);
expect(invokeMock).toHaveBeenCalledWith('start_core_process', {});
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* Service-backed transport for the boot-check orchestrator.
*
* The orchestrator (`app/src/lib/bootCheck/`) keeps all I/O behind a
* `BootCheckTransport` interface so it can be unit-tested without Tauri.
* This module is the production implementation: it owns the direct
* `invoke` and `callCoreRpc` references so IPC stays localized to
* `app/src/services/` per project conventions.
*/
import { invoke } from '@tauri-apps/api/core';
import type { BootCheckTransport } from '../lib/bootCheck';
import { callCoreRpc } from './coreRpcClient';
async function callRpc<T>(method: string, params?: Record<string, unknown>): Promise<T> {
return callCoreRpc<T>({ method, params });
}
async function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
return invoke<T>(cmd, args);
}
export const bootCheckTransport: BootCheckTransport = { callRpc, invokeCmd };
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import reducer, { resetCoreMode, setCoreMode } from './coreModeSlice';
describe('coreModeSlice', () => {
it('initialises to unset', () => {
const state = reducer(undefined, { type: '@@INIT' });
expect(state.mode).toEqual({ kind: 'unset' });
});
it('sets local mode', () => {
const state = reducer(undefined, setCoreMode({ kind: 'local' }));
expect(state.mode).toEqual({ kind: 'local' });
});
it('sets cloud mode with url', () => {
const state = reducer(
undefined,
setCoreMode({ kind: 'cloud', url: 'https://core.example.com/rpc' })
);
expect(state.mode).toEqual({ kind: 'cloud', url: 'https://core.example.com/rpc' });
});
it('resets to unset', () => {
const withLocal = reducer(undefined, setCoreMode({ kind: 'local' }));
const reset = reducer(withLocal, resetCoreMode());
expect(reset.mode).toEqual({ kind: 'unset' });
});
it('overwrites previous mode on setCoreMode', () => {
const withCloud = reducer(
undefined,
setCoreMode({ kind: 'cloud', url: 'https://old.example.com' })
);
const withLocal = reducer(withCloud, setCoreMode({ kind: 'local' }));
expect(withLocal.mode).toEqual({ kind: 'local' });
});
it('slice name is coreMode', () => {
// Structural assertion: the key used by redux-persist must match the
// persist config key declared in store/index.ts.
expect(setCoreMode.type).toMatch(/^coreMode\//);
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* coreModeSlice — persists the user's chosen core connection mode across
* launches. Two kinds of mode exist:
*
* local — embedded in-process core; spawned by the Tauri shell on demand.
* cloud — user-supplied HTTP(S) URL to a remote core RPC endpoint.
*
* `unset` is the initial value shown to first-time users; the BootCheckGate
* forces the user to pick before the rest of the app mounts. After that the
* value is persisted in plain localStorage (NOT user-scoped storage) because
* it is pre-login and not tied to any particular user identity.
*/
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type CoreMode = { kind: 'unset' } | { kind: 'local' } | { kind: 'cloud'; url: string };
export interface CoreModeState {
mode: CoreMode;
}
const initialState: CoreModeState = { mode: { kind: 'unset' } };
const coreModeSlice = createSlice({
name: 'coreMode',
initialState,
reducers: {
/**
* Set the active core mode. Dispatched by the BootCheckGate picker when
* the user clicks "Continue".
*/
setCoreMode(state, action: PayloadAction<CoreMode>) {
state.mode = action.payload;
},
/**
* Reset back to `unset` so the picker re-appears on the next render.
* Dispatched by "Switch mode" affordances inside the gate.
*/
resetCoreMode(state) {
state.mode = { kind: 'unset' };
},
},
});
export const { setCoreMode, resetCoreMode } = coreModeSlice.actions;
export default coreModeSlice.reducer;
+8
View File
@@ -10,11 +10,13 @@ import {
REGISTER,
REHYDRATE,
} from 'redux-persist';
import defaultStorage from 'redux-persist/lib/storage';
import { IS_DEV } from '../utils/config';
import accountsReducer from './accountsSlice';
import channelConnectionsReducer from './channelConnectionsSlice';
import chatRuntimeReducer from './chatRuntimeSlice';
import coreModeReducer from './coreModeSlice';
import notificationReducer from './notificationSlice';
import providerSurfacesReducer from './providerSurfaceSlice';
import socketReducer from './socketSlice';
@@ -26,6 +28,11 @@ import { userScopedStorage } from './userScopedStorage';
// that leaks across users on logout/login (#900).
const storage = userScopedStorage;
// coreMode is pre-login and not user-scoped — use plain localStorage so the
// setting survives across user switches without leaking per-user state.
const coreModePersistConfig = { key: 'coreMode', storage: defaultStorage, whitelist: ['mode'] };
const persistedCoreModeReducer = persistReducer(coreModePersistConfig, coreModeReducer);
const channelConnectionsPersistConfig = {
key: 'channelConnections',
storage,
@@ -68,6 +75,7 @@ export const store = configureStore({
accounts: persistedAccountsReducer,
notifications: persistedNotificationReducer,
providerSurfaces: providerSurfacesReducer,
coreMode: persistedCoreModeReducer,
},
middleware: getDefaultMiddleware => {
const middleware = getDefaultMiddleware({