mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
feat(boot): gate launch on core mode pick + version check (#1316)
This commit is contained in:
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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', {});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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\//);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user