From 77334e7c8b24e31dbcb9ac36e7abb15b7bcf8e77 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:51:45 -0400 Subject: [PATCH] fix(orca-profiles): tell the renderer when a cloud session is revoked (#18694) A revoked refresh-token family makes the main process clear the stored cloud session, but the renderer cached orcaProfileAuthStatus at startup and only re-fetched when it was empty. The account card kept showing "Connected" and Mobile pairing kept showing a generic Relay failure until the user restarted the app. - Push: clearing a session on an auth failure now emits an invalidation event that broadcasts orcaProfiles:authStatusChanged to every window; the renderer re-reads auth status from it. Explicit sign-out is unchanged. - Pull: every pane that renders auth state re-reads it on mount through useOrcaProfileAuthStatusRefresh instead of only when the store is empty. - Copy: a Relay mint failure re-reads auth status, and the failure notice says the session expired and to sign in again instead of offering a retry that cannot succeed. The LAN path is untouched. --- .../ipc/orca-profile-auth-status-broadcast.ts | 15 ++++ src/main/ipc/orca-profiles.ts | 8 ++ .../profile-cloud-session-invalidation.ts | 30 +++++++ .../profile-cloud-session-refresh.test.ts | 45 ++++++++++ .../profile-cloud-session-refresh.ts | 4 + src/preload/api/orca-profile-api.ts | 2 + src/preload/api/orca-profiles-bridge.ts | 6 ++ .../src/components/mobile/MobilePage.test.tsx | 4 +- .../src/components/mobile/MobilePage.tsx | 4 +- .../mobile-relay-mint-failure-notice.tsx | 33 ++++++-- .../mobile/use-mobile-pairing-generation.ts | 7 +- .../settings/ArtifactsSettingsPane.tsx | 9 +- .../src/components/settings/DevToolsPane.tsx | 2 + .../MobilePairingConnectionOptions.test.tsx | 55 ++++++++++++- .../MobilePairingConnectionOptions.tsx | 8 +- .../components/settings/MobilePane.test.tsx | 7 +- .../src/components/settings/MobilePane.tsx | 3 + .../settings/OrcaAccountSettingsPane.tsx | 10 +-- .../settings/ShareSkillsSettingsPane.tsx | 9 +- .../ipc-events/app-lifetime-ipc-bridge.ts | 2 + .../orca-profile-auth-ipc-bridge.test.ts | 82 +++++++++++++++++++ .../orca-profile-auth-ipc-bridge.ts | 14 ++++ .../use-orca-profile-auth-status-refresh.ts | 14 ++++ .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 + src/renderer/src/i18n/locales/en.json | 4 +- .../web/preload-api/web-orca-profiles-api.ts | 2 + src/shared/orca-profiles.ts | 2 + 27 files changed, 339 insertions(+), 44 deletions(-) create mode 100644 src/main/ipc/orca-profile-auth-status-broadcast.ts create mode 100644 src/main/orca-profiles/profile-cloud-session-invalidation.ts create mode 100644 src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/use-orca-profile-auth-status-refresh.ts diff --git a/src/main/ipc/orca-profile-auth-status-broadcast.ts b/src/main/ipc/orca-profile-auth-status-broadcast.ts new file mode 100644 index 00000000000..b5ac8483943 --- /dev/null +++ b/src/main/ipc/orca-profile-auth-status-broadcast.ts @@ -0,0 +1,15 @@ +import { BrowserWindow } from 'electron' +import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles' + +export function broadcastOrcaProfileAuthStatusChanged(): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL) + } catch { + // A renderer can disappear between isDestroyed() and send(). + } + } +} diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index bf1bf666270..480c6f350f9 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -45,6 +45,8 @@ import { signOutCurrentOrcaProfile } from '../orca-profiles/profile-cloud-service' import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' +import { onOrcaCloudSessionInvalidated } from '../orca-profiles/profile-cloud-session-invalidation' +import { broadcastOrcaProfileAuthStatusChanged } from './orca-profile-auth-status-broadcast' type RegisterOrcaProfileHandlersOptions = { onBeforeRelaunch?: () => void | Promise @@ -178,6 +180,12 @@ export function registerOrcaProfileHandlers( getCurrentOrcaProfileAuthStatus(getProfileUserDataPath()) ) + // Why: a background refresh can revoke the session with no renderer request in + // flight, so push the change instead of waiting for the next pane to ask. + // Why not options.onAuthMutation: that hook drives the relay coordinator, which + // is the caller that just failed the refresh — re-entering it here would be a loop. + onOrcaCloudSessionInvalidated(broadcastOrcaProfileAuthStatusChanged) + ipcMain.handle( 'orcaProfiles:createLocal', (_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => { diff --git a/src/main/orca-profiles/profile-cloud-session-invalidation.ts b/src/main/orca-profiles/profile-cloud-session-invalidation.ts new file mode 100644 index 00000000000..a9415e28ac6 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-invalidation.ts @@ -0,0 +1,30 @@ +type OrcaCloudSessionInvalidationListener = () => void + +const listeners = new Set() + +/** + * Fires when an auth failure (revoked or rotated-away refresh token) clears a + * stored cloud session. Never fires for an explicit user sign-out, which already + * hands the fresh auth status back to its caller. + */ +export function onOrcaCloudSessionInvalidated( + listener: OrcaCloudSessionInvalidationListener +): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function emitOrcaCloudSessionInvalidated(): void { + for (const listener of listeners) { + try { + listener() + } catch (error) { + console.warn( + '[orca-profiles] Cloud session invalidation listener failed:', + error instanceof Error ? error.message : String(error) + ) + } + } +} diff --git a/src/main/orca-profiles/profile-cloud-session-refresh.test.ts b/src/main/orca-profiles/profile-cloud-session-refresh.test.ts index 42b665d4e35..3b869beb6de 100644 --- a/src/main/orca-profiles/profile-cloud-session-refresh.test.ts +++ b/src/main/orca-profiles/profile-cloud-session-refresh.test.ts @@ -36,6 +36,8 @@ vi.mock('./profile-cloud-client', async (importOriginal) => { vi.mock('./profile-cloud-index', () => ({ linkOrcaProfileToCloud: linkMock })) import { readFreshOrcaCloudSession } from './profile-cloud-session-refresh' +import { OrcaCloudRequestError } from './profile-cloud-client' +import { onOrcaCloudSessionInvalidated } from './profile-cloud-session-invalidation' const config = {} as OrcaCloudAuthConfig const active = { @@ -110,4 +112,47 @@ describe('profile cloud session refresh', () => { expect(saveIfCurrentMock).toHaveBeenCalledTimes(1) expect(linkMock).toHaveBeenCalledTimes(1) }) + + it('notifies subscribers when an auth failure clears the stored session', async () => { + const invalidated = vi.fn() + const unsubscribe = onOrcaCloudSessionInvalidated(invalidated) + refreshMock.mockRejectedValue(new OrcaCloudRequestError(401)) + + await expect(readFreshOrcaCloudSession(config, active, '/data')).resolves.toEqual({ + status: 'reconnect-required' + }) + + expect(clearMock).toHaveBeenCalledTimes(1) + expect(invalidated).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('stays silent when a concurrent rotation already replaced the failed session', async () => { + const invalidated = vi.fn() + const unsubscribe = onOrcaCloudSessionInvalidated(invalidated) + refreshMock.mockRejectedValue(new OrcaCloudRequestError(401)) + readMock.mockReturnValueOnce({ + status: 'found', + session: staleSession, + persistence: 'memory-only' + }) + readMock.mockReturnValueOnce({ + status: 'found', + session: staleSession, + persistence: 'memory-only' + }) + readMock.mockReturnValue({ + status: 'found', + session: { ...staleSession, refreshToken: 'rotated-refresh' }, + persistence: 'memory-only' + }) + + await expect(readFreshOrcaCloudSession(config, active, '/data')).resolves.toEqual({ + status: 'reconnect-required' + }) + + expect(clearMock).not.toHaveBeenCalled() + expect(invalidated).not.toHaveBeenCalled() + unsubscribe() + }) }) diff --git a/src/main/orca-profiles/profile-cloud-session-refresh.ts b/src/main/orca-profiles/profile-cloud-session-refresh.ts index ced48a16e7b..32e2e39f6e0 100644 --- a/src/main/orca-profiles/profile-cloud-session-refresh.ts +++ b/src/main/orca-profiles/profile-cloud-session-refresh.ts @@ -13,6 +13,7 @@ import { cloudSessionIdentity, tombstoneCloudSession } from './profile-cloud-session-mutation' +import { emitOrcaCloudSessionInvalidated } from './profile-cloud-session-invalidation' const CLOUD_SESSION_REFRESH_SKEW_MS = 60_000 @@ -66,6 +67,9 @@ function clearCloudSessionIfUnchanged( ) } clearOrcaCloudSession(profileId, userDataPath) + // Why: the renderer cached auth status at startup; without this it keeps + // showing "Connected" until the app restarts. + emitOrcaCloudSessionInvalidated() } async function refreshStoredCloudSession( diff --git a/src/preload/api/orca-profile-api.ts b/src/preload/api/orca-profile-api.ts index 16c2a575078..80e9f08fc8d 100644 --- a/src/preload/api/orca-profile-api.ts +++ b/src/preload/api/orca-profile-api.ts @@ -28,6 +28,8 @@ import type { export type OrcaProfileApi = { list: () => Promise authStatus: () => Promise + /** Fires when main changed the stored auth status on its own (e.g. a revoked session). */ + onAuthStatusChanged: (callback: () => void) => () => void createLocal: (args?: CreateLocalOrcaProfileArgs) => Promise createCloudLinked: ( args?: CreateCloudLinkedOrcaProfileArgs diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts index 0b2897f8ab1..da58b2d9def 100644 --- a/src/preload/api/orca-profiles-bridge.ts +++ b/src/preload/api/orca-profiles-bridge.ts @@ -1,9 +1,15 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' +import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles' export const orcaProfilesApi = { list: () => ipcRenderer.invoke('orcaProfiles:list'), authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), + onAuthStatusChanged: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL, listener) + return () => ipcRenderer.removeListener(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL, listener) + }, createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx index 2ef1cf5e965..2149cadc284 100644 --- a/src/renderer/src/components/mobile/MobilePage.test.tsx +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -18,6 +18,7 @@ type StoreState = { mobilePairingCustomAddresses?: string[] } updateSettings: () => Promise + fetchOrcaProfileAuthStatus: () => Promise } const mocks = vi.hoisted(() => ({ @@ -146,7 +147,8 @@ describe('MobilePage pairing connection mode', () => { closeMobilePage: vi.fn(), orcaProfileAuthStatus: { state: 'connected' }, settings: { showMobileButton: true }, - updateSettings: vi.fn().mockResolvedValue(undefined) + updateSettings: vi.fn().mockResolvedValue(undefined), + fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(null) } Object.defineProperty(window, 'api', { configurable: true, diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index ccb9a9e093f..434f0298626 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -39,6 +39,7 @@ export default function MobilePage(): React.JSX.Element { const [relayMintFailure, setRelayMintFailure] = useState(null) const [pairLoading, setPairLoading] = useState(false) const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') + const refreshAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus) const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() const [networkInterfaces, setNetworkInterfaces] = useState([]) const pairingAddressChangeRef = useRef<(change: MobilePairingAddressChange) => void>(() => {}) @@ -93,7 +94,8 @@ export default function MobilePage(): React.JSX.Element { setPairingUrl, setPairingQrError, setPairLoading, - setRelayMintFailure + setRelayMintFailure, + refreshAuthStatus }) useLayoutEffect(() => { pairingAddressChangeRef.current = ({ address, source }) => { diff --git a/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx b/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx index d5e929152fb..ee3d82a51d1 100644 --- a/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx +++ b/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx @@ -3,6 +3,7 @@ import { CircleAlert, Loader2 } from 'lucide-react' import { Button } from '../ui/button' import { translate } from '@/i18n/i18n' import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' +import { useAppStore } from '@/store' import { cn } from '@/lib/utils' export function MobileRelayMintFailureNotice({ @@ -23,6 +24,10 @@ export function MobileRelayMintFailureNotice({ busy?: boolean }): React.JSX.Element { const providerMissing = failure.stage === 'provider_missing' + // Why: a revoked cloud session fails every mint; "retry or use LAN" hides the one action that works. + const reconnectRequired = useAppStore( + (state) => state.orcaProfileAuthStatus?.state === 'reconnect-required' + ) const [showBusyFeedback, setShowBusyFeedback] = useState(false) useEffect(() => { if (!busy) { @@ -43,10 +48,15 @@ export function MobileRelayMintFailureNotice({ 'auto.components.mobile.MobileRelayMintFailureNotice.unavailableTitle', 'Orca Relay isn’t available on this desktop.' ) - : translate( - 'auto.components.mobile.MobileRelayMintFailureNotice.title', - 'Couldn’t create a Relay pairing code.' - ) + : reconnectRequired + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.reconnectTitle', + 'Your Orca account session expired.' + ) + : translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.title', + 'Couldn’t create a Relay pairing code.' + ) const body = visibleBusy ? translate( 'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody', @@ -57,10 +67,15 @@ export function MobileRelayMintFailureNotice({ 'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody', 'Use LAN to pair over Tailscale or the same Wi‑Fi.' ) - : translate( - 'auto.components.mobile.MobileRelayMintFailureNotice.body', - 'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.' - ) + : reconnectRequired + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.reconnectBody', + 'Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi.' + ) + : translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.body', + 'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.' + ) return (
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')} - {!providerMissing ? ( + {!providerMissing && !reconnectRequired ? (