diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index ffbe251e5..d9ed7564a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -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) -> Result<(), String> { + log::info!("[app] app_quit: quit requested from frontend"); + app.exit(0); + Ok(()) +} + #[tauri::command] async fn restart_app(app: tauri::AppHandle) -> 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, diff --git a/app/src/App.tsx b/app/src/App.tsx index 86d7300d9..aa8e9cb28 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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() { )}> } persistor={persistor}> - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx new file mode 100644 index 000000000..91ea03b93 --- /dev/null +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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(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 ( + +

Choose core mode

+

+ OpenHuman needs a running core to operate. Choose how you want to connect. +

+ +
+ {/* Local option */} + + + {/* Cloud option */} + + + {selected === 'cloud' && ( +
+ { + 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 &&

{urlError}

} +
+ )} +
+ +
+ +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Spinner / checking +// --------------------------------------------------------------------------- + +function CheckingScreen() { + return ( + +
+
+

Checking core…

+
+ + ); +} + +// --------------------------------------------------------------------------- +// 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 ( + +

Could not reach core

+

+ {result.reason || 'The core process is unreachable. Try switching to a different mode.'} +

+ {actionError &&

{actionError}

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

Legacy background core detected

+

+ A separately-installed OpenHuman daemon is running on this device. It must be removed + before the embedded core can take over. +

+ {actionError &&

{actionError}

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

Local core needs a restart

+

+ The local core version does not match this app build. Restarting it will load the correct + version. +

+ {actionError &&

{actionError}

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

Cloud core needs an update

+

+ The cloud core version does not match this app build. Run the core updater to resolve the + mismatch. +

+ {actionError &&

{actionError}

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

Core version check failed

+

+ The core is running but does not expose a version endpoint. It may be outdated. Restart or + update the core to continue. +

+ {actionError &&

{actionError}

} +
+ + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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(() => + coreMode.kind === 'unset' ? 'picker' : 'checking' + ); + const [result, setResult] = useState(null); + const [actionBusy, setActionBusy] = useState(false); + const [actionError, setActionError] = useState(null); + + // Prevent concurrent or stale runs. + const runningRef = useRef(false); + + // Production transport lives in services/bootCheckService so direct + // Tauri/RPC imports stay localized there. + const transport = bootCheckTransport; + + const runCheck = useCallback( + async (mode: CoreMode) => { + if (runningRef.current) { + log('[boot-check] gate — check already running, skipping duplicate'); + return; + } + runningRef.current = true; + setPhase('checking'); + setResult(null); + setActionError(null); + log('[boot-check] gate — starting check mode=%s', mode.kind); + + try { + const checkResult = await runBootCheck(mode, transport); + log('[boot-check] gate — check result=%s', checkResult.kind); + + if (checkResult.kind === 'match') { + // Gate resolves — render children. + setPhase('result'); + setResult(checkResult); + } else { + setPhase('result'); + setResult(checkResult); + } + } catch (err) { + logError('[boot-check] gate — unexpected error: %o', err); + setPhase('result'); + setResult({ + kind: 'unreachable', + reason: err instanceof Error ? err.message : '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 ( + <> + + + ); + } + + // Check in flight. + if (phase === 'checking') { + return ; + } + + // Match — pass through. + if (result?.kind === 'match') { + return <>{children}; + } + + // Non-match result. + return ( + <> + + + ); +} diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx new file mode 100644 index 000000000..e3b935e59 --- /dev/null +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -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( + + +
App Content
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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( + + +
App Content
+
+
+ ); + + 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( + + +
App Content
+
+
+ ); + + await waitFor(() => { + expect(screen.getByText('Checking core…')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Choose core mode')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/lib/bootCheck/index.test.ts b/app/src/lib/bootCheck/index.test.ts new file mode 100644 index 000000000..bdaef89f8 --- /dev/null +++ b/app/src/lib/bootCheck/index.test.ts @@ -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 { + 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): 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(); + }); +}); diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts new file mode 100644 index 000000000..03b2cb7ba --- /dev/null +++ b/app/src/lib/bootCheck/index.ts @@ -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: (method: string, params?: Record) => Promise; + /** Invoke a Tauri command. */ + invokeCmd: (cmd: string, args?: Record) => Promise; +} + +// 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 { + 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 { + 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 { + 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 { + 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('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 ?? ''); + 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' }; +} diff --git a/app/src/services/bootCheckService.test.ts b/app/src/services/bootCheckService.test.ts new file mode 100644 index 000000000..7a617e98b --- /dev/null +++ b/app/src/services/bootCheckService.test.ts @@ -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) => 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('start_core_process', {}); + + expect(result).toBe(42); + expect(invokeMock).toHaveBeenCalledWith('start_core_process', {}); + }); +}); diff --git a/app/src/services/bootCheckService.ts b/app/src/services/bootCheckService.ts new file mode 100644 index 000000000..d014eb2c5 --- /dev/null +++ b/app/src/services/bootCheckService.ts @@ -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(method: string, params?: Record): Promise { + return callCoreRpc({ method, params }); +} + +async function invokeCmd(cmd: string, args?: Record): Promise { + return invoke(cmd, args); +} + +export const bootCheckTransport: BootCheckTransport = { callRpc, invokeCmd }; diff --git a/app/src/store/coreModeSlice.test.ts b/app/src/store/coreModeSlice.test.ts new file mode 100644 index 000000000..b58be0787 --- /dev/null +++ b/app/src/store/coreModeSlice.test.ts @@ -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\//); + }); +}); diff --git a/app/src/store/coreModeSlice.ts b/app/src/store/coreModeSlice.ts new file mode 100644 index 000000000..a013019c3 --- /dev/null +++ b/app/src/store/coreModeSlice.ts @@ -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) { + 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; diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 3592ff9d6..2f725ccb0 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -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({