diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index cb379f15fe..a02991d17f 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -6,6 +6,20 @@ description = "Core RPC URL, sidecar restart, dictation hotkey, webview-account, allow = [ "core_rpc_url", "restart_core_process", + # `restart_app` triggers `app.restart()` so CEF re-initializes against + # the active user's `users//cef` profile after an identity flip + # (#900). Without this allow entry, the invoke is silently denied by + # Tauri capabilities and webviews keep the prior user's third-party + # cookies. + "restart_app", + "schedule_cef_profile_purge", + # `get_active_user_id` reads `~/.openhuman/active_user.toml` so the + # frontend can prime `userScopedStorage` from the Rust source of truth + # BEFORE redux-persist hydrates — the prior `localStorage`-only seed + # was bound to the per-user CEF profile dir and went stale across + # restart-driven flips, causing a false re-flip and restart loop on + # every login. (#900) + "get_active_user_id", "service_install_direct", "service_start_direct", "service_stop_direct", diff --git a/app/src-tauri/src/cef_profile.rs b/app/src-tauri/src/cef_profile.rs index 37eba115c2..71935b2a59 100644 --- a/app/src-tauri/src/cef_profile.rs +++ b/app/src-tauri/src/cef_profile.rs @@ -36,7 +36,7 @@ fn default_root_dir_name() -> &'static str { } } -fn default_root_openhuman_dir() -> Result { +pub fn default_root_openhuman_dir() -> Result { if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") { let trimmed = workspace.trim(); if !trimmed.is_empty() { @@ -50,7 +50,7 @@ fn default_root_openhuman_dir() -> Result { Ok(home.join(default_root_dir_name())) } -fn read_active_user_id(default_openhuman_dir: &Path) -> Option { +pub fn read_active_user_id(default_openhuman_dir: &Path) -> Option { let path = default_openhuman_dir.join(ACTIVE_USER_STATE_FILE); let contents = std::fs::read_to_string(path).ok()?; let state: ActiveUserState = toml::from_str(&contents).ok()?; @@ -297,6 +297,33 @@ pub fn prepare_process_cache_path() -> Result { user_id, cache_dir.display() ); + + // When a real user is active, the pre-login `users/local/cef` bucket is + // stale third-party state captured during cold-bootstrap (before + // `active_user.toml` existed) — e.g. a Slack/WhatsApp tile added on a + // fresh install while the process was still running on the `local` + // fallback path. If we don't sweep it, those cookies leak into the + // first user's session via webview pre-warm and across users when the + // pre-login bucket is reused on subsequent fresh installs. Drop it + // synchronously here, before CEF init, so it's safe to delete. (#900) + if user_id != PRE_LOGIN_USER_ID { + if let Ok(local_cef) = cache_dir_for_user(&default_openhuman_dir, PRE_LOGIN_USER_ID) { + if local_cef.exists() { + match std::fs::remove_dir_all(&local_cef) { + Ok(()) => log::info!( + "[cef-profile] purged stale pre-login CEF cache path={}", + local_cef.display() + ), + Err(error) => log::warn!( + "[cef-profile] failed to purge stale pre-login CEF cache path={} error={}", + local_cef.display(), + error + ), + } + } + } + } + Ok(cache_dir) } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d87805af11..cfff73ae4b 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ mod telegram_scanner; mod webview_accounts; mod webview_apis; mod whatsapp_scanner; +mod window_state; use std::sync::Mutex; @@ -318,9 +319,38 @@ async fn restart_core_process( #[tauri::command] async fn restart_app(app: tauri::AppHandle) -> Result<(), String> { log::info!("[app] restart_app invoked from frontend"); + // Persist main-window geometry and hide the window before exit so + // the macOS WindowServer doesn't briefly black-out the desktop layer + // on the (now defunct) display when the focused app dies, and so + // the new process can land its window on the same display+position + // the user had it on. (#900 secondary fixes) + if let Some(window) = app.get_webview_window("main") { + window_state::save_main(&window); + if let Err(err) = window.hide() { + log::warn!("[app] hide main window before restart failed: {err}"); + } + } app.restart(); } +/// Read the authoritative active user id from `active_user.toml` so the +/// frontend can seed `userScopedStorage` BEFORE redux-persist hydrates. +/// +/// The previous frontend-only seed (a `localStorage` key) was bound to the +/// per-user CEF profile dir, so on every restart-driven user flip the new +/// process read whatever value the new profile's `localStorage` happened to +/// hold from a prior session — usually stale, triggering a false re-flip and +/// a restart loop. The Rust core writes `active_user.toml` atomically as part +/// of `auth_store_session`, so it's the only profile-independent source of +/// truth available to the UI at boot. Reuses +/// `cef_profile::default_root_openhuman_dir()` so the lookup honors +/// `OPENHUMAN_WORKSPACE` overrides used in test harnesses. (#900) +#[tauri::command] +fn get_active_user_id() -> Result, String> { + let dir = cef_profile::default_root_openhuman_dir()?; + Ok(cef_profile::read_active_user_id(&dir)) +} + #[tauri::command] async fn schedule_cef_profile_purge(user_id: Option) -> Result { let queued = cef_profile::queue_profile_purge_for_user(user_id.as_deref())?; @@ -962,6 +992,24 @@ pub fn run() { } }); + // Restore last-known window position+size before showing the + // window so the user's first paint after a restart-driven flow + // (#900 identity flip) lands on the same display they used, + // not back at the default centered initial size on the + // primary monitor. `tauri.conf.json` ships `visible: false` + // / `center: false` for the main window so the placement + // happens before the first paint and there's no jump. + if let Some(window) = app.get_webview_window("main") { + if !window_state::restore_main(&window) { + window_state::center_main(&window); + } + if !daemon_mode { + if let Err(err) = window.show() { + log::warn!("[window-state] show main window failed: {err}"); + } + } + } + if daemon_mode { if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); @@ -1299,6 +1347,7 @@ pub fn run() { apply_app_update, restart_core_process, restart_app, + get_active_user_id, schedule_cef_profile_purge, service_install_direct, service_start_direct, diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs index 66a28a794f..85f0a4e33d 100644 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ b/app/src-tauri/src/webview_accounts/mod.rs @@ -612,27 +612,27 @@ fn teardown_account_scanners(app: &AppHandle, account_id: &str) { { let registry = registry.inner().clone(); let acct = account_id.to_string(); - tokio::spawn(async move { registry.forget(&acct).await }); + tauri::async_runtime::spawn(async move { registry.forget(&acct).await }); } if let Some(registry) = app.try_state::>() { let registry = registry.inner().clone(); let acct = account_id.to_string(); - tokio::spawn(async move { registry.forget(&acct).await }); + tauri::async_runtime::spawn(async move { registry.forget(&acct).await }); } if let Some(registry) = app.try_state::>() { let registry = registry.inner().clone(); let acct = account_id.to_string(); - tokio::spawn(async move { registry.forget(&acct).await }); + tauri::async_runtime::spawn(async move { registry.forget(&acct).await }); } if let Some(registry) = app.try_state::>() { let registry = registry.inner().clone(); let acct = account_id.to_string(); - tokio::spawn(async move { registry.forget(&acct).await }); + tauri::async_runtime::spawn(async move { registry.forget(&acct).await }); } } diff --git a/app/src-tauri/src/window_state.rs b/app/src-tauri/src/window_state.rs new file mode 100644 index 0000000000..3a29acba2c --- /dev/null +++ b/app/src-tauri/src/window_state.rs @@ -0,0 +1,192 @@ +//! Persistence of main-window position + size across restarts. +//! +//! `app.restart()` (used by #900's identity-flip flow) spawns a fresh +//! process, so the new window doesn't inherit anything from the old one. +//! Without us re-applying state, every login-driven respawn snaps the +//! window back to the default initial size in the center of the primary +//! display — even when the user had it on an external monitor or had +//! resized it. +//! +//! This module persists a tiny TOML record at +//! `/window_state.toml` capturing the outer position and +//! outer size of the main window in physical pixels. On launch the +//! record is read and applied before the window is shown. On restart we +//! save first, hide the window, then call `app.restart()`. +//! +//! Saved state is best-effort: read errors, missing file, off-screen +//! positions, and non-existent monitors all fall back to the default +//! centered window so we never trap the window where the user can't +//! reach it. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tauri::{PhysicalPosition, PhysicalSize, Runtime, WebviewWindow}; + +use crate::cef_profile; + +const STATE_FILE: &str = "window_state.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WindowState { + x: i32, + y: i32, + width: u32, + height: u32, +} + +fn state_path() -> Option { + cef_profile::default_root_openhuman_dir() + .ok() + .map(|root| root.join(STATE_FILE)) +} + +/// Capture the main window's outer geometry and write it to disk. +/// +/// Called from `restart_app` immediately before `app.restart()` so the +/// next process can land the new window where the user left it. +pub fn save_main(window: &WebviewWindow) { + let Ok(pos) = window.outer_position() else { + log::warn!("[window-state] outer_position unavailable; skip save"); + return; + }; + let Ok(size) = window.outer_size() else { + log::warn!("[window-state] outer_size unavailable; skip save"); + return; + }; + let state = WindowState { + x: pos.x, + y: pos.y, + width: size.width, + height: size.height, + }; + let Some(path) = state_path() else { + log::warn!("[window-state] no path available; skip save"); + return; + }; + if let Some(parent) = path.parent() { + if let Err(err) = std::fs::create_dir_all(parent) { + log::warn!( + "[window-state] mkdir {} failed: {}; skip save", + parent.display(), + err + ); + return; + } + } + let raw = match toml::to_string_pretty(&state) { + Ok(r) => r, + Err(err) => { + log::warn!("[window-state] serialize failed: {err}; skip save"); + return; + } + }; + if let Err(err) = std::fs::write(&path, raw) { + log::warn!("[window-state] write {} failed: {err}", path.display()); + } else { + log::info!( + "[window-state] saved geometry x={} y={} w={} h={}", + state.x, + state.y, + state.width, + state.height + ); + } +} + +/// Read the saved geometry (if any) and apply it to the main window. +/// +/// Returns `true` when saved geometry was applied. Returns `false` when +/// no saved file exists, the file is malformed, or the saved position +/// falls outside every currently-attached monitor (e.g. the user +/// undocked an external display); the caller is then expected to fall +/// back to a centered default so we never strand the window off-screen. +pub fn restore_main(window: &WebviewWindow) -> bool { + let Some(path) = state_path() else { + return false; + }; + let Ok(raw) = std::fs::read_to_string(&path) else { + return false; + }; + let state: WindowState = match toml::from_str(&raw) { + Ok(s) => s, + Err(err) => { + log::warn!( + "[window-state] parse {} failed: {err}; using default placement", + path.display() + ); + return false; + } + }; + + if !position_visible_on_any_monitor(window, state.x, state.y, state.width, state.height) { + log::info!( + "[window-state] saved position x={} y={} not on any monitor; falling back to centered default", + state.x, + state.y + ); + return false; + } + + if let Err(err) = window.set_size(PhysicalSize::new(state.width, state.height)) { + log::warn!("[window-state] set_size failed: {err}"); + } + if let Err(err) = window.set_position(PhysicalPosition::new(state.x, state.y)) { + log::warn!("[window-state] set_position failed: {err}"); + return false; + } + log::info!( + "[window-state] restored geometry x={} y={} w={} h={}", + state.x, + state.y, + state.width, + state.height + ); + true +} + +/// Center the main window on the primary display (or its current monitor +/// if `current_monitor` resolves) when no saved state applied. +pub fn center_main(window: &WebviewWindow) { + let Ok(Some(monitor)) = window + .primary_monitor() + .or_else(|_| window.current_monitor()) + else { + let _ = window.center(); + return; + }; + let Ok(size) = window.outer_size() else { + let _ = window.center(); + return; + }; + let mon_pos = monitor.position(); + let mon_size = monitor.size(); + let x = mon_pos.x + (mon_size.width as i32 - size.width as i32) / 2; + let y = mon_pos.y + (mon_size.height as i32 - size.height as i32) / 2; + let _ = window.set_position(PhysicalPosition::new(x, y)); +} + +fn position_visible_on_any_monitor( + window: &WebviewWindow, + x: i32, + y: i32, + width: u32, + height: u32, +) -> bool { + let Ok(monitors) = window.available_monitors() else { + return false; + }; + // Treat the window as on-screen if at least a 100x100 px patch of it + // overlaps any attached monitor. + let win_right = x.saturating_add(width as i32); + let win_bottom = y.saturating_add(height as i32); + monitors.iter().any(|m| { + let pos = m.position(); + let size = m.size(); + let mon_right = pos.x.saturating_add(size.width as i32); + let mon_bottom = pos.y.saturating_add(size.height as i32); + let overlap_w = (win_right.min(mon_right) - x.max(pos.x)).max(0); + let overlap_h = (win_bottom.min(mon_bottom) - y.max(pos.y)).max(0); + overlap_w >= 100 && overlap_h >= 100 + }) +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index ce76ffe3c0..475804b8d5 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -16,10 +16,10 @@ "title": "OpenHuman", "width": 1000, "height": 800, - "visible": true, + "visible": false, "decorations": true, "resizable": true, - "center": true + "center": false } ], "security": { diff --git a/app/src/main.tsx b/app/src/main.tsx index da8eea2a6c..7cd79de646 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -12,7 +12,9 @@ import OverlayApp from './overlay/OverlayApp'; import './polyfills'; import { initSentry } from './services/analytics'; import { setStoreForApiClient } from './services/apiClient'; +import { primeActiveUserId } from './store/userScopedStorage'; import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener'; +import { getActiveUserIdFromCore } from './utils/tauriCommands'; setStoreForApiClient(() => getCoreStateSnapshot().snapshot.sessionToken); @@ -43,14 +45,27 @@ if (!isOverlayWindow) { }); } -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - {isOverlayWindow ? : } -); +// Prime `userScopedStorage` from the Rust core's `active_user.toml` +// BEFORE redux-persist hydrates. The previous localStorage-only seed was +// bound to the per-user CEF profile dir and went stale across the +// restart-driven user flips that #900 introduced, so the new process +// would read the previous user's namespace, mis-detect a flip, and bounce +// into a second restart. Reading the Rust state up front pins the right +// namespace from the first storage call. (#900) +function bootRender() { + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); + root.render({isOverlayWindow ? : }); -if (!isOverlayWindow) { - // Mount error notification in an isolated React root so it survives App crashes. - const errorRoot = document.createElement('div'); - errorRoot.id = 'error-report-root'; - document.body.appendChild(errorRoot); - ReactDOM.createRoot(errorRoot).render(); + if (!isOverlayWindow) { + // Mount error notification in an isolated React root so it survives App crashes. + const errorRoot = document.createElement('div'); + errorRoot.id = 'error-report-root'; + document.body.appendChild(errorRoot); + ReactDOM.createRoot(errorRoot).render(); + } } + +getActiveUserIdFromCore() + .then(id => primeActiveUserId(id)) + .catch(() => primeActiveUserId(null)) + .finally(bootRender); diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 7055f0c6ec..051d60c497 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -25,6 +25,10 @@ import { listTeams, updateCoreLocalState, } from '../services/coreStateApi'; +import { socketService } from '../services/socketService'; +import { store } from '../store'; +import { resetUserScopedState } from '../store/resetActions'; +import { getActiveUserId, setActiveUserId } from '../store/userScopedStorage'; import { openhumanUpdateAnalyticsSettings, restartApp, @@ -75,6 +79,41 @@ function snapshotIdentity(snapshot: CoreAppSnapshot): string | null { return snapshot.auth.userId ?? snapshot.currentUser?._id ?? null; } +/** + * Restart-class cleanup for identity changes that require a process relaunch + * to re-hydrate redux-persist from the new user's namespace. + * + * redux-persist hydrates ONCE at module init, reading from whatever namespace + * `userScopedStorage` was pointing at. After that, `setActiveUserId` only + * routes new writes/reads — it doesn't re-hydrate in-memory state. So when + * the active userId changes from the namespace that was hydrated to a + * different one, we have to restart the app to get a fresh hydrate. + * + * Steps: + * 1. Re-point `userScopedStorage` to the new user's namespace so the + * `OPENHUMAN_ACTIVE_USER_ID` localStorage seed is correct on relaunch. + * 2. Dispatch `resetUserScopedState` to wipe the live store immediately — + * cosmetic during the brief frame between this call and `restartApp()`, + * so the prior user's slices don't render against the new auth. + * 3. Disconnect the Socket.IO connection so the reconnect after relaunch + * carries the new user's auth token. + * 4. `restartApp()` — the new process module-init reads + * `OPENHUMAN_ACTIVE_USER_ID=nextUserId`, hydrates from that namespace, + * and singleton services / Rust webview accounts come up clean. + * + * We deliberately do NOT call `persistor.purge()`. Each user's persisted + * blob lives at its own namespaced key, so user A's data must survive B's + * session intact and rehydrate when A returns. See [#900]. + */ +async function handleIdentityFlip(opts: { reason: string; nextUserId: string }): Promise { + const { reason, nextUserId } = opts; + log('identity flip restart reason=%s nextUserId=%s', reason, `****${nextUserId.slice(-4)}`); + setActiveUserId(nextUserId); + store.dispatch(resetUserScopedState()); + socketService.disconnect(); + await restartApp(); +} + function normalizeSnapshot( result: Awaited> ): CoreAppSnapshot { @@ -122,7 +161,6 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const logoutGuardUntilRef = useRef(0); const bootstrapFailCountRef = useRef(0); const refreshInFlightRef = useRef | null>(null); - const commitState = useCallback((updater: (previous: CoreState) => CoreState) => { setState(previous => { const next = updater(previous); @@ -137,23 +175,47 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) if (!snapshot.sessionToken) { logoutGuardUntilRef.current = 0; } + // Capture pre-commit identity outside the setState updater so flip + // detection runs synchronously regardless of React's batching policy. + const beforeCommit = getCoreStateSnapshot().snapshot; + const shouldIgnoreTokenDuringLogout = + Date.now() < logoutGuardUntilRef.current && + !beforeCommit.sessionToken && + Boolean(snapshot.sessionToken); + const nextSnapshot = shouldIgnoreTokenDuringLogout ? toSignedOutSnapshot(snapshot) : snapshot; + const previousIdentity = snapshotIdentity(beforeCommit); + const nextIdentity = snapshotIdentity(nextSnapshot); + const previousAuthed = beforeCommit.auth.isAuthenticated; + const nextAuthed = nextSnapshot.auth.isAuthenticated; + // Source of truth for "what userId's data is currently in memory" is the + // `OPENHUMAN_ACTIVE_USER_ID` localStorage seed read by `userScopedStorage` + // at module init — that's whose namespace redux-persist hydrated, and + // it's also what the Rust `prepare_process_cache_path` reads from + // `active_user.toml` on each cold launch to pick a CEF cache dir. If the + // userId that just authenticated is different (or different from null on + // a fresh device), we MUST restart so: + // 1. redux-persist re-hydrates from the new user's namespace, and + // 2. CEF re-initializes with the new user's `users//cef` profile, + // so embedded webviews (Slack, WhatsApp, …) don't see the prior + // user's third-party cookies. + // This single rule covers every login path uniformly: + // - cold bootstrap on a fresh install (seed is null, nextId is real) + // - direct `storeSessionToken` (Tauri OAuth) + // - deep-link `core-state:session-token-updated` + // - poll-detected flip (core-side user swap) + // - re-login as a different user after sign-out + const seedUserId = getActiveUserId(); + const isFlip = Boolean(nextIdentity) && seedUserId !== nextIdentity; + const isLogout = Boolean(previousAuthed) && !nextAuthed; + // Clear team caches whenever the visible identity changes (in-memory user + // shift) so the post-commit UI doesn't show user A's team list during the + // brief signed-out window or user B's session. + const shouldClearScopedCaches = isFlip || isLogout || previousIdentity !== nextIdentity; + commitState(previous => { if (requestId !== snapshotRequestIdRef.current) { return previous; } - - const shouldIgnoreTokenDuringLogout = - Date.now() < logoutGuardUntilRef.current && - !previous.snapshot.sessionToken && - Boolean(snapshot.sessionToken); - const nextSnapshot = shouldIgnoreTokenDuringLogout ? toSignedOutSnapshot(snapshot) : snapshot; - - const previousIdentity = snapshotIdentity(previous.snapshot); - const nextIdentity = snapshotIdentity(nextSnapshot); - const shouldClearScopedCaches = - previousIdentity !== nextIdentity || - (previous.snapshot.auth.isAuthenticated && !nextSnapshot.auth.isAuthenticated); - return { ...previous, isBootstrapping: false, @@ -164,6 +226,23 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) teamInvitesById: shouldClearScopedCaches ? {} : previous.teamInvitesById, }; }); + + if (isFlip && nextIdentity) { + await handleIdentityFlip({ reason: 'identity-flip', nextUserId: nextIdentity }).catch(err => { + log('handleIdentityFlip failed: %O', sanitizeError(err)); + }); + } else if (isLogout) { + // Sign-out: keep `OPENHUMAN_ACTIVE_USER_ID` pointing at the last user + // so the next login can detect via seed comparison whether it's a + // same-user re-login (no restart) or a different-user re-login + // (restart). Slice data also stays in memory since signed-out UI + // doesn't render user-scoped slices. Just drop the live socket since + // the token it was authed with has been invalidated by the core. + socketService.disconnect(); + } + // Same-user re-login (seedUserId === nextIdentity) and cold bootstrap + // with matching seed are no-ops — redux-persist already loaded the + // right namespace and the active user id is already correct. syncAnalyticsConsent(snapshot.analyticsEnabled); if (!snapshot.sessionToken) { @@ -379,7 +458,6 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const storeSessionToken = useCallback( async (token: string, user?: object) => { - const previousIdentity = snapshotIdentity(getCoreStateSnapshot().snapshot); logoutGuardUntilRef.current = 0; await storeSession(token, user ?? {}); try { @@ -388,21 +466,16 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) } catch (error) { console.warn('[core-state] memory client sync failed after session store:', error); } + // refresh() drives refreshCore, which now owns identity-flip detection + // and dispatches handleIdentityFlip when both prev and next are + // authenticated and identities differ. The previous standalone + // restartApp call here was redundant and skipped the persist purge, + // letting redux-persist rehydrate the prior user's slices on launch + // (#900). Restart now happens inside handleIdentityFlip after purge. await refresh(); await refreshTeams().catch(err => { log('refreshTeams failed after session store: %O', sanitizeError(err)); }); - - const nextIdentity = snapshotIdentity(getCoreStateSnapshot().snapshot); - if (nextIdentity && previousIdentity !== nextIdentity) { - const mask = (s: string | null) => - s == null ? 'none' : s.length > 4 ? `****${s.slice(-4)}` : '****'; - console.debug('[core-state] user changed; restarting app to switch CEF profile', { - previousIdentity: mask(previousIdentity), - nextIdentity: mask(nextIdentity), - }); - await restartApp(); - } }, [refresh, refreshTeams] ); @@ -418,6 +491,13 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) snapshot: toSignedOutSnapshot(previous.snapshot), })); memoryTokenRef.current = null; + // Keep `OPENHUMAN_ACTIVE_USER_ID` pointing at the last user. The next + // refresh's `getActiveUserId()` seed comparison decides whether the + // upcoming login is a same-user re-login (no restart) or a different- + // user re-login (restart). We do NOT dispatch `resetUserScopedState` + // here either — the signed-out UI doesn't render user-scoped slices, + // and a same-user re-login should not pay a "rehydrate from disk" + // cost (slices are still in memory). See [#900]. await tauriLogout(); await refresh().catch(err => { log('refresh failed after clearSession: %O', sanitizeError(err)); diff --git a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx new file mode 100644 index 0000000000..04a8e36fc2 --- /dev/null +++ b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx @@ -0,0 +1,300 @@ +import { act, render } from '@testing-library/react'; +import { useEffect } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as coreStateApi from '../../services/coreStateApi'; +import * as userScopedStorage from '../../store/userScopedStorage'; +import * as tauriCommands from '../../utils/tauriCommands'; +import { setCoreStateSnapshot } from '../../lib/coreState/store'; +import { socketService } from '../../services/socketService'; +import { store } from '../../store'; +import { addAccount } from '../../store/accountsSlice'; +import { resetUserScopedState } from '../../store/resetActions'; +import CoreStateProvider, { useCoreState } from '../CoreStateProvider'; + +vi.mock('../../services/coreStateApi'); +vi.mock('../../services/analytics', () => ({ syncAnalyticsConsent: vi.fn() })); +vi.mock('../../utils/tauriCommands', () => ({ + openhumanUpdateAnalyticsSettings: vi.fn(), + restartApp: vi.fn().mockResolvedValue(undefined), + setOnboardingCompleted: vi.fn(), + storeSession: vi.fn().mockResolvedValue(undefined), + syncMemoryClientToken: vi.fn().mockResolvedValue(undefined), + logout: vi.fn().mockResolvedValue(undefined), +})); + +type Snapshot = Awaited>; + +function makeSnapshot(overrides: { + userId?: string | null; + sessionToken?: string | null; + isAuthenticated?: boolean; +}): Snapshot { + return { + auth: { + isAuthenticated: overrides.isAuthenticated ?? Boolean(overrides.userId), + userId: overrides.userId ?? null, + user: null as never, + profileId: null, + }, + sessionToken: overrides.sessionToken ?? null, + currentUser: null as never, + onboardingCompleted: false, + chatOnboardingCompleted: false, + analyticsEnabled: false, + localState: {}, + runtime: { + screenIntelligence: null as never, + localAi: null as never, + autocomplete: null as never, + service: null as never, + }, + }; +} + +type CoreStateContextValue = ReturnType; + +function Consumer({ captureCtx }: { captureCtx: (ctx: CoreStateContextValue) => void }) { + const state = useCoreState(); + useEffect(() => { + captureCtx(state); + }); + return {state.snapshot.auth.userId ?? 'none'}; +} + +function resetCoreStateStore() { + setCoreStateSnapshot({ + isBootstrapping: true, + isReady: false, + snapshot: { + auth: { isAuthenticated: false, userId: null, user: null, profileId: null }, + sessionToken: null, + currentUser: null, + onboardingCompleted: false, + chatOnboardingCompleted: false, + analyticsEnabled: false, + localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null }, + runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, + }, + teams: [], + teamMembersById: {}, + teamInvitesById: {}, + }); +} + +function seedAccountsWithUserAData() { + store.dispatch( + addAccount({ + id: 'acct-A', + provider: 'whatsapp', + label: 'WhatsApp A', + status: 'connected', + } as never) + ); +} + +describe('CoreStateProvider — identity flip cleanup (#900)', () => { + const fetchSnapshot = vi.mocked(coreStateApi.fetchCoreAppSnapshot); + const listTeams = vi.mocked(coreStateApi.listTeams); + const restartApp = vi.mocked(tauriCommands.restartApp); + + beforeEach(() => { + fetchSnapshot.mockReset(); + listTeams.mockReset(); + listTeams.mockResolvedValue([]); + restartApp.mockReset(); + restartApp.mockResolvedValue(undefined); + resetCoreStateStore(); + store.dispatch(resetUserScopedState()); + userScopedStorage.setActiveUserId(null); + }); + + it('cold bootstrap on a fresh device (seed=null, nextId=A): RESTART required so CEF picks up A profile', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); + const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); + const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); + + let ctx: CoreStateContextValue | undefined; + render( + + (ctx = c)} /> + + ); + await act(async () => { + await ctx!.refresh(); + }); + + expect(setActiveSpy).toHaveBeenCalledWith('A'); + expect(restartApp).toHaveBeenCalledTimes(1); + + setActiveSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); + + it('warm launch (seed=A, snapshot=A): no restart', async () => { + userScopedStorage.setActiveUserId('A'); + const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); + + let ctx: CoreStateContextValue | undefined; + render( + + (ctx = c)} /> + + ); + await act(async () => { + await ctx!.refresh(); + }); + + expect(restartApp).not.toHaveBeenCalled(); + expect(setActiveSpy).not.toHaveBeenCalled(); + + setActiveSpy.mockRestore(); + }); + + it('auth-to-auth flip (A→B without intermediate logout): restart, re-points to B', async () => { + userScopedStorage.setActiveUserId('A'); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); + const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); + const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); + + let ctx: CoreStateContextValue | undefined; + render( + + (ctx = c)} /> + + ); + await act(async () => { + await ctx!.refresh(); + }); + seedAccountsWithUserAData(); + expect(store.getState().accounts.order).toContain('acct-A'); + + setActiveSpy.mockClear(); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'B', sessionToken: 'tokB' })); + await act(async () => { + await ctx!.refresh(); + await Promise.resolve(); + }); + + expect(setActiveSpy).toHaveBeenCalledWith('B'); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(restartApp).toHaveBeenCalledTimes(1); + expect(store.getState().accounts.order).not.toContain('acct-A'); + + setActiveSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); + + it('logout: keeps active user id seed; disconnects socket; no restart, no reset', async () => { + userScopedStorage.setActiveUserId('A'); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); + const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); + const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); + + let ctx: CoreStateContextValue | undefined; + render( + + (ctx = c)} /> + + ); + await act(async () => { + await ctx!.refresh(); + }); + seedAccountsWithUserAData(); + + setActiveSpy.mockClear(); + fetchSnapshot.mockResolvedValue( + makeSnapshot({ userId: null, sessionToken: null, isAuthenticated: false }) + ); + await act(async () => { + await ctx!.refresh(); + }); + + // Seed must NOT be cleared on logout — same-user re-login depends on it. + expect(setActiveSpy).not.toHaveBeenCalled(); + expect(userScopedStorage.getActiveUserId()).toBe('A'); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(restartApp).not.toHaveBeenCalled(); + expect(store.getState().accounts.order).toContain('acct-A'); + + setActiveSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); + + it('same-user re-login (A→logout→A): no restart (seed still points at A)', async () => { + userScopedStorage.setActiveUserId('A'); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); + const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); + + let ctx: CoreStateContextValue | undefined; + render( + + (ctx = c)} /> + + ); + await act(async () => { + await ctx!.refresh(); + }); + seedAccountsWithUserAData(); + + fetchSnapshot.mockResolvedValue( + makeSnapshot({ userId: null, sessionToken: null, isAuthenticated: false }) + ); + await act(async () => { + await ctx!.refresh(); + }); + + setActiveSpy.mockClear(); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA2' })); + await act(async () => { + await ctx!.refresh(); + }); + + expect(setActiveSpy).not.toHaveBeenCalled(); + expect(restartApp).not.toHaveBeenCalled(); + expect(store.getState().accounts.order).toContain('acct-A'); + + setActiveSpy.mockRestore(); + }); + + it('different-user re-login (A→logout→B): restart, re-points to B', async () => { + userScopedStorage.setActiveUserId('A'); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); + const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); + const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); + + let ctx: CoreStateContextValue | undefined; + render( + + (ctx = c)} /> + + ); + await act(async () => { + await ctx!.refresh(); + }); + seedAccountsWithUserAData(); + + fetchSnapshot.mockResolvedValue( + makeSnapshot({ userId: null, sessionToken: null, isAuthenticated: false }) + ); + await act(async () => { + await ctx!.refresh(); + }); + + setActiveSpy.mockClear(); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'B', sessionToken: 'tokB' })); + await act(async () => { + await ctx!.refresh(); + await Promise.resolve(); + }); + + expect(setActiveSpy).toHaveBeenCalledWith('B'); + expect(restartApp).toHaveBeenCalledTimes(1); + expect(disconnectSpy).toHaveBeenCalled(); + expect(store.getState().accounts.order).not.toContain('acct-A'); + + setActiveSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); +}); diff --git a/app/src/store/accountsSlice.ts b/app/src/store/accountsSlice.ts index 622e75725e..0c2c323477 100644 --- a/app/src/store/accountsSlice.ts +++ b/app/src/store/accountsSlice.ts @@ -7,6 +7,7 @@ import type { AccountStatus, IngestedMessage, } from '../types/accounts'; +import { resetUserScopedState } from './resetActions'; const MAX_MESSAGES_PER_ACCOUNT = 200; const MAX_LOG_LINES_PER_ACCOUNT = 100; @@ -105,6 +106,9 @@ const accountsSlice = createSlice({ return initialState; }, }, + extraReducers: builder => { + builder.addCase(resetUserScopedState, () => initialState); + }, }); export const { diff --git a/app/src/store/channelConnectionsSlice.ts b/app/src/store/channelConnectionsSlice.ts index 19a3fc2340..51537a8b11 100644 --- a/app/src/store/channelConnectionsSlice.ts +++ b/app/src/store/channelConnectionsSlice.ts @@ -7,6 +7,7 @@ import type { ChannelConnectionStatus, ChannelType, } from '../types/channels'; +import { resetUserScopedState } from './resetActions'; const SCHEMA_VERSION = 1; @@ -116,6 +117,9 @@ const channelConnectionsSlice = createSlice({ return initialState; }, }, + extraReducers: builder => { + builder.addCase(resetUserScopedState, () => initialState); + }, }); export const { diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 58598f401e..ae4695249f 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -1,5 +1,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { resetUserScopedState } from './resetActions'; + export type ToolTimelineEntryStatus = 'running' | 'success' | 'error'; export interface InferenceStatus { @@ -133,6 +135,9 @@ const chatRuntimeSlice = createSlice({ state.sessionTokenUsage = { inputTokens: 0, outputTokens: 0, turns: 0, lastUpdated: 0 }; }, }, + extraReducers: builder => { + builder.addCase(resetUserScopedState, () => initialState); + }, }); export const { diff --git a/app/src/store/index.ts b/app/src/store/index.ts index d6091f9808..be8de6b3cb 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -10,7 +10,6 @@ import { REGISTER, REHYDRATE, } from 'redux-persist'; -import storage from 'redux-persist/lib/storage'; import { IS_DEV } from '../utils/config'; import accountsReducer from './accountsSlice'; @@ -20,6 +19,12 @@ import notificationReducer from './notificationSlice'; import providerSurfacesReducer from './providerSurfaceSlice'; import socketReducer from './socketSlice'; import threadReducer from './threadSlice'; +import { userScopedStorage } from './userScopedStorage'; + +// Persisted slices write through `userScopedStorage` so each user's blob +// lives at `${userId}:persist:` instead of a single per-device blob +// that leaks across users on logout/login (#900). +const storage = userScopedStorage; const channelConnectionsPersistConfig = { key: 'channelConnections', diff --git a/app/src/store/notificationSlice.ts b/app/src/store/notificationSlice.ts index 48bb456dc7..1b280a4afa 100644 --- a/app/src/store/notificationSlice.ts +++ b/app/src/store/notificationSlice.ts @@ -1,6 +1,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import type { IntegrationNotification } from '../types/notifications'; +import { resetUserScopedState } from './resetActions'; export type NotificationCategory = 'messages' | 'agents' | 'skills' | 'system'; @@ -114,6 +115,9 @@ const notificationSlice = createSlice({ } }, }, + extraReducers: builder => { + builder.addCase(resetUserScopedState, () => initialState); + }, }); export const selectUnreadCount = (items: NotificationItem[]): number => diff --git a/app/src/store/providerSurfaceSlice.ts b/app/src/store/providerSurfaceSlice.ts index 4fb1e0015c..5018700eac 100644 --- a/app/src/store/providerSurfaceSlice.ts +++ b/app/src/store/providerSurfaceSlice.ts @@ -2,6 +2,7 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { providerSurfacesApi } from '../services/api/providerSurfacesApi'; import type { RespondQueueItem } from '../types/providerSurfaces'; +import { resetUserScopedState } from './resetActions'; interface ProviderSurfaceState { queue: RespondQueueItem[]; @@ -57,7 +58,8 @@ const providerSurfaceSlice = createSlice({ state.error = (action.payload as string) ?? 'Failed to load provider respond queue'; } // silent failures: leave status/error as-is; a subsequent successful poll will clear - }); + }) + .addCase(resetUserScopedState, () => initialState); }, }); diff --git a/app/src/store/resetActions.ts b/app/src/store/resetActions.ts new file mode 100644 index 0000000000..afcbecf789 --- /dev/null +++ b/app/src/store/resetActions.ts @@ -0,0 +1,8 @@ +import { createAction } from '@reduxjs/toolkit'; + +/** + * Top-level action dispatched on identity flip (user A → user B) and on + * sign-out. Every user-scoped slice handles this in `extraReducers` and + * returns its `initialState`. See [#900]. + */ +export const resetUserScopedState = createAction('store/resetUserScopedState'); diff --git a/app/src/store/socketSlice.ts b/app/src/store/socketSlice.ts index b6d9c4a895..716ac5d780 100644 --- a/app/src/store/socketSlice.ts +++ b/app/src/store/socketSlice.ts @@ -1,5 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { resetUserScopedState } from './resetActions'; + export type SocketConnectionStatus = 'connected' | 'disconnected' | 'connecting'; export interface SocketUserState { @@ -51,6 +53,9 @@ const socketSlice = createSlice({ state.byUser[userId] = { ...initialUserState }; }, }, + extraReducers: builder => { + builder.addCase(resetUserScopedState, () => initialState); + }, }); export const { setStatusForUser, setSocketIdForUser, resetForUser } = socketSlice.actions; diff --git a/app/src/store/threadSlice.ts b/app/src/store/threadSlice.ts index 506da34e22..9ad8e17e30 100644 --- a/app/src/store/threadSlice.ts +++ b/app/src/store/threadSlice.ts @@ -3,6 +3,7 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { threadApi } from '../services/api/threadApi'; import type { Thread, ThreadMessage } from '../types/thread'; import { IS_DEV } from '../utils/config'; +import { resetUserScopedState } from './resetActions'; interface ThreadState { threads: Thread[]; @@ -318,7 +319,8 @@ const threadSlice = createSlice({ }) .addCase(deleteThread.fulfilled, (state, action) => { delete state.messagesByThreadId[action.payload.threadId]; - }); + }) + .addCase(resetUserScopedState, () => initialState); }, }); diff --git a/app/src/store/userScopedStorage.ts b/app/src/store/userScopedStorage.ts new file mode 100644 index 0000000000..62a243cccd --- /dev/null +++ b/app/src/store/userScopedStorage.ts @@ -0,0 +1,180 @@ +/** + * User-scoped redux-persist storage. Wraps `localStorage` so every key is + * namespaced by `userId`, e.g. `persist:accounts` → `${userId}:persist:accounts`. + * + * This is the durable half of the cross-user leak fix in [#900]: the in-memory + * Redux reset clears the live store on identity flip, but the localStorage + * blob has to be partitioned per user so user A's data survives B's session + * (and rehydrates when A returns) without leaking into B. + * + * The active user id is sourced from the standalone `OPENHUMAN_ACTIVE_USER_ID` + * key, written by `setActiveUserId(...)`. The key is read once at module load + * so redux-persist's first-paint rehydrate sees the right namespace; later + * changes call the setter, which updates the in-memory ref and persists the id + * to localStorage so the *next* cold launch is also seeded. + * + * When `activeUserId` is `null` (signed-out), all reads return `null` and all + * writes are silent no-ops. This is intentional — we never want to write a + * user-shaped blob to a global key, and we never want to rehydrate a stale + * blob into a signed-out shell. + */ + +const ACTIVE_USER_KEY = 'OPENHUMAN_ACTIVE_USER_ID'; + +function safeGetActiveUserIdSync(): string | null { + try { + return localStorage.getItem(ACTIVE_USER_KEY); + } catch { + return null; + } +} + +let activeUserId: string | null = safeGetActiveUserIdSync(); + +// Gate redux-persist's rehydrate on the boot prime from main.tsx +// (which reads the authoritative id from `~/.openhuman/active_user.toml` +// via the Rust core). The localStorage value used at module load is +// bound to the per-user CEF profile dir and goes stale across +// restart-driven user flips, so storage reads must wait for the +// asynchronous prime before resolving the namespace. (#900) +let activeUserIdResolve!: () => void; +const activeUserIdReady = new Promise(resolve => { + activeUserIdResolve = resolve; +}); +let primed = false; + +/** + * Mark `userScopedStorage` as primed with the boot-time active user id. + * + * Called once by `main.tsx` after `getActiveUserIdFromCore()` returns. + * Pass `null` for "no user logged in yet" — storage reads/writes then + * fall through as no-ops until a real id is supplied later via + * `setActiveUserId`. + * + * Safe to call before `setActiveUserId` for an initial seed; subsequent + * `primeActiveUserId(...)` calls have no effect (the gate is one-shot). + */ +export function primeActiveUserId(id: string | null): void { + if (primed) return; + primed = true; + activeUserId = id; + try { + if (id) { + localStorage.setItem(ACTIVE_USER_KEY, id); + } else { + localStorage.removeItem(ACTIVE_USER_KEY); + } + } catch { + // localStorage may be unavailable; in-memory ref still drives reads + } + activeUserIdResolve(); +} + +/** + * Returns the userId currently in scope for persisted reads/writes, or `null` + * if no user is active yet. Reads through to the latest set value. + */ +export function getActiveUserId(): string | null { + return activeUserId; +} + +/** + * Update the active user id for redux-persist storage scoping. Pass `null` + * for sign-out so subsequent persisted writes are dropped on the floor. + * + * Persisted to `localStorage[OPENHUMAN_ACTIVE_USER_ID]` so the next cold + * launch can seed `activeUserId` synchronously before redux-persist + * rehydrates. + */ +export function setActiveUserId(id: string | null): void { + const previous = activeUserId; + activeUserId = id; + try { + if (id) { + localStorage.setItem(ACTIVE_USER_KEY, id); + if (!previous) { + migrateLegacyPersistKeys(id); + } + } else { + localStorage.removeItem(ACTIVE_USER_KEY); + } + } catch { + // localStorage may be unavailable (private mode quota); swallowing is + // fine — the in-memory ref still drives the current session. + } +} + +/** + * One-shot migration for users upgrading from the pre-#900 build, where + * persist blobs lived at unscoped keys (`persist:accounts`, etc.). On the + * first identity assignment after launch, if any legacy key exists and the + * corresponding user-scoped key is empty, copy legacy → `${id}:` and + * drop the legacy entry. This lets the FIRST user to log in on the upgraded + * build keep their UI shimmer; later users see initial state and rehydrate + * from backend as usual. + */ +function migrateLegacyPersistKeys(id: string): void { + const LEGACY_PREFIXES = ['persist:']; + try { + const legacyKeys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key) continue; + if (LEGACY_PREFIXES.some(p => key.startsWith(p))) { + legacyKeys.push(key); + } + } + for (const key of legacyKeys) { + const scoped = `${id}:${key}`; + if (localStorage.getItem(scoped) !== null) continue; // already migrated + const value = localStorage.getItem(key); + if (value === null) continue; + localStorage.setItem(scoped, value); + localStorage.removeItem(key); + } + } catch { + // best-effort; ignore quota / unavailable + } +} + +function namespacedKey(key: string): string | null { + if (!activeUserId) return null; + return `${activeUserId}:${key}`; +} + +/** + * `Storage`-shaped object compatible with redux-persist's storage contract. + * Methods return promises because redux-persist treats storage as async. + */ +export const userScopedStorage = { + async getItem(key: string): Promise { + await activeUserIdReady; + const ns = namespacedKey(key); + if (!ns) return null; + try { + return localStorage.getItem(ns); + } catch { + return null; + } + }, + async setItem(key: string, value: string): Promise { + await activeUserIdReady; + const ns = namespacedKey(key); + if (!ns) return; + try { + localStorage.setItem(ns, value); + } catch { + // ignore quota / unavailable + } + }, + async removeItem(key: string): Promise { + await activeUserIdReady; + const ns = namespacedKey(key); + if (!ns) return; + try { + localStorage.removeItem(ns); + } catch { + // ignore + } + }, +}; diff --git a/app/src/utils/tauriCommands/core.ts b/app/src/utils/tauriCommands/core.ts index 9f1f330061..b19e8c2be8 100644 --- a/app/src/utils/tauriCommands/core.ts +++ b/app/src/utils/tauriCommands/core.ts @@ -71,6 +71,23 @@ export async function restartApp(): Promise { await invoke('restart_app'); } +/** + * Read the active user id from `~/.openhuman/active_user.toml` via Rust. + * Used at startup (before redux-persist hydrates) to seed + * `userScopedStorage` from the profile-independent source of truth so + * the UI always lands on the right user namespace, regardless of any + * stale `localStorage` value bound to a previously-active CEF profile. + * (#900) + */ +export async function getActiveUserIdFromCore(): Promise { + if (!isTauri()) return null; + try { + return await invoke('get_active_user_id'); + } catch { + return null; + } +} + /** * Queue deletion of a user-scoped CEF profile on the next app launch. */