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.
This commit is contained in:
Jinwoo Hong
2026-09-04 16:51:45 -04:00
committed by GitHub
parent 9f10f415f5
commit 77334e7c8b
27 changed files with 339 additions and 44 deletions
@@ -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().
}
}
}
+8
View File
@@ -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<void>
@@ -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 => {
@@ -0,0 +1,30 @@
type OrcaCloudSessionInvalidationListener = () => void
const listeners = new Set<OrcaCloudSessionInvalidationListener>()
/**
* 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)
)
}
}
}
@@ -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()
})
})
@@ -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(
+2
View File
@@ -28,6 +28,8 @@ import type {
export type OrcaProfileApi = {
list: () => Promise<OrcaProfileListResult>
authStatus: () => Promise<OrcaProfileAuthStatus>
/** Fires when main changed the stored auth status on its own (e.g. a revoked session). */
onAuthStatusChanged: (callback: () => void) => () => void
createLocal: (args?: CreateLocalOrcaProfileArgs) => Promise<CreateLocalOrcaProfileResult>
createCloudLinked: (
args?: CreateCloudLinkedOrcaProfileArgs
+6
View File
@@ -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),
@@ -18,6 +18,7 @@ type StoreState = {
mobilePairingCustomAddresses?: string[]
}
updateSettings: () => Promise<void>
fetchOrcaProfileAuthStatus: () => Promise<unknown>
}
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,
@@ -39,6 +39,7 @@ export default function MobilePage(): React.JSX.Element {
const [relayMintFailure, setRelayMintFailure] = useState<MobileRelayMintFailure | null>(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<MobileNetworkInterface[]>([])
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 }) => {
@@ -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 isnt available on this desktop.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.title',
'Couldnt create a Relay pairing code.'
)
: reconnectRequired
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.reconnectTitle',
'Your Orca account session expired.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.title',
'Couldnt 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 WiFi.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.body',
'Retry, or use LAN to pair over Tailscale or the same WiFi.'
)
: reconnectRequired
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.reconnectBody',
'Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same WiFi.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.body',
'Retry, or use LAN to pair over Tailscale or the same WiFi.'
)
return (
<div
@@ -90,7 +105,7 @@ export function MobileRelayMintFailureNotice({
<Button type="button" size={compact ? 'xs' : 'sm'} onClick={onUseLan}>
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')}
</Button>
{!providerMissing ? (
{!providerMissing && !reconnectRequired ? (
<Button
type="button"
size={compact ? 'xs' : 'sm'}
@@ -28,6 +28,8 @@ export function useMobilePairingGeneration(params: {
setPairingQrError: (value: boolean) => void
setPairLoading: (value: boolean) => void
setRelayMintFailure: (value: MobileRelayMintFailure | null) => void
/** Re-read on a Relay mint failure: a revoked session is the likeliest cause. */
refreshAuthStatus: () => void
}): {
generatePairing: (
rotate: boolean,
@@ -47,7 +49,8 @@ export function useMobilePairingGeneration(params: {
setPairingUrl,
setPairingQrError,
setPairLoading,
setRelayMintFailure
setRelayMintFailure,
refreshAuthStatus
} = params
const generatePairing = useCallback(
@@ -92,6 +95,7 @@ export function useMobilePairingGeneration(params: {
setPairingQrError(false)
if (result.reason === 'relay_mint_failed' && result.relayFailure) {
setRelayMintFailure(result.relayFailure)
refreshAuthStatus()
} else {
setRelayMintFailure(null)
// Why: IPC now forwards reason/guidance for all unavailability paths;
@@ -132,6 +136,7 @@ export function useMobilePairingGeneration(params: {
hasGeneratedRef,
mountedRef,
pairingRequestIdRef,
refreshAuthStatus,
selectedAddress,
setPairLoading,
setPairQrDataUrl,
@@ -1,8 +1,8 @@
import { useEffect } from 'react'
import { ArrowRight, Files } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { Button } from '@/components/ui/button'
import { SettingsSwitchRow } from './SettingsFormControls'
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
import { useAppStore } from '@/store'
import { isWebClientLocation } from '@/lib/web-client-location'
import { translate } from '@/i18n/i18n'
@@ -20,18 +20,13 @@ export function ArtifactsSettingsPane({
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
const signedIn = authStatus?.state === 'connected'
// Why: the capability lives in the desktop host's store and is deliberately absent from the
// settings.update allowlist, so a web client can only mirror it — never grant it.
const isWebClient = isWebClientLocation()
const sharingEnabled = settings.artifactSharingEnabled === true
useEffect(() => {
if (!authStatus) {
void fetchAuthStatus()
}
}, [authStatus, fetchAuthStatus])
useOrcaProfileAuthStatusRefresh()
const howToSteps: HowToStep[] = [
...(sharingEnabled
@@ -5,6 +5,7 @@ import { Badge } from '../ui/badge'
import { SettingsSubsectionHeader } from './SettingsFormControls'
import { showDeleteWorktreeFailureToast } from '../sidebar/delete-worktree-failure-toast'
import { showLocalBaseRefUpdateSuggestionToast } from '../sidebar/local-base-ref-suggestion-toast'
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
@@ -137,6 +138,7 @@ function OrcaCloudDevSubsection(): React.JSX.Element {
const refresh = useAppStore((s) => s.fetchOrcaProfileAuthStatus)
const configured = authStatus?.configured === true
const connected = authStatus?.state === 'connected'
useOrcaProfileAuthStatusRefresh()
return (
<section className="space-y-3">
@@ -2,6 +2,7 @@
import '@testing-library/jest-dom/vitest'
import { StrictMode, useSyncExternalStore } from 'react'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -17,13 +18,28 @@ type MobileRelayStoreState = {
}
const mocks = vi.hoisted(() => ({
state: {} as MobileRelayStoreState
state: {} as MobileRelayStoreState,
listeners: new Set<() => void>()
}))
// Why subscribable: a mid-mount auth re-read has to reach the rendered tree, which a
// plain selector-over-a-mutable-object mock silently swallows.
vi.mock('../../store', () => ({
useAppStore: (selector: (state: MobileRelayStoreState) => unknown) => selector(mocks.state)
useAppStore: (selector: (state: MobileRelayStoreState) => unknown) =>
useSyncExternalStore(
(onStoreChange) => {
mocks.listeners.add(onStoreChange)
return () => mocks.listeners.delete(onStoreChange)
},
() => selector(mocks.state)
)
}))
function publishStoreState(next: MobileRelayStoreState): void {
mocks.state = next
mocks.listeners.forEach((listener) => listener())
}
vi.mock('../../i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
@@ -251,4 +267,39 @@ describe('MobilePairingConnectionOptions', () => {
await user.click(lan)
expect(onChange).toHaveBeenCalledWith('local-only')
})
it('re-reads a session revoked since startup and offers Sign in again', async () => {
// Regression: the store cached "connected" at startup and the pane only
// fetched when it was empty, so a revoked session stayed invisible.
const connectedState: MobileRelayStoreState = {
...mocks.state,
orcaProfileAuthStatus: {
activeProfileId: 'profile-1',
configured: true,
state: 'connected',
persistence: 'encrypted'
}
}
mocks.state = connectedState
fetchAuthStatus.mockImplementation(async () => {
const revoked: OrcaProfileAuthStatus = {
activeProfileId: 'profile-1',
configured: true,
state: 'reconnect-required',
persistence: 'encrypted'
}
publishStoreState({ ...connectedState, orcaProfileAuthStatus: revoked })
return revoked
})
// StrictMode double-invokes the effect: a fetch keyed on what it writes would loop.
render(
<StrictMode>
<MobilePairingConnectionOptions value="automatic" onChange={vi.fn()} />
</StrictMode>
)
expect(await screen.findByRole('button', { name: 'Sign in again for Relay' })).toBeVisible()
expect(fetchAuthStatus).toHaveBeenCalledTimes(2)
})
})
@@ -4,6 +4,7 @@ import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { translate } from '../../i18n/i18n'
import { useAppStore } from '../../store'
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
import { cn } from '@/lib/utils'
import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
@@ -54,7 +55,6 @@ export function MobilePairingConnectionOptions({
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
const [relayStatus, setRelayStatus] = useState<MobileRelayStatus>('offline')
const signedIn = authStatus?.state === 'connected'
const reconnectRequired = authStatus?.state === 'reconnect-required'
@@ -93,11 +93,7 @@ export function MobilePairingConnectionOptions({
optionRefs.current[next]?.focus()
}
useEffect(() => {
if (!authStatus) {
void fetchAuthStatus()
}
}, [authStatus, fetchAuthStatus])
useOrcaProfileAuthStatusRefresh()
useEffect(() => {
let receivedEvent = false
@@ -32,6 +32,7 @@ type StoreState = {
}
updateSettings: (patch: Record<string, unknown>) => Promise<void>
recordFeatureInteraction: (feature: string) => void
fetchOrcaProfileAuthStatus: () => Promise<unknown>
}
const mocks = vi.hoisted(() => {
@@ -188,7 +189,8 @@ describe('MobilePane pairing connection mode', () => {
settingsSearchQuery: '',
settings: { mobileAutoRestoreFitMs: null },
updateSettings,
recordFeatureInteraction: vi.fn()
recordFeatureInteraction: vi.fn(),
fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(null)
}
Object.defineProperty(window, 'api', {
configurable: true,
@@ -797,7 +799,8 @@ describe('MobilePane', () => {
settingsSearchQuery: '',
settings: { mobileAutoRestoreFitMs: null },
updateSettings: mocks.updateSettings,
recordFeatureInteraction: vi.fn()
recordFeatureInteraction: vi.fn(),
fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(null)
}
Object.defineProperty(window, 'api', {
configurable: true,
@@ -218,6 +218,9 @@ export function MobilePane(): React.JSX.Element {
setEndpoint(null)
if (result.reason === 'relay_mint_failed' && result.relayFailure) {
setRelayMintFailure(result.relayFailure)
// Why: a revoked session is the likeliest cause; re-read it so the
// notice can offer sign-in instead of a retry that cannot succeed.
void useAppStore.getState().fetchOrcaProfileAuthStatus()
} else {
setRelayMintFailure(null)
// Why: IPC now forwards reason/guidance for all unavailability paths;
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { BookOpen, Check, CircleUserRound, Files, Smartphone } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
@@ -61,18 +62,13 @@ export function OrcaAccountSettingsPane(): React.JSX.Element {
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
const signOut = useAppStore((state) => state.signOutCurrentOrcaProfile)
const [signOutOpen, setSignOutOpen] = useState(false)
const [signingOut, setSigningOut] = useState(false)
const connected = authStatus?.state === 'connected'
const canConnect = authStatus?.configured === true
useEffect(() => {
if (!authStatus) {
void fetchAuthStatus()
}
}, [authStatus, fetchAuthStatus])
useOrcaProfileAuthStatusRefresh()
const confirmSignOut = async (): Promise<void> => {
if (signingOut) {
@@ -1,6 +1,6 @@
import { useEffect } from 'react'
import { ArrowRight, BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
import { translate } from '@/i18n/i18n'
import { isWebClientLocation } from '@/lib/web-client-location'
import { useAppStore } from '@/store'
@@ -16,16 +16,11 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
const signedIn = authStatus?.state === 'connected'
const isWebClient = isWebClientLocation()
const agentSharingEnabled = settings?.agentSkillSharingEnabled === true
useEffect(() => {
if (!authStatus) {
void fetchAuthStatus()
}
}, [authStatus, fetchAuthStatus])
useOrcaProfileAuthStatusRefresh()
const steps: HowToStep[] = [
{
@@ -12,6 +12,7 @@ import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime'
import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge'
import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge'
import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge'
import { registerOrcaProfileAuthIpcBridge } from './orca-profile-auth-ipc-bridge'
import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge'
import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge'
import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge'
@@ -78,6 +79,7 @@ export function installAppLifetimeIpcEvents(
remountTerminalTabsAwaitingHostHydration
)
registerSettingsAndSidebarIpcBridge(unsubs)
registerOrcaProfileAuthIpcBridge(unsubs)
registerWorkspaceShortcutIpcBridge(unsubs)
registerOsMarkdownFileOpenBridge(unsubs)
unsubs.push(
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
import { createTestStore } from '../../store/slices/store-test-helpers'
const { storeHolder } = vi.hoisted(() => ({
storeHolder: { current: null as { getState: () => unknown } | null }
}))
vi.mock('../../store', () => ({
useAppStore: { getState: () => storeHolder.current?.getState() }
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() }
}))
import { registerOrcaProfileAuthIpcBridge } from './orca-profile-auth-ipc-bridge'
const connectedAuthStatus: OrcaProfileAuthStatus = {
activeProfileId: 'local-default',
configured: true,
state: 'connected',
persistence: 'encrypted'
}
const reconnectRequiredAuthStatus: OrcaProfileAuthStatus = {
activeProfileId: 'local-default',
configured: true,
state: 'reconnect-required',
persistence: 'encrypted'
}
describe('orca profile auth IPC bridge', () => {
let listener: (() => void) | null = null
const unsubscribe = vi.fn()
const authStatus = vi.fn()
beforeEach(() => {
listener = null
unsubscribe.mockClear()
authStatus.mockReset()
vi.stubGlobal('window', {
api: {
orcaProfiles: {
authStatus,
onAuthStatusChanged: (callback: () => void) => {
listener = callback
return unsubscribe
}
}
}
})
})
it('re-fetches auth status on the push, flipping connected to reconnect-required', async () => {
authStatus.mockResolvedValue(connectedAuthStatus)
const store = createTestStore()
storeHolder.current = store
await store.getState().fetchOrcaProfileAuthStatus()
expect(store.getState().orcaProfileAuthStatus).toEqual(connectedAuthStatus)
const unsubs: (() => void)[] = []
registerOrcaProfileAuthIpcBridge(unsubs)
authStatus.mockResolvedValue(reconnectRequiredAuthStatus)
listener?.()
await vi.waitFor(() =>
expect(store.getState().orcaProfileAuthStatus).toEqual(reconnectRequiredAuthStatus)
)
unsubs.forEach((dispose) => dispose())
expect(unsubscribe).toHaveBeenCalledTimes(1)
})
it('skips registration when the preload bridge does not expose the event', () => {
vi.stubGlobal('window', { api: { orcaProfiles: { authStatus } } })
const unsubs: (() => void)[] = []
registerOrcaProfileAuthIpcBridge(unsubs)
expect(unsubs).toHaveLength(0)
})
})
@@ -0,0 +1,14 @@
import { useAppStore } from '../../store'
/** Re-reads auth status when main clears a revoked cloud session behind the renderer's back. */
export function registerOrcaProfileAuthIpcBridge(unsubs: (() => void)[]): void {
const subscribe = window.api.orcaProfiles?.onAuthStatusChanged
if (typeof subscribe !== 'function') {
return
}
unsubs.push(
subscribe(() => {
void useAppStore.getState().fetchOrcaProfileAuthStatus()
})
)
}
@@ -0,0 +1,14 @@
import { useEffect } from 'react'
import { useAppStore } from '../store'
/**
* Re-reads the cloud auth status whenever a surface that renders it mounts. The
* store caches the startup value, so without this a session revoked since launch
* still reads as connected. The cached value stays rendered while the fetch runs.
*/
export function useOrcaProfileAuthStatusRefresh(): void {
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
useEffect(() => {
void fetchAuthStatus()
}, [fetchAuthStatus])
}
@@ -19,6 +19,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
'emulator.onPaneFocus',
'gh.onPRRefreshEvent',
'keybindings.onChanged',
'orcaProfiles.onAuthStatusChanged',
'pty.onExit',
'rateLimits.onUpdate',
'remoteWorkspace.onChanged',
@@ -126,6 +127,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
'ui.onToggleWorktreePalette',
'ui.onToggleFloatingTerminal',
'ui.onTerminalShortcutCaptured',
'orcaProfiles.onAuthStatusChanged',
'ui.onOpenQuickOpen',
'ui.onToggleQuickCommandsMenu',
'ui.onOpenNewWorkspace',
+3 -1
View File
@@ -13771,7 +13771,9 @@
"useLan": "Use LAN",
"retrying": "Retrying…",
"retry": "Retry Relay",
"copyDiagnostics": "Copy diagnostics"
"copyDiagnostics": "Copy diagnostics",
"reconnectTitle": "Your Orca account session expired.",
"reconnectBody": "Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same WiFi."
}
},
"gitlab": {
@@ -3,6 +3,7 @@ import {
DEFAULT_LOCAL_ORCA_PROFILE_ID,
createDefaultLocalOrcaProfile
} from '../../../../shared/orca-profiles'
import { noopUnsubscribe } from './web-storage'
export function createWebOrcaProfilesApi(): Partial<PreloadApi> {
const webOrcaProfileAuthStatus = () =>
@@ -22,6 +23,7 @@ export function createWebOrcaProfilesApi(): Partial<PreloadApi> {
multiProfileUi: false
}),
authStatus: webOrcaProfileAuthStatus,
onAuthStatusChanged: () => noopUnsubscribe,
createLocal: () =>
Promise.resolve({
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
+2
View File
@@ -4,6 +4,8 @@ import type { ExecutionHostId } from './execution-host'
export const ORCA_PROFILE_INDEX_SCHEMA_VERSION = 1
export const DEFAULT_LOCAL_ORCA_PROFILE_ID = 'local-default'
export const DEFAULT_LOCAL_ORCA_PROFILE_NAME = 'Personal'
/** Main -> renderer push when the stored auth status changed without the renderer asking. */
export const ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL = 'orcaProfiles:authStatusChanged'
const LEGACY_ORCA_BROWSER_SESSION_PARTITION_PREFIX = 'persist:orca-browser-session-'
export type OrcaProfileAvatar = {