From b8b068ea6446d2f04e122150e7a0e23580ba278b Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 14:21:37 +0530 Subject: [PATCH 01/19] feat(store): add resetUserScopedState action (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`, replacing the per-slice ad-hoc reset reducers that some slices have and others lack. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/store/resetActions.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 app/src/store/resetActions.ts 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'); From b101bc76b9643f23519ae26261802a87c356af4e Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 14:21:48 +0530 Subject: [PATCH 02/19] feat(store): reset all user-scoped slices on identity flip (#900) Wires `resetUserScopedState` into every user-scoped slice via `extraReducers`, so a single dispatch returns each slice to `initialState`: - threadSlice: threads, selectedThreadId, messagesByThreadId, activeThreadId, welcomeThreadId, messages - chatRuntimeSlice: inferenceStatusByThread, streamingAssistantByThread, toolTimelineByThread, inferenceTurnLifecycleByThread, sessionTokenUsage - notificationSlice: items, preferences, integrationItems, integrationUnreadCount - providerSurfaceSlice: respond queue - accountsSlice, channelConnectionsSlice, socketSlice: parity with the per-slice `reset*State` actions they already expose Without this, `redux-persist` rehydrates user A's `persist:accounts`, `persist:notifications`, and `persist:channelConnections` blobs into user B's session on the next render or app launch, leaking the prior user's account rail, thread list, and notifications. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/store/accountsSlice.ts | 4 ++++ app/src/store/channelConnectionsSlice.ts | 4 ++++ app/src/store/chatRuntimeSlice.ts | 5 +++++ app/src/store/notificationSlice.ts | 4 ++++ app/src/store/providerSurfaceSlice.ts | 4 +++- app/src/store/socketSlice.ts | 5 +++++ app/src/store/threadSlice.ts | 4 +++- 7 files changed, 28 insertions(+), 2 deletions(-) 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/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/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); }, }); From ba68f63366de57d6c0ff32857a630df3d0fcb65e Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 14:22:04 +0530 Subject: [PATCH 03/19] fix(core-state): purge persist + reset Redux + drop socket on identity flip (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cross-user state leak: when user B logs in on the same device after user A, the React layer kept showing user A's account rail, thread list, chat-runtime timelines, and notifications because `localStorage` `persist:*` blobs are per-device and rehydrated user A's slices into user B's session. Adds a single `handleIdentityFlip({restart})` helper that: 1. Dispatches `resetUserScopedState` so every user-scoped slice returns to its initial shape in-memory. 2. Calls `socketService.disconnect()` so the next reconnect carries the new user's auth token (fresh `client_id` server-side). 3. Awaits `persistor.purge()` so the localStorage `persist:*` blobs are gone before any restart or re-render. 4. Optionally awaits `restartApp()` — only on a real flip, never on bootstrap or sign-out. `refreshCore` now classifies each snapshot transition as bootstrap, flip, or logout up front (outside the React functional setState updater so detection runs synchronously regardless of batching). Restart is gated on `Boolean(previousIdentity) && previousIdentity !== nextIdentity` so the signed-out → signed-in bootstrap path can never restart-loop. `storeSessionToken` loses its standalone `restartApp` call — the existing await on `refresh()` now drives `refreshCore` which owns flip detection, so the previous code path that restarted without purging the persistor is gone. `clearSession` calls `handleIdentityFlip({restart: false})` so sign-out also wipes Redux + persist + socket; the next login (which will fire `storeSessionToken`) handles the restart from a known clean slate. Deep-link `core-state:session-token-updated` reaches `refreshCore` via its trailing `refresh()`, so identity flips driven by deep links are covered without any extra wiring. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/providers/CoreStateProvider.tsx | 102 ++++++++++++++++++------ 1 file changed, 77 insertions(+), 25 deletions(-) diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 7055f0c6ec..d4842323a2 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -25,6 +25,9 @@ import { listTeams, updateCoreLocalState, } from '../services/coreStateApi'; +import { socketService } from '../services/socketService'; +import { persistor, store } from '../store'; +import { resetUserScopedState } from '../store/resetActions'; import { openhumanUpdateAnalyticsSettings, restartApp, @@ -75,6 +78,36 @@ function snapshotIdentity(snapshot: CoreAppSnapshot): string | null { return snapshot.auth.userId ?? snapshot.currentUser?._id ?? null; } +/** + * Universal cleanup for identity changes (flip A→B, or sign-out). + * + * Resets every user-scoped Redux slice via `resetUserScopedState`, drops the + * `persist:*` localStorage blobs (otherwise rehydration on the next render or + * launch leaks user A's slices into user B's session), and disconnects the + * live Socket.IO connection so the next reconnect carries the new auth token. + * + * Pass `restart: true` for a real flip (A→B): a process restart is needed + * because singleton services and Rust-side webview accounts pin the prior + * user's state for the lifetime of the process. Pass `restart: false` for + * sign-out — the signed-out UI is empty, so a relaunch would be jarring. + * + * See [#900]. + */ +async function handleIdentityFlip(opts: { restart: boolean; reason: string }): Promise { + const { restart, reason } = opts; + log('identity flip cleanup reason=%s restart=%s', reason, restart); + store.dispatch(resetUserScopedState()); + socketService.disconnect(); + try { + await persistor.purge(); + } catch (error) { + console.warn('[core-state] persistor.purge failed during identity flip:', error); + } + if (restart) { + await restartApp(); + } +} + function normalizeSnapshot( result: Awaited> ): CoreAppSnapshot { @@ -137,23 +170,33 @@ 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; + // Only flag a flip when BOTH sides are authenticated and identities differ. + // Bootstrap (signed-out → signed-in) and logout transitions are handled + // separately so we never restart-loop on launch (#900). + const isFlip = + Boolean(previousAuthed) && + nextAuthed && + Boolean(previousIdentity) && + previousIdentity !== nextIdentity; + const isLogout = Boolean(previousAuthed) && !nextAuthed; + 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 +207,16 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) teamInvitesById: shouldClearScopedCaches ? {} : previous.teamInvitesById, }; }); + + if (isFlip) { + await handleIdentityFlip({ restart: true, reason: 'refreshCore-flip' }).catch(err => { + log('handleIdentityFlip(flip) failed: %O', sanitizeError(err)); + }); + } else if (isLogout) { + await handleIdentityFlip({ restart: false, reason: 'refreshCore-logout' }).catch(err => { + log('handleIdentityFlip(logout) failed: %O', sanitizeError(err)); + }); + } syncAnalyticsConsent(snapshot.analyticsEnabled); if (!snapshot.sessionToken) { @@ -379,7 +432,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 +440,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 +465,11 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) snapshot: toSignedOutSnapshot(previous.snapshot), })); memoryTokenRef.current = null; + // Reset every user-scoped slice + purge persist + drop the live socket + // before the tauriLogout RPC so user A's data is gone the moment the UI + // re-renders signed-out (#900). No restart — signed-out UI is empty; + // the next storeSessionToken (login as B) will restart via refreshCore. + await handleIdentityFlip({ restart: false, reason: 'clearSession' }); await tauriLogout(); await refresh().catch(err => { log('refresh failed after clearSession: %O', sanitizeError(err)); From f571230b861c91d76b45bd01eb400772ca6f4119 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 14:22:13 +0530 Subject: [PATCH 04/19] test(core-state): cover identity-flip cleanup paths (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Vitest cases over `CoreStateProvider`: 1. Flip A→B: asserts `resetUserScopedState` dispatched, `persistor.purge` called once, `socketService.disconnect` called once, `restartApp` called once, and a seeded `accounts.order` entry is gone after the flip. 2. `clearSession`: same purge/disconnect/reset assertions, but `restartApp` MUST NOT be called — signed-out UI is empty so a relaunch would be jarring. 3. Bootstrap (signed-out → signed-in): asserts `restartApp` is NOT called on first auth so cold-launch never restart-loops. Mocks `tauriCommands.restartApp`, spies on `persistor.purge` and `socketService.disconnect`. Uses real Redux store + `addAccount` fixture to verify the slice reset is visible end-to-end (not just that `dispatch` was called with the action creator). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CoreStateProvider.identityFlip.test.tsx | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx 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..095ee34e52 --- /dev/null +++ b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx @@ -0,0 +1,202 @@ +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 tauriCommands from '../../utils/tauriCommands'; +import { setCoreStateSnapshot } from '../../lib/coreState/store'; +import { socketService } from '../../services/socketService'; +import { persistor, 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(); + // Reset Redux back to clean baseline before each test. + store.dispatch(resetUserScopedState()); + }); + + it('flip A→B: dispatches reset, purges persistor, disconnects socket, restarts app', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); + const dispatchSpy = vi.spyOn(store, 'dispatch'); + const purgeSpy = vi.spyOn(persistor, 'purge').mockResolvedValue(undefined); + 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'); + + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'B', sessionToken: 'tokB' })); + await act(async () => { + await ctx!.refresh(); + // Allow the void-fired handleIdentityFlip to settle. + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(dispatchSpy).toHaveBeenCalledWith(resetUserScopedState()); + expect(purgeSpy).toHaveBeenCalledTimes(1); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(restartApp).toHaveBeenCalledTimes(1); + expect(store.getState().accounts.order).not.toContain('acct-A'); + + dispatchSpy.mockRestore(); + purgeSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); + + it('clearSession: resets + purges + disconnects, but does NOT restart', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); + const purgeSpy = vi.spyOn(persistor, 'purge').mockResolvedValue(undefined); + 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!.clearSession(); + }); + + expect(purgeSpy).toHaveBeenCalled(); + expect(disconnectSpy).toHaveBeenCalled(); + expect(restartApp).not.toHaveBeenCalled(); + expect(store.getState().accounts.order).not.toContain('acct-A'); + + purgeSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); + + it('bootstrap (signed-out → signed-in): does NOT restart on first auth', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); + const purgeSpy = vi.spyOn(persistor, 'purge').mockResolvedValue(undefined); + const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); + + let ctx: CoreStateContextValue | undefined; + render( + + (ctx = c)} /> + + ); + await act(async () => { + await ctx!.refresh(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(restartApp).not.toHaveBeenCalled(); + + purgeSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); +}); From 054080ba9566f26f54f9d872f8b578f206fe1192 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 14:42:23 +0530 Subject: [PATCH 05/19] feat(store): user-scoped redux-persist storage namespace per active userId (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `app/src/store/userScopedStorage.ts`, a `Storage`-shaped wrapper around `localStorage` that prefixes every key with the active userId, e.g. `persist:accounts` → `${userId}:persist:accounts`. The active id is sourced from a single `OPENHUMAN_ACTIVE_USER_ID` localStorage key, read synchronously at module init so redux-persist's first-paint rehydrate sees the right namespace, and updated via `setActiveUserId` on identity changes. When `activeUserId` is `null` (signed-out), reads return `null` and writes are silent no-ops — we never want a user-shaped blob written to a global key, and never want a stale blob hydrated into a signed-out shell. Routes the three persisted slices (`accounts`, `notifications`, `channelConnections`) through `userScopedStorage` instead of the default per-device `localStorage`. Each user's blob now lives at its own namespaced key, so user A's data survives B's session intact and rehydrates when A returns. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/store/index.ts | 7 +- app/src/store/userScopedStorage.ts | 103 +++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 app/src/store/userScopedStorage.ts 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/userScopedStorage.ts b/app/src/store/userScopedStorage.ts new file mode 100644 index 0000000000..759194dcc2 --- /dev/null +++ b/app/src/store/userScopedStorage.ts @@ -0,0 +1,103 @@ +/** + * 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(); + +/** + * 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 { + activeUserId = id; + try { + if (id) { + localStorage.setItem(ACTIVE_USER_KEY, 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. + } +} + +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 = { + getItem(key: string): Promise { + const ns = namespacedKey(key); + if (!ns) return Promise.resolve(null); + try { + return Promise.resolve(localStorage.getItem(ns)); + } catch { + return Promise.resolve(null); + } + }, + setItem(key: string, value: string): Promise { + const ns = namespacedKey(key); + if (!ns) return Promise.resolve(); + try { + localStorage.setItem(ns, value); + } catch { + // ignore quota / unavailable + } + return Promise.resolve(); + }, + removeItem(key: string): Promise { + const ns = namespacedKey(key); + if (!ns) return Promise.resolve(); + try { + localStorage.removeItem(ns); + } catch { + // ignore + } + return Promise.resolve(); + }, +}; From 5d9d08138f0238abba2da8716d305e001ba4048f Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 14:42:40 +0530 Subject: [PATCH 06/19] fix(core-state): re-point persist namespace on identity flip; never purge (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the unconditional `persistor.purge()` in `handleIdentityFlip` with a `setActiveUserId(nextUserId)` call. Each user's persisted blob now lives at its own namespaced key (per the prior commit), so purging would delete user A's data and prevent rehydration when A returns to the device — the exact regression manual smoke just surfaced. Behavior matrix: - Flip A→B: dispatch `resetUserScopedState` (in-memory wipe), point storage at B's namespace, disconnect socket, restart. On relaunch, `userScopedStorage` seeds from `OPENHUMAN_ACTIVE_USER_ID=B` and rehydrates B's blob. - Sign-out (`clearSession`): same in-memory + socket cleanup, point storage at `null` (signed-out writes drop on the floor), no restart, and crucially NO purge — A's blob stays so re-login rehydrates it. - Bootstrap (signed-out → signed-in on cold launch): no restart, no reset; just seeds `OPENHUMAN_ACTIVE_USER_ID` so subsequent persist writes route to the new user's namespace. Updates the identity-flip Vitest to assert `setActiveUserId` is called with the right value at each transition (no purge spy anymore), and adds a fourth case covering the A→B→A round-trip: storage points back to A's namespace on return. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/providers/CoreStateProvider.tsx | 81 +++++++++++++------ .../CoreStateProvider.identityFlip.test.tsx | 70 ++++++++++++---- 2 files changed, 111 insertions(+), 40 deletions(-) diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index d4842323a2..cc45fe7f22 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -26,8 +26,9 @@ import { updateCoreLocalState, } from '../services/coreStateApi'; import { socketService } from '../services/socketService'; -import { persistor, store } from '../store'; +import { store } from '../store'; import { resetUserScopedState } from '../store/resetActions'; +import { setActiveUserId } from '../store/userScopedStorage'; import { openhumanUpdateAnalyticsSettings, restartApp, @@ -81,28 +82,39 @@ function snapshotIdentity(snapshot: CoreAppSnapshot): string | null { /** * Universal cleanup for identity changes (flip A→B, or sign-out). * - * Resets every user-scoped Redux slice via `resetUserScopedState`, drops the - * `persist:*` localStorage blobs (otherwise rehydration on the next render or - * launch leaks user A's slices into user B's session), and disconnects the - * live Socket.IO connection so the next reconnect carries the new auth token. + * 1. Re-points `userScopedStorage` to the new user's namespace (or `null` for + * sign-out). On the next cold launch — or right now for in-memory writes — + * redux-persist reads/writes blobs under `${nextUserId}:persist:*`. + * 2. Resets every user-scoped Redux slice via `resetUserScopedState` so the + * live store is empty before any rehydrate from the new namespace. + * 3. Disconnects the live Socket.IO connection so the reconnect carries the + * new user's auth token (fresh `client_id` server-side). + * 4. On a real flip (A→B), restarts the app so singleton services and + * Rust-side webview accounts pick up the new user dir. On sign-out, the + * signed-out UI is already empty — a relaunch would be jarring. * - * Pass `restart: true` for a real flip (A→B): a process restart is needed - * because singleton services and Rust-side webview accounts pin the prior - * user's state for the lifetime of the process. Pass `restart: false` for - * sign-out — the signed-out UI is empty, so a relaunch would be jarring. - * - * See [#900]. + * Note: 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: { restart: boolean; reason: string }): Promise { - const { restart, reason } = opts; - log('identity flip cleanup reason=%s restart=%s', reason, restart); +async function handleIdentityFlip(opts: { + restart: boolean; + reason: string; + nextUserId: string | null; +}): Promise { + const { restart, reason, nextUserId } = opts; + log( + 'identity flip cleanup reason=%s restart=%s nextUserId=%s', + reason, + restart, + nextUserId ? `****${nextUserId.slice(-4)}` : 'none' + ); + // Re-point storage BEFORE the in-memory reset so any stray persist write + // triggered between the reset dispatch and the restart goes to the new + // user's namespace (or is dropped when nextUserId is null). + setActiveUserId(nextUserId); store.dispatch(resetUserScopedState()); socketService.disconnect(); - try { - await persistor.purge(); - } catch (error) { - console.warn('[core-state] persistor.purge failed during identity flip:', error); - } if (restart) { await restartApp(); } @@ -209,13 +221,31 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) }); if (isFlip) { - await handleIdentityFlip({ restart: true, reason: 'refreshCore-flip' }).catch(err => { + await handleIdentityFlip({ + restart: true, + reason: 'refreshCore-flip', + nextUserId: nextIdentity, + }).catch(err => { log('handleIdentityFlip(flip) failed: %O', sanitizeError(err)); }); } else if (isLogout) { - await handleIdentityFlip({ restart: false, reason: 'refreshCore-logout' }).catch(err => { + await handleIdentityFlip({ + restart: false, + reason: 'refreshCore-logout', + nextUserId: null, + }).catch(err => { log('handleIdentityFlip(logout) failed: %O', sanitizeError(err)); }); + } else if ( + // First-paint bootstrap (signed-out → signed-in on cold launch): seed + // the active user id so subsequent persist writes route to this user's + // namespace. No restart, no Redux reset — bootstrap state is already + // correct. + !previousAuthed && + nextAuthed && + nextIdentity + ) { + setActiveUserId(nextIdentity); } syncAnalyticsConsent(snapshot.analyticsEnabled); @@ -465,11 +495,14 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) snapshot: toSignedOutSnapshot(previous.snapshot), })); memoryTokenRef.current = null; - // Reset every user-scoped slice + purge persist + drop the live socket + // Reset every user-scoped slice + drop the live socket + un-scope storage // before the tauriLogout RPC so user A's data is gone the moment the UI // re-renders signed-out (#900). No restart — signed-out UI is empty; - // the next storeSessionToken (login as B) will restart via refreshCore. - await handleIdentityFlip({ restart: false, reason: 'clearSession' }); + // the next storeSessionToken (login) will restart via refreshCore. + // We do NOT purge persist storage — A's blob stays at its namespaced key + // so when A returns to this device, their accounts/threads/notifications + // rehydrate. + await handleIdentityFlip({ restart: false, reason: 'clearSession', nextUserId: null }); 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 index 095ee34e52..b3503cb380 100644 --- a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx @@ -3,10 +3,11 @@ 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 { persistor, store } from '../../store'; +import { store } from '../../store'; import { addAccount } from '../../store/accountsSlice'; import { resetUserScopedState } from '../../store/resetActions'; import CoreStateProvider, { useCoreState } from '../CoreStateProvider'; @@ -106,12 +107,12 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { resetCoreStateStore(); // Reset Redux back to clean baseline before each test. store.dispatch(resetUserScopedState()); + userScopedStorage.setActiveUserId(null); }); - it('flip A→B: dispatches reset, purges persistor, disconnects socket, restarts app', async () => { + it('flip A→B: dispatches reset, re-points storage to B, disconnects socket, restarts app', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); - const dispatchSpy = vi.spyOn(store, 'dispatch'); - const purgeSpy = vi.spyOn(persistor, 'purge').mockResolvedValue(undefined); + const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); let ctx: CoreStateContextValue | undefined; @@ -126,28 +127,26 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { seedAccountsWithUserAData(); expect(store.getState().accounts.order).toContain('acct-A'); + setActiveSpy.mockClear(); fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'B', sessionToken: 'tokB' })); await act(async () => { await ctx!.refresh(); - // Allow the void-fired handleIdentityFlip to settle. await Promise.resolve(); await Promise.resolve(); }); - expect(dispatchSpy).toHaveBeenCalledWith(resetUserScopedState()); - expect(purgeSpy).toHaveBeenCalledTimes(1); + expect(setActiveSpy).toHaveBeenCalledWith('B'); expect(disconnectSpy).toHaveBeenCalledTimes(1); expect(restartApp).toHaveBeenCalledTimes(1); expect(store.getState().accounts.order).not.toContain('acct-A'); - dispatchSpy.mockRestore(); - purgeSpy.mockRestore(); + setActiveSpy.mockRestore(); disconnectSpy.mockRestore(); }); - it('clearSession: resets + purges + disconnects, but does NOT restart', async () => { + it('clearSession: resets + drops socket + un-scopes storage, no restart, NO purge', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); - const purgeSpy = vi.spyOn(persistor, 'purge').mockResolvedValue(undefined); + const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); let ctx: CoreStateContextValue | undefined; @@ -161,6 +160,7 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { }); seedAccountsWithUserAData(); + setActiveSpy.mockClear(); fetchSnapshot.mockResolvedValue( makeSnapshot({ userId: null, sessionToken: null, isAuthenticated: false }) ); @@ -168,18 +168,18 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { await ctx!.clearSession(); }); - expect(purgeSpy).toHaveBeenCalled(); + expect(setActiveSpy).toHaveBeenCalledWith(null); expect(disconnectSpy).toHaveBeenCalled(); expect(restartApp).not.toHaveBeenCalled(); expect(store.getState().accounts.order).not.toContain('acct-A'); - purgeSpy.mockRestore(); + setActiveSpy.mockRestore(); disconnectSpy.mockRestore(); }); - it('bootstrap (signed-out → signed-in): does NOT restart on first auth', async () => { + it('bootstrap (signed-out → signed-in): does NOT restart, seeds active user id', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); - const purgeSpy = vi.spyOn(persistor, 'purge').mockResolvedValue(undefined); + const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); let ctx: CoreStateContextValue | undefined; @@ -195,8 +195,46 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { }); expect(restartApp).not.toHaveBeenCalled(); + expect(setActiveSpy).toHaveBeenCalledWith('A'); + expect(disconnectSpy).not.toHaveBeenCalled(); - purgeSpy.mockRestore(); + setActiveSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); + + it('returning to user A: storage re-points to A, A blob namespace becomes active again', 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(); + }); + + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'B', sessionToken: 'tokB' })); + await act(async () => { + await ctx!.refresh(); + await Promise.resolve(); + }); + + setActiveSpy.mockClear(); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA2' })); + await act(async () => { + await ctx!.refresh(); + await Promise.resolve(); + }); + + // Each B→A flip re-points storage to A so A's persisted blob hydrates back. + expect(setActiveSpy).toHaveBeenCalledWith('A'); + expect(restartApp).toHaveBeenCalledTimes(2); + + setActiveSpy.mockRestore(); disconnectSpy.mockRestore(); }); }); From 0279aae06e576fb54a36eae7d993a4827f6ba757 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 14:43:33 +0530 Subject: [PATCH 07/19] feat(store): migrate legacy unscoped persist:* keys into user namespace (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-shot migration for users upgrading from pre-#900 builds. On the first `setActiveUserId(nonNull)` after launch (i.e. the first user to log in on the upgraded build), copy any pre-existing `persist:*` keys into `${id}:persist:*` and drop the legacy entries. Skips any key whose user-scoped twin already exists. Without this, upgrading users lose their account rail / thread list / notification cache shimmer on first launch — the legacy blob would orphan and the new namespace would be empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/store/userScopedStorage.ts | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/src/store/userScopedStorage.ts b/app/src/store/userScopedStorage.ts index 759194dcc2..b64f7f3d11 100644 --- a/app/src/store/userScopedStorage.ts +++ b/app/src/store/userScopedStorage.ts @@ -48,10 +48,14 @@ export function getActiveUserId(): string | null { * 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); } @@ -61,6 +65,39 @@ export function setActiveUserId(id: string | null): void { } } +/** + * 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}`; From 1c59d570290d63e5e2ba989128ec883b07d9f1da Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 15:52:16 +0530 Subject: [PATCH 08/19] fix(core-state): restart on different-user re-login via signed-out window (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous patch only restarted on auth-to-auth flips. The actual sign-out → sign-in path always routes through `clearSession`, which sets `auth.isAuthenticated=false` before the next user logs in — so the next refresh sees `previousAuthed=false` and never matched the flip branch. Without a restart, redux-persist's already-hydrated in-memory state from the launch-time namespace stayed in place and no rehydration ever fired against the new namespace, so a returning user (or a different user logging in after one) saw an empty UI even though their `${userId}:persist:*` blob was on disk. Adds a `lastAuthedUserIdRef` ref that records the userId whose data is currently in memory (set on initial auth landing and after a flip-restart). The refresh-time decision tree is now: - auth → auth, identities differ → restart-class flip - signed-out → signed-in, ref !== nextId → re-login as different user (restart so redux-persist re-hydrates from the new namespace) - signed-out → signed-in, ref === nextId or ref is null → cold bootstrap or same-user re-login. No restart; just seed `setActiveUserId(nextId)` and update the ref. - auth → signed-out (logout): drop active user id, disconnect socket, do NOT dispatch `resetUserScopedState`. Keeping in-memory slice data lets a same-user re-login keep showing accounts/threads/ notifications without a costly restart, and the signed-out UI doesn't render those slices anyway. `clearSession` correspondingly drops its prior `handleIdentityFlip` call: just signs out the snapshot, calls `setActiveUserId(null)`, and does the tauriLogout RPC. The `handleIdentityFlip` helper is now restart-only (the `restart: false` mode was removed). Tests cover all six paths (cold bootstrap, auth-flip, logout, same- user re-login, different-user re-login, A→B→A round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/providers/CoreStateProvider.tsx | 143 ++++++++++-------- .../CoreStateProvider.identityFlip.test.tsx | 115 ++++++++++++-- 2 files changed, 179 insertions(+), 79 deletions(-) diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index cc45fe7f22..1d12c209f4 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -80,44 +80,38 @@ function snapshotIdentity(snapshot: CoreAppSnapshot): string | null { } /** - * Universal cleanup for identity changes (flip A→B, or sign-out). + * Restart-class cleanup for identity changes that require a process relaunch + * to re-hydrate redux-persist from the new user's namespace. * - * 1. Re-points `userScopedStorage` to the new user's namespace (or `null` for - * sign-out). On the next cold launch — or right now for in-memory writes — - * redux-persist reads/writes blobs under `${nextUserId}:persist:*`. - * 2. Resets every user-scoped Redux slice via `resetUserScopedState` so the - * live store is empty before any rehydrate from the new namespace. - * 3. Disconnects the live Socket.IO connection so the reconnect carries the - * new user's auth token (fresh `client_id` server-side). - * 4. On a real flip (A→B), restarts the app so singleton services and - * Rust-side webview accounts pick up the new user dir. On sign-out, the - * signed-out UI is already empty — a relaunch would be jarring. + * 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. * - * Note: 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]. + * 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: { - restart: boolean; - reason: string; - nextUserId: string | null; -}): Promise { - const { restart, reason, nextUserId } = opts; - log( - 'identity flip cleanup reason=%s restart=%s nextUserId=%s', - reason, - restart, - nextUserId ? `****${nextUserId.slice(-4)}` : 'none' - ); - // Re-point storage BEFORE the in-memory reset so any stray persist write - // triggered between the reset dispatch and the restart goes to the new - // user's namespace (or is dropped when nextUserId is null). +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(); - if (restart) { - await restartApp(); - } + await restartApp(); } function normalizeSnapshot( @@ -167,6 +161,13 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const logoutGuardUntilRef = useRef(0); const bootstrapFailCountRef = useRef(0); const refreshInFlightRef = useRef | null>(null); + // The userId whose `${id}:persist:*` blob is currently in memory. Set on + // first auth landing (cold bootstrap) and after a successful flip-restart. + // Used to decide whether a signed-out → signed-in transition is a real + // re-login (data is on disk under a different namespace, restart to + // re-hydrate) or a same-user re-login (in-memory state survived a logout + // window because we don't reset on logout). See [#900]. + const lastAuthedUserIdRef = useRef(null); const commitState = useCallback((updater: (previous: CoreState) => CoreState) => { setState(previous => { @@ -194,15 +195,27 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const nextIdentity = snapshotIdentity(nextSnapshot); const previousAuthed = beforeCommit.auth.isAuthenticated; const nextAuthed = nextSnapshot.auth.isAuthenticated; - // Only flag a flip when BOTH sides are authenticated and identities differ. - // Bootstrap (signed-out → signed-in) and logout transitions are handled - // separately so we never restart-loop on launch (#900). - const isFlip = + // Auth-to-auth identity flip (e.g., one window force-switches user mid-session). + const isAuthFlip = Boolean(previousAuthed) && nextAuthed && Boolean(previousIdentity) && previousIdentity !== nextIdentity; + // Re-login as a DIFFERENT user (signed-out → signed-in, current id != + // last-seen). The clearSession → storeSessionToken sequence always + // routes through this branch since clearSession sets isAuthenticated=false. + const isReLoginFlip = + !previousAuthed && + nextAuthed && + Boolean(nextIdentity) && + lastAuthedUserIdRef.current !== null && + lastAuthedUserIdRef.current !== nextIdentity; + const isFlip = isAuthFlip || isReLoginFlip; const isLogout = Boolean(previousAuthed) && !nextAuthed; + const isInitialAuth = !previousAuthed && nextAuthed && Boolean(nextIdentity) && !isReLoginFlip; + // Clear team caches whenever the visible identity changes (any direction) + // so the post-commit UI doesn't show A's team list during the brief + // signed-out window or B's session. const shouldClearScopedCaches = isFlip || isLogout || previousIdentity !== nextIdentity; commitState(previous => { @@ -220,32 +233,33 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) }; }); - if (isFlip) { + if (isFlip && nextIdentity) { + // Track the new identity before triggering restart so that a test (or + // a no-op restartApp mock) sees the ref updated; in production the + // page reloads and the ref is rebuilt on next mount anyway. + lastAuthedUserIdRef.current = nextIdentity; await handleIdentityFlip({ - restart: true, - reason: 'refreshCore-flip', + reason: isAuthFlip ? 'auth-flip' : 'relogin-different-user', nextUserId: nextIdentity, }).catch(err => { - log('handleIdentityFlip(flip) failed: %O', sanitizeError(err)); + log('handleIdentityFlip failed: %O', sanitizeError(err)); }); } else if (isLogout) { - await handleIdentityFlip({ - restart: false, - reason: 'refreshCore-logout', - nextUserId: null, - }).catch(err => { - log('handleIdentityFlip(logout) failed: %O', sanitizeError(err)); - }); - } else if ( - // First-paint bootstrap (signed-out → signed-in on cold launch): seed - // the active user id so subsequent persist writes route to this user's - // namespace. No restart, no Redux reset — bootstrap state is already - // correct. - !previousAuthed && - nextAuthed && - nextIdentity - ) { + // Sign-out: keep slice data in memory (signed-out UI doesn't expose + // it) so a same-user re-login can keep showing the user's accounts / + // threads / notifications without a costly process restart. Drop the + // active user id so any stray persist write during the signed-out + // window is a no-op, and disconnect the live socket since the token + // it was authed with has been invalidated by the core. + setActiveUserId(null); + socketService.disconnect(); + } else if (isInitialAuth && nextIdentity) { + // Cold bootstrap OR same-user re-login (signed-out → signed-in where + // the next id matches the last-seen id). Either way redux-persist's + // initial hydrate already loaded the right namespace into memory, so + // just seed the active user id and continue. setActiveUserId(nextIdentity); + lastAuthedUserIdRef.current = nextIdentity; } syncAnalyticsConsent(snapshot.analyticsEnabled); @@ -495,14 +509,15 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) snapshot: toSignedOutSnapshot(previous.snapshot), })); memoryTokenRef.current = null; - // Reset every user-scoped slice + drop the live socket + un-scope storage - // before the tauriLogout RPC so user A's data is gone the moment the UI - // re-renders signed-out (#900). No restart — signed-out UI is empty; - // the next storeSessionToken (login) will restart via refreshCore. - // We do NOT purge persist storage — A's blob stays at its namespaced key - // so when A returns to this device, their accounts/threads/notifications - // rehydrate. - await handleIdentityFlip({ restart: false, reason: 'clearSession', nextUserId: null }); + // Drop the active user id so any stray persist write during the signed- + // out window (e.g., before tauriLogout completes) is a no-op rather than + // landing in the prior user's namespace. We deliberately do NOT dispatch + // `resetUserScopedState` here — keeping in-memory slice data intact lets + // a same-user re-login (immediate or after an OAuth round-trip) keep + // showing the user's accounts/threads without a process restart. The + // signed-out UI doesn't render those slices anyway, so there's no leak. + // See [#900]. + setActiveUserId(null); 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 index b3503cb380..7edcc433d7 100644 --- a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx @@ -105,12 +105,34 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { restartApp.mockReset(); restartApp.mockResolvedValue(undefined); resetCoreStateStore(); - // Reset Redux back to clean baseline before each test. store.dispatch(resetUserScopedState()); userScopedStorage.setActiveUserId(null); }); - it('flip A→B: dispatches reset, re-points storage to B, disconnects socket, restarts app', async () => { + it('cold bootstrap (signed-out → signed-in, no prior auth this session): no restart, seeds active user id', 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(restartApp).not.toHaveBeenCalled(); + expect(setActiveSpy).toHaveBeenCalledWith('A'); + expect(disconnectSpy).not.toHaveBeenCalled(); + + setActiveSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); + + it('auth-to-auth flip (A→B without intermediate logout): restarts and re-points to B', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); @@ -132,7 +154,6 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { await act(async () => { await ctx!.refresh(); await Promise.resolve(); - await Promise.resolve(); }); expect(setActiveSpy).toHaveBeenCalledWith('B'); @@ -144,7 +165,7 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { disconnectSpy.mockRestore(); }); - it('clearSession: resets + drops socket + un-scopes storage, no restart, NO purge', async () => { + it('logout: drops active user id + disconnects socket; does NOT reset slice data or restart', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); @@ -165,19 +186,20 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { makeSnapshot({ userId: null, sessionToken: null, isAuthenticated: false }) ); await act(async () => { - await ctx!.clearSession(); + await ctx!.refresh(); }); expect(setActiveSpy).toHaveBeenCalledWith(null); - expect(disconnectSpy).toHaveBeenCalled(); + expect(disconnectSpy).toHaveBeenCalledTimes(1); expect(restartApp).not.toHaveBeenCalled(); - expect(store.getState().accounts.order).not.toContain('acct-A'); + // Slice data preserved across logout — same-user re-login keeps the UI shimmer. + expect(store.getState().accounts.order).toContain('acct-A'); setActiveSpy.mockRestore(); disconnectSpy.mockRestore(); }); - it('bootstrap (signed-out → signed-in): does NOT restart, seeds active user id', async () => { + it('same-user re-login (A→logout→A): no restart, no reset', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); @@ -190,19 +212,71 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { ); await act(async () => { await ctx!.refresh(); - await Promise.resolve(); - await Promise.resolve(); + }); + 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(restartApp).not.toHaveBeenCalled(); expect(setActiveSpy).toHaveBeenCalledWith('A'); - expect(disconnectSpy).not.toHaveBeenCalled(); + expect(restartApp).not.toHaveBeenCalled(); + // Slice data still there from before the logout window. + expect(store.getState().accounts.order).toContain('acct-A'); + + setActiveSpy.mockRestore(); + disconnectSpy.mockRestore(); + }); + + it('different-user re-login (A→logout→B): restarts, re-points to B', 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(); + }); + 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(); }); - it('returning to user A: storage re-points to A, A blob namespace becomes active again', async () => { + it('round-trip A→B→A: each different-user flip restarts, storage re-points correctly', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA' })); const setActiveSpy = vi.spyOn(userScopedStorage, 'setActiveUserId'); const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); @@ -217,11 +291,23 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { await ctx!.refresh(); }); + fetchSnapshot.mockResolvedValue( + makeSnapshot({ userId: null, sessionToken: null, isAuthenticated: false }) + ); + await act(async () => { + await ctx!.refresh(); + }); fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'B', sessionToken: 'tokB' })); await act(async () => { await ctx!.refresh(); await Promise.resolve(); }); + fetchSnapshot.mockResolvedValue( + makeSnapshot({ userId: null, sessionToken: null, isAuthenticated: false }) + ); + await act(async () => { + await ctx!.refresh(); + }); setActiveSpy.mockClear(); fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'A', sessionToken: 'tokA2' })); @@ -230,9 +316,8 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { await Promise.resolve(); }); - // Each B→A flip re-points storage to A so A's persisted blob hydrates back. expect(setActiveSpy).toHaveBeenCalledWith('A'); - expect(restartApp).toHaveBeenCalledTimes(2); + expect(restartApp).toHaveBeenCalledTimes(2); // once for A→B, once for B→A setActiveSpy.mockRestore(); disconnectSpy.mockRestore(); From e868272fed4bdc6546d2e0bda8794e32182cb34c Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 15:59:41 +0530 Subject: [PATCH 09/19] fix(core-state): unify flip detection on lastRef !== nextId regardless of prev state (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous patch's flip detection was split across `isAuthFlip` (prev authed && next authed && id differs) and `isReLoginFlip` (prev NOT authed && next authed && lastRef differs). The deep-link OAuth path falls through both: the synchronous pre-refresh commit in `onSessionTokenUpdated` flips `auth.isAuthenticated=true` BEFORE `refreshCore` runs, so by the time we classify, `previousAuthed` is true but `previousIdentity` is still null (the deep-link payload only carries a token, not a userId). `isAuthFlip` requires `previousIdentity` truthy → false. `isReLoginFlip` requires `!previousAuthed` → false. Neither branch fires, no restart, the prior user's slices stay in memory and bleed into the new session. Replaces the two-branch detection with one rule: a flip is "the in-memory data is for a different userId than the one that just authenticated." `lastAuthedUserIdRef` tracks whose namespace redux-persist hydrated, and any non-null nextIdentity that differs from it triggers a restart-class flip — uniformly across direct `storeSessionToken` calls, deep-link events, and poll-detected swaps. The ref is initialized from `getActiveUserId()` so even on the first refresh after mount, an in-memory user inherited from redux-persist's module-init pass is detected and forced to flip when a different user logs in. Tests still cover all six paths and pass unchanged: cold bootstrap, auth-flip, logout, same-user re-login, different-user re-login, A→B→A round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/providers/CoreStateProvider.tsx | 70 ++++++++++++------------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 1d12c209f4..cc75c61db4 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -28,7 +28,7 @@ import { import { socketService } from '../services/socketService'; import { store } from '../store'; import { resetUserScopedState } from '../store/resetActions'; -import { setActiveUserId } from '../store/userScopedStorage'; +import { getActiveUserId, setActiveUserId } from '../store/userScopedStorage'; import { openhumanUpdateAnalyticsSettings, restartApp, @@ -161,13 +161,12 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const logoutGuardUntilRef = useRef(0); const bootstrapFailCountRef = useRef(0); const refreshInFlightRef = useRef | null>(null); - // The userId whose `${id}:persist:*` blob is currently in memory. Set on - // first auth landing (cold bootstrap) and after a successful flip-restart. - // Used to decide whether a signed-out → signed-in transition is a real - // re-login (data is on disk under a different namespace, restart to - // re-hydrate) or a same-user re-login (in-memory state survived a logout - // window because we don't reset on logout). See [#900]. - const lastAuthedUserIdRef = useRef(null); + // The userId whose `${id}:persist:*` blob is currently in memory. Initialized + // from `getActiveUserId()` so the first refresh after mount knows which + // namespace was hydrated by redux-persist's module-init pass — a different + // userId landing means we have to restart so persistor re-hydrates from the + // new namespace. See [#900]. + const lastAuthedUserIdRef = useRef(getActiveUserId()); const commitState = useCallback((updater: (previous: CoreState) => CoreState) => { setState(previous => { @@ -195,27 +194,28 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const nextIdentity = snapshotIdentity(nextSnapshot); const previousAuthed = beforeCommit.auth.isAuthenticated; const nextAuthed = nextSnapshot.auth.isAuthenticated; - // Auth-to-auth identity flip (e.g., one window force-switches user mid-session). - const isAuthFlip = - Boolean(previousAuthed) && - nextAuthed && - Boolean(previousIdentity) && - previousIdentity !== nextIdentity; - // Re-login as a DIFFERENT user (signed-out → signed-in, current id != - // last-seen). The clearSession → storeSessionToken sequence always - // routes through this branch since clearSession sets isAuthenticated=false. - const isReLoginFlip = - !previousAuthed && - nextAuthed && + // Identity flip detection is purely "is the in-memory data for a different + // user than the one who just authenticated?". `lastAuthedUserIdRef` tracks + // whose namespace redux-persist hydrated. If a different non-null userId + // lands, we have to restart so persistor re-hydrates from the new + // namespace. This rule covers every login path uniformly: + // - direct `storeSessionToken` (Tauri OAuth) + // - deep-link `core-state:session-token-updated` (where the synchronous + // pre-refresh commit already flipped `auth.isAuthenticated=true`, so + // `previousAuthed` is true but `previousIdentity` is still null — + // previous-state-based heuristics get fooled) + // - poll-detected flip (core-side user swap) + const isFlip = Boolean(nextIdentity) && lastAuthedUserIdRef.current !== null && lastAuthedUserIdRef.current !== nextIdentity; - const isFlip = isAuthFlip || isReLoginFlip; const isLogout = Boolean(previousAuthed) && !nextAuthed; - const isInitialAuth = !previousAuthed && nextAuthed && Boolean(nextIdentity) && !isReLoginFlip; - // Clear team caches whenever the visible identity changes (any direction) - // so the post-commit UI doesn't show A's team list during the brief - // signed-out window or B's session. + // Cold bootstrap OR same-user re-login: a userId landed and it matches + // (or is the first ever lookup against a null ref). + const isInitialOrSameUserAuth = Boolean(nextIdentity) && !isFlip; + // 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 => { @@ -234,30 +234,26 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) }); if (isFlip && nextIdentity) { - // Track the new identity before triggering restart so that a test (or - // a no-op restartApp mock) sees the ref updated; in production the - // page reloads and the ref is rebuilt on next mount anyway. + // Track the new identity before triggering restart so a test (or a + // no-op restartApp mock) sees the ref updated; in production the page + // reloads and the ref is rebuilt from `getActiveUserId()` on next mount. lastAuthedUserIdRef.current = nextIdentity; - await handleIdentityFlip({ - reason: isAuthFlip ? 'auth-flip' : 'relogin-different-user', - nextUserId: nextIdentity, - }).catch(err => { + await handleIdentityFlip({ reason: 'identity-flip', nextUserId: nextIdentity }).catch(err => { log('handleIdentityFlip failed: %O', sanitizeError(err)); }); } else if (isLogout) { // Sign-out: keep slice data in memory (signed-out UI doesn't expose - // it) so a same-user re-login can keep showing the user's accounts / + // it) so a same-user re-login keeps showing the user's accounts / // threads / notifications without a costly process restart. Drop the // active user id so any stray persist write during the signed-out // window is a no-op, and disconnect the live socket since the token // it was authed with has been invalidated by the core. setActiveUserId(null); socketService.disconnect(); - } else if (isInitialAuth && nextIdentity) { - // Cold bootstrap OR same-user re-login (signed-out → signed-in where - // the next id matches the last-seen id). Either way redux-persist's + } else if (isInitialOrSameUserAuth && nextIdentity) { + // Cold bootstrap OR same-user re-login. Either way redux-persist's // initial hydrate already loaded the right namespace into memory, so - // just seed the active user id and continue. + // just seed the active user id and the ref and continue. setActiveUserId(nextIdentity); lastAuthedUserIdRef.current = nextIdentity; } From 4ba4f6d9d6c93dfe2e73d25c658ed1fb4568e625 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 16:21:56 +0530 Subject: [PATCH 10/19] fix(core-state): force restart on cold-bootstrap so first user CEF profile is correct (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous detection used a React-level `lastAuthedUserIdRef` that was seeded from `getActiveUserId()` at mount time but updated locally on each landing — racy across mount/unmount and missed the cold-bootstrap case where the FIRST user logging in needs a restart so the Rust `prepare_process_cache_path` re-evaluates `active_user.toml` and points CEF at `users//cef` (instead of the pre-login `local` fallback bucket where the very first Slack/WhatsApp tile add would otherwise dump cookies that then leak across users). Replaces the ref with a single rule: source of truth is the `OPENHUMAN_ACTIVE_USER_ID` localStorage seed read by `userScopedStorage`. If the userId that just authenticated differs from the seed (or the seed is null on a fresh device), restart. This rule covers every login path uniformly: - cold bootstrap on a fresh install (seed=null, nextId=A → restart) - direct `storeSessionToken` (Tauri OAuth) - deep-link `core-state:session-token-updated` - poll-detected flip - re-login as a different user after sign-out Logout no longer clears the seed: keeping it pointing at the last user lets the next refresh distinguish a same-user re-login (no restart) from a different-user re-login (restart). `clearSession` correspondingly drops its prior `setActiveUserId(null)` call. Tests cover all six paths: cold bootstrap, warm launch (seed match), auth-flip, logout, same-user re-login, different-user re-login. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/providers/CoreStateProvider.tsx | 82 +++++++--------- .../CoreStateProvider.identityFlip.test.tsx | 95 +++++++------------ 2 files changed, 68 insertions(+), 109 deletions(-) diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index cc75c61db4..051d60c497 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -161,13 +161,6 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const logoutGuardUntilRef = useRef(0); const bootstrapFailCountRef = useRef(0); const refreshInFlightRef = useRef | null>(null); - // The userId whose `${id}:persist:*` blob is currently in memory. Initialized - // from `getActiveUserId()` so the first refresh after mount knows which - // namespace was hydrated by redux-persist's module-init pass — a different - // userId landing means we have to restart so persistor re-hydrates from the - // new namespace. See [#900]. - const lastAuthedUserIdRef = useRef(getActiveUserId()); - const commitState = useCallback((updater: (previous: CoreState) => CoreState) => { setState(previous => { const next = updater(previous); @@ -194,25 +187,26 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const nextIdentity = snapshotIdentity(nextSnapshot); const previousAuthed = beforeCommit.auth.isAuthenticated; const nextAuthed = nextSnapshot.auth.isAuthenticated; - // Identity flip detection is purely "is the in-memory data for a different - // user than the one who just authenticated?". `lastAuthedUserIdRef` tracks - // whose namespace redux-persist hydrated. If a different non-null userId - // lands, we have to restart so persistor re-hydrates from the new - // namespace. This rule covers every login path uniformly: + // 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` (where the synchronous - // pre-refresh commit already flipped `auth.isAuthenticated=true`, so - // `previousAuthed` is true but `previousIdentity` is still null — - // previous-state-based heuristics get fooled) + // - deep-link `core-state:session-token-updated` // - poll-detected flip (core-side user swap) - const isFlip = - Boolean(nextIdentity) && - lastAuthedUserIdRef.current !== null && - lastAuthedUserIdRef.current !== nextIdentity; + // - re-login as a different user after sign-out + const seedUserId = getActiveUserId(); + const isFlip = Boolean(nextIdentity) && seedUserId !== nextIdentity; const isLogout = Boolean(previousAuthed) && !nextAuthed; - // Cold bootstrap OR same-user re-login: a userId landed and it matches - // (or is the first ever lookup against a null ref). - const isInitialOrSameUserAuth = Boolean(nextIdentity) && !isFlip; // 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. @@ -234,29 +228,21 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) }); if (isFlip && nextIdentity) { - // Track the new identity before triggering restart so a test (or a - // no-op restartApp mock) sees the ref updated; in production the page - // reloads and the ref is rebuilt from `getActiveUserId()` on next mount. - lastAuthedUserIdRef.current = nextIdentity; await handleIdentityFlip({ reason: 'identity-flip', nextUserId: nextIdentity }).catch(err => { log('handleIdentityFlip failed: %O', sanitizeError(err)); }); } else if (isLogout) { - // Sign-out: keep slice data in memory (signed-out UI doesn't expose - // it) so a same-user re-login keeps showing the user's accounts / - // threads / notifications without a costly process restart. Drop the - // active user id so any stray persist write during the signed-out - // window is a no-op, and disconnect the live socket since the token - // it was authed with has been invalidated by the core. - setActiveUserId(null); + // 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(); - } else if (isInitialOrSameUserAuth && nextIdentity) { - // Cold bootstrap OR same-user re-login. Either way redux-persist's - // initial hydrate already loaded the right namespace into memory, so - // just seed the active user id and the ref and continue. - setActiveUserId(nextIdentity); - lastAuthedUserIdRef.current = nextIdentity; } + // 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) { @@ -505,15 +491,13 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) snapshot: toSignedOutSnapshot(previous.snapshot), })); memoryTokenRef.current = null; - // Drop the active user id so any stray persist write during the signed- - // out window (e.g., before tauriLogout completes) is a no-op rather than - // landing in the prior user's namespace. We deliberately do NOT dispatch - // `resetUserScopedState` here — keeping in-memory slice data intact lets - // a same-user re-login (immediate or after an OAuth round-trip) keep - // showing the user's accounts/threads without a process restart. The - // signed-out UI doesn't render those slices anyway, so there's no leak. - // See [#900]. - setActiveUserId(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 index 7edcc433d7..04a8e36fc2 100644 --- a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx @@ -109,7 +109,7 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { userScopedStorage.setActiveUserId(null); }); - it('cold bootstrap (signed-out → signed-in, no prior auth this session): no restart, seeds active user id', async () => { + 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(() => {}); @@ -124,15 +124,36 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { await ctx!.refresh(); }); - expect(restartApp).not.toHaveBeenCalled(); expect(setActiveSpy).toHaveBeenCalledWith('A'); - expect(disconnectSpy).not.toHaveBeenCalled(); + expect(restartApp).toHaveBeenCalledTimes(1); setActiveSpy.mockRestore(); disconnectSpy.mockRestore(); }); - it('auth-to-auth flip (A→B without intermediate logout): restarts and re-points to B', async () => { + 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(() => {}); @@ -165,7 +186,8 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { disconnectSpy.mockRestore(); }); - it('logout: drops active user id + disconnects socket; does NOT reset slice data or restart', async () => { + 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(() => {}); @@ -189,20 +211,21 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { await ctx!.refresh(); }); - expect(setActiveSpy).toHaveBeenCalledWith(null); + // 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(); - // Slice data preserved across logout — same-user re-login keeps the UI shimmer. expect(store.getState().accounts.order).toContain('acct-A'); setActiveSpy.mockRestore(); disconnectSpy.mockRestore(); }); - it('same-user re-login (A→logout→A): no restart, no reset', async () => { + 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'); - const disconnectSpy = vi.spyOn(socketService, 'disconnect').mockImplementation(() => {}); let ctx: CoreStateContextValue | undefined; render( @@ -228,16 +251,15 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { await ctx!.refresh(); }); - expect(setActiveSpy).toHaveBeenCalledWith('A'); + expect(setActiveSpy).not.toHaveBeenCalled(); expect(restartApp).not.toHaveBeenCalled(); - // Slice data still there from before the logout window. expect(store.getState().accounts.order).toContain('acct-A'); setActiveSpy.mockRestore(); - disconnectSpy.mockRestore(); }); - it('different-user re-login (A→logout→B): restarts, re-points to B', async () => { + 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(() => {}); @@ -275,51 +297,4 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { setActiveSpy.mockRestore(); disconnectSpy.mockRestore(); }); - - it('round-trip A→B→A: each different-user flip restarts, storage re-points correctly', 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(); - }); - - fetchSnapshot.mockResolvedValue( - makeSnapshot({ userId: null, sessionToken: null, isAuthenticated: false }) - ); - await act(async () => { - await ctx!.refresh(); - }); - fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'B', sessionToken: 'tokB' })); - await act(async () => { - await ctx!.refresh(); - await Promise.resolve(); - }); - 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(); - await Promise.resolve(); - }); - - expect(setActiveSpy).toHaveBeenCalledWith('A'); - expect(restartApp).toHaveBeenCalledTimes(2); // once for A→B, once for B→A - - setActiveSpy.mockRestore(); - disconnectSpy.mockRestore(); - }); }); From d996009d23d489ed8dfc01100ed0fd9d9ca9933c Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 16:22:06 +0530 Subject: [PATCH 11/19] fix(cef-profile): purge stale pre-login `local` CEF cache on launch with real user (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the very first cold launch on a fresh install ran without an `active_user.toml` (no user logged in yet), `prepare_process_cache_path` fell back to the `users/local/cef` bucket. If the user added a webview-account tile (Slack, WhatsApp, …) BEFORE the frontend triggered a flip-restart, those third-party cookies landed in the `local` bucket and survived across users. With the matching frontend change (cold-bootstrap now forces a restart on first auth), this flow is much rarer — but any pre-existing device that already has data in `users/local/cef` from before the fix shipped is still vulnerable. Purge it synchronously at the start of `prepare_process_cache_path` whenever a real user (not the `local` sentinel) is active. The delete runs before CEF init, so it's safe. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src-tauri/src/cef_profile.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src-tauri/src/cef_profile.rs b/app/src-tauri/src/cef_profile.rs index 37eba115c2..825360dd44 100644 --- a/app/src-tauri/src/cef_profile.rs +++ b/app/src-tauri/src/cef_profile.rs @@ -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) } From 992eb4b355d427a35b9c893bef839142c4d27969 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 23:44:46 +0530 Subject: [PATCH 12/19] fix(perms): allow restart_app + get_active_user_id + schedule_cef_profile_purge (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri v2 silently denies any `invoke()` for a command not listed in a capability allowlist. The cross-user state-leak fix calls all three from the frontend, so the prior implementation looked correct in code but the underlying `app.restart()` / profile-purge / active-user lookups never actually ran — webviews kept the prior user's third-party cookies and the boot prime had no way to read the authoritative active user id. Closes the silent-deny class of failures for the #900 fix path. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src-tauri/permissions/allow-core-process.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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", From 1ecc5c4f4b7309740c0d1e40a01841ff73c992ae Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 23:44:55 +0530 Subject: [PATCH 13/19] fix(webview-accounts): use tauri::async_runtime::spawn for teardown (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `teardown_account_scanners` ran `tokio::spawn(...)` from the CEF main thread to drop per-provider scanner handles asynchronously. CEF's main thread does not host a Tokio runtime in its thread-local, so `tokio::spawn` panics with "panic in a function that cannot unwind" and the panic propagates as SIGABRT — visible to the user as a generic crash dialog after every identity-flip restart. `tauri::async_runtime::spawn` resolves the executor from the Tauri runtime that the host is built against, so it works regardless of which thread invokes it. This is the same primitive Tauri uses internally for command futures, which is why the rest of this file was already on it; the four teardown sites were the outliers. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src-tauri/src/webview_accounts/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 }); } } From c6c6f7d733e0ee71a4d2c48d8a018f4e2be59afa Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 23:45:05 +0530 Subject: [PATCH 14/19] refactor(cef-profile): expose default_root_openhuman_dir + read_active_user_id (#900) These helpers were private when `cef_profile` was the only consumer. The boot-prime command (Tauri side) and the window-state module both need to resolve the OpenHuman root directory and read the authoritative active user id from `active_user.toml`, so promote them to `pub` rather than duplicate the `OPENHUMAN_WORKSPACE` / HOME-fallback logic at three call sites. No behavior change in the existing module. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src-tauri/src/cef_profile.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/cef_profile.rs b/app/src-tauri/src/cef_profile.rs index 825360dd44..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()?; From 3a564bb232dcc5fec281398c4280540f54fe25bb Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 23:45:16 +0530 Subject: [PATCH 15/19] feat(app): add get_active_user_id Tauri command (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads `~/.openhuman/active_user.toml` (or `$OPENHUMAN_WORKSPACE/active_user.toml`) and returns the active user id. Lets the React boot path seed `userScopedStorage` from the profile-independent source of truth BEFORE redux-persist hydrates. The previous frontend-only seed was a `localStorage` key — bound to the per-user CEF profile dir. Every restart-driven user flip caused the new process to read the previous session's value from whatever profile the new CEF instance was reading from, so seed and next id disagreed and the flip-detection re-fired into a second restart, and a third, and so on (the restart loop on login). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src-tauri/src/lib.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d87805af11..37c0f85c07 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -321,6 +321,24 @@ async fn restart_app(app: tauri::AppHandle) -> Result<(), String> { 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())?; @@ -1299,6 +1317,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, From 63ff86592c02dd4cf2eff2c11e9e2d26bc416e6c Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 23:45:40 +0530 Subject: [PATCH 16/19] fix(window-state): persist + restore main window across app.restart() (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three secondary issues observed during the identity-flip restart that weren't visible before the auth-leak fix landed (because `restart_app` was silently denied and never fired): 1. The new window snapped back to the default centered initial size on the primary monitor — even when the user had moved or resized it on an external display. 2. The macOS WindowServer briefly painted black on the (now defunct) display layer between the old process exiting and the new one showing its window. 3. The window respawn jumped to whatever monitor the OS picked first. Fix: - New `window_state` module persists outer position + outer size to `/window_state.toml` and reads it back at setup time. - Position restore is gated on `position_visible_on_any_monitor` so an undocked external display can't strand the window off-screen; unmatched state falls back to a centered default. - `restart_app` saves geometry, hides the window, then exits — hiding *before* the process dies lets WindowServer release the layer cleanly so there's no black flash on the old display. - `tauri.conf.json` ships the main window with `visible: false` and `center: false`. The setup hook applies the restored geometry and *then* calls `window.show()`, so the user never sees a centered first paint that snaps to its real position. The lag observed on the third restart is not addressed here — likely debug-build CEF cold-init overhead; will verify in release. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src-tauri/src/lib.rs | 30 +++++ app/src-tauri/src/window_state.rs | 192 ++++++++++++++++++++++++++++++ app/src-tauri/tauri.conf.json | 4 +- 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 app/src-tauri/src/window_state.rs diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 37c0f85c07..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,6 +319,17 @@ 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(); } @@ -980,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(); 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": { From 54d19a3fee6a3b6f37558c19ca84f0776ba0770c Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 23:45:52 +0530 Subject: [PATCH 17/19] feat(tauri-commands): getActiveUserIdFromCore wrapper (#900) Thin wrapper over the new `get_active_user_id` Tauri command. Returns `null` outside Tauri (web preview) and on any RPC error so the boot path can fall through to a no-prime initial render without crashing. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/utils/tauriCommands/core.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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. */ From 40ffd65e96bdbce2134255fe6c05d7446dcea70a Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 23:46:03 +0530 Subject: [PATCH 18/19] feat(store): gate userScopedStorage on boot prime (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a one-shot `primeActiveUserId(id)` entry point and a Promise gate that storage methods await before resolving the namespace. The methods are now async (redux-persist's storage contract is async, so this is a no-op for callers). Why: `localStorage` lives inside the active CEF profile dir. After an identity-flip restart, the new process boots with a different CEF user-data-dir, so the previous module-load read of `OPENHUMAN_ACTIVE_USER_ID` resolves the *new* user's localStorage — which is empty on a never-before-seen user and stale on a previously active one. Without the gate, redux-persist's first rehydrate read beat any subsequent re-pin, the wrong namespace was selected, and `refreshCore` mis-detected a flip on the very next snapshot, kicking off a second restart. The gate lets `main.tsx` await `getActiveUserIdFromCore()` (which reads the Rust-authoritative `active_user.toml`) before any redux-persist storage call resolves, so the namespace is pinned to the correct user from the very first read. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/store/userScopedStorage.ts | 60 +++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/app/src/store/userScopedStorage.ts b/app/src/store/userScopedStorage.ts index b64f7f3d11..62a243cccd 100644 --- a/app/src/store/userScopedStorage.ts +++ b/app/src/store/userScopedStorage.ts @@ -31,6 +31,45 @@ function safeGetActiveUserIdSync(): string | 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. @@ -108,33 +147,34 @@ function namespacedKey(key: string): string | null { * Methods return promises because redux-persist treats storage as async. */ export const userScopedStorage = { - getItem(key: string): Promise { + async getItem(key: string): Promise { + await activeUserIdReady; const ns = namespacedKey(key); - if (!ns) return Promise.resolve(null); + if (!ns) return null; try { - return Promise.resolve(localStorage.getItem(ns)); + return localStorage.getItem(ns); } catch { - return Promise.resolve(null); + return null; } }, - setItem(key: string, value: string): Promise { + async setItem(key: string, value: string): Promise { + await activeUserIdReady; const ns = namespacedKey(key); - if (!ns) return Promise.resolve(); + if (!ns) return; try { localStorage.setItem(ns, value); } catch { // ignore quota / unavailable } - return Promise.resolve(); }, - removeItem(key: string): Promise { + async removeItem(key: string): Promise { + await activeUserIdReady; const ns = namespacedKey(key); - if (!ns) return Promise.resolve(); + if (!ns) return; try { localStorage.removeItem(ns); } catch { // ignore } - return Promise.resolve(); }, }; From 2ff7039b682938686c69c76df34a860d3bd3b30f Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 28 Apr 2026 23:46:11 +0530 Subject: [PATCH 19/19] fix(boot): prime userScopedStorage from Rust before render (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the React render in a `getActiveUserIdFromCore().then(prime).finally(boot)` chain so redux-persist's storage adapter sees the right namespace from the very first call. The auth token and Redux slice rehydration both depend on this — without it, `refreshCore` runs against the wrong namespace, mis-detects a flip, and triggers a second restart. `finally(boot)` runs render even when the prime call fails (web preview, RPC error) — primeActiveUserId(null) resolves the gate so storage falls through as no-ops until `setActiveUserId(...)` lands a real id later via `handleIdentityFlip` or `storeSession`. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/main.tsx | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) 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);