From e51d2edb9ef4b871fcff977ae73376aa77954f7a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:49:04 -0700 Subject: [PATCH] fable review --- src/main/browser/browser-session-registry.ts | 5 +- .../ipc/orca-profile-auth-handlers.test.ts | 41 +++- src/main/ipc/orca-profiles.test.ts | 42 +++- src/main/ipc/orca-profiles.ts | 57 +++-- .../profile-cloud-auth-config.test.ts | 38 ++- .../profile-cloud-auth-config.ts | 63 +++-- .../orca-profiles/profile-cloud-client.ts | 27 +-- src/main/orca-profiles/profile-cloud-pkce.ts | 13 +- .../profile-cloud-session-refresh.ts | 82 +++++-- .../orca-profiles/profile-index-store.test.ts | 42 ++++ src/main/orca-profiles/profile-index-store.ts | 50 +++- .../profile-project-session-transfer.ts | 6 +- .../profile-project-transfer.test.ts | 4 +- .../orca-profiles/profile-project-transfer.ts | 6 +- src/main/persistence.ts | 21 ++ src/renderer/src/App.tsx | 5 +- .../OrcaProfileManagementDialog.tsx | 6 +- ...rcaProfileProjectTransferConfirmDialog.tsx | 27 ++- .../OrcaProfileSwitchConfirmDialog.tsx | 43 ++-- .../orca-profiles/OrcaProfileSwitcher.tsx | 17 +- src/renderer/src/i18n/locales/en.json | 48 +++- src/renderer/src/i18n/locales/es.json | 122 ++++++---- src/renderer/src/i18n/locales/ja.json | 124 ++++++---- src/renderer/src/i18n/locales/ko.json | 124 ++++++---- src/renderer/src/i18n/locales/zh.json | 126 ++++++---- .../src/store/slices/orca-profiles.test.ts | 13 ++ .../src/store/slices/orca-profiles.ts | 217 ++---------------- src/renderer/src/store/slices/repos.ts | 21 +- 28 files changed, 830 insertions(+), 560 deletions(-) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index c3ae2346145..cc974ff7a1a 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -361,7 +361,10 @@ class BrowserSessionRegistry { resolveKnownPartition(profileId: string | null | undefined): string | null { if (!profileId) { - return ORCA_BROWSER_PARTITION + // Why: must track the active Orca profile's default partition, not the + // legacy constant, or non-default profiles would resolve local-default's + // cookie jar. + return this.defaultPartition } return this.profiles.get(profileId)?.partition ?? null } diff --git a/src/main/ipc/orca-profile-auth-handlers.test.ts b/src/main/ipc/orca-profile-auth-handlers.test.ts index 43a764defa6..73726dd2b52 100644 --- a/src/main/ipc/orca-profile-auth-handlers.test.ts +++ b/src/main/ipc/orca-profile-auth-handlers.test.ts @@ -38,6 +38,7 @@ vi.mock('../tray/system-tray', () => ({ vi.mock('../orca-profiles/profile-index-store', () => ({ createLocalOrcaProfile: vi.fn(), getOrcaProfileListState: vi.fn(), + seedNewOrcaProfileTelemetryConsent: vi.fn(), setActiveOrcaProfile: vi.fn() })) @@ -75,7 +76,11 @@ describe('registerOrcaProfileHandlers auth channels', () => { persistence: 'none' } getCurrentOrcaProfileAuthStatusMock.mockReturnValue(status) - registerOrcaProfileHandlers({ flush: vi.fn() } as never) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) await expect(Promise.resolve(handlers.get('orcaProfiles:authStatus')?.(null))).resolves.toBe( status @@ -88,7 +93,11 @@ describe('registerOrcaProfileHandlers auth channels', () => { const signOutResult = { status: 'signed-out', auth: { activeProfileId: 'local-default' } } connectCurrentOrcaProfileMock.mockResolvedValue(connectResult) signOutCurrentOrcaProfileMock.mockResolvedValue(signOutResult) - registerOrcaProfileHandlers({ flush: vi.fn() } as never) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) await expect( Promise.resolve(handlers.get('orcaProfiles:connectCurrent')?.(null)) @@ -103,18 +112,26 @@ describe('registerOrcaProfileHandlers auth channels', () => { it('refreshes profile auth through the cloud service', async () => { const refreshResult = { status: 'refreshed', auth: { activeProfileId: 'local-default' } } refreshCurrentOrcaProfileAuthMock.mockResolvedValue(refreshResult) - registerOrcaProfileHandlers({ flush: vi.fn() } as never) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) - await expect( - Promise.resolve(handlers.get('orcaProfiles:refreshAuth')?.(null)) - ).resolves.toBe(refreshResult) + await expect(Promise.resolve(handlers.get('orcaProfiles:refreshAuth')?.(null))).resolves.toBe( + refreshResult + ) expect(refreshCurrentOrcaProfileAuthMock).toHaveBeenCalledWith('/tmp/orca-user-data') }) it('validates organization selection before calling the cloud service', async () => { const selectResult = { status: 'selected', auth: { activeProfileId: 'local-default' } } selectCurrentOrcaProfileOrgMock.mockResolvedValue(selectResult) - registerOrcaProfileHandlers({ flush: vi.fn() } as never) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) await expect( Promise.resolve(handlers.get('orcaProfiles:selectOrg')?.(null, { orgId: ' org-1 ' })) @@ -135,13 +152,15 @@ describe('registerOrcaProfileHandlers auth channels', () => { profile: { id: 'cloud-1' } } createCloudLinkedOrcaProfileMock.mockResolvedValue(createResult) - registerOrcaProfileHandlers({ flush: vi.fn() } as never) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) await expect( Promise.resolve( - handlers - .get('orcaProfiles:createCloudLinked') - ?.(null, { orgId: ' org-1 ', name: ' Acme ' }) + handlers.get('orcaProfiles:createCloudLinked')?.(null, { orgId: ' org-1 ', name: ' Acme ' }) ) ).resolves.toBe(createResult) expect(createCloudLinkedOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data', { diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index e852a2a99af..3cf7a947d1c 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -3,19 +3,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { handlers, appExitMock, + appQuitMock, appRelaunchMock, destroySystemTrayMock, createLocalOrcaProfileMock, getOrcaProfileListStateMock, + seedNewOrcaProfileTelemetryConsentMock, setActiveOrcaProfileMock, transferOrcaProfileProjectMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), appExitMock: vi.fn(), + appQuitMock: vi.fn(), appRelaunchMock: vi.fn(), destroySystemTrayMock: vi.fn(), createLocalOrcaProfileMock: vi.fn(), getOrcaProfileListStateMock: vi.fn(), + seedNewOrcaProfileTelemetryConsentMock: vi.fn(), setActiveOrcaProfileMock: vi.fn(), transferOrcaProfileProjectMock: vi.fn() })) @@ -23,6 +27,7 @@ const { vi.mock('electron', () => ({ app: { exit: appExitMock, + quit: appQuitMock, relaunch: appRelaunchMock, getPath: () => '/tmp/orca-user-data' }, @@ -40,9 +45,18 @@ vi.mock('../tray/system-tray', () => ({ vi.mock('../orca-profiles/profile-index-store', () => ({ createLocalOrcaProfile: createLocalOrcaProfileMock, getOrcaProfileListState: getOrcaProfileListStateMock, + seedNewOrcaProfileTelemetryConsent: seedNewOrcaProfileTelemetryConsentMock, setActiveOrcaProfile: setActiveOrcaProfileMock })) +function makeStoreMock(flush = vi.fn()): { + flush: typeof flush + freezeWrites: ReturnType + getSettings: () => Record +} { + return { flush, freezeWrites: vi.fn(), getSettings: () => ({}) } +} + vi.mock('../orca-profiles/profile-project-transfer', () => ({ transferOrcaProfileProject: transferOrcaProfileProjectMock })) @@ -54,10 +68,12 @@ describe('registerOrcaProfileHandlers', () => { vi.useFakeTimers() handlers.clear() appExitMock.mockReset() + appQuitMock.mockReset() appRelaunchMock.mockReset() destroySystemTrayMock.mockReset() createLocalOrcaProfileMock.mockReset() getOrcaProfileListStateMock.mockReset() + seedNewOrcaProfileTelemetryConsentMock.mockReset() setActiveOrcaProfileMock.mockReset() transferOrcaProfileProjectMock.mockReset() }) @@ -78,7 +94,7 @@ describe('registerOrcaProfileHandlers', () => { getOrcaProfileListStateMock.mockReturnValue(listState) createLocalOrcaProfileMock.mockReturnValue(createState) - registerOrcaProfileHandlers({ flush: vi.fn() } as never) + registerOrcaProfileHandlers(makeStoreMock() as never) await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toBe( listState @@ -100,7 +116,7 @@ describe('registerOrcaProfileHandlers', () => { activeProfileId: 'local-work', profiles: [] }) - registerOrcaProfileHandlers({ flush } as never, { onBeforeRelaunch }) + registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) const resultPromise = Promise.resolve( handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) @@ -117,9 +133,11 @@ describe('registerOrcaProfileHandlers', () => { await vi.advanceTimersByTimeAsync(150) - expect(destroySystemTrayMock).toHaveBeenCalledOnce() expect(appRelaunchMock).toHaveBeenCalledOnce() - expect(appExitMock).toHaveBeenCalledWith(0) + // Why quit, not exit: before-quit/will-quit teardown (scrollback capture, + // PTY kill, daemon checkpoints) must run on a profile switch. + expect(appQuitMock).toHaveBeenCalledOnce() + expect(appExitMock).not.toHaveBeenCalled() }) it('does not mark a profile active when current profile flush fails', async () => { @@ -130,7 +148,7 @@ describe('registerOrcaProfileHandlers', () => { activeProfileId: 'local-default', profiles: [] }) - registerOrcaProfileHandlers({ flush } as never) + registerOrcaProfileHandlers(makeStoreMock(flush) as never) await expect( Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })) @@ -145,7 +163,7 @@ describe('registerOrcaProfileHandlers', () => { activeProfileId: 'local-default', profiles: [] }) - registerOrcaProfileHandlers({ flush: vi.fn() } as never) + registerOrcaProfileHandlers(makeStoreMock() as never) await expect( Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' })) @@ -156,7 +174,7 @@ describe('registerOrcaProfileHandlers', () => { }) it('rejects invalid profile ids', async () => { - registerOrcaProfileHandlers({ flush: vi.fn() } as never) + registerOrcaProfileHandlers(makeStoreMock() as never) await expect( Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' })) @@ -179,7 +197,7 @@ describe('registerOrcaProfileHandlers', () => { profiles: [] }) transferOrcaProfileProjectMock.mockReturnValue(result) - registerOrcaProfileHandlers({ flush } as never) + registerOrcaProfileHandlers(makeStoreMock(flush) as never) await expect( Promise.resolve( @@ -221,7 +239,7 @@ describe('registerOrcaProfileHandlers', () => { profiles: [] }) transferOrcaProfileProjectMock.mockReturnValue(result) - registerOrcaProfileHandlers({ flush } as never, { onBeforeRelaunch }) + registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) await expect( Promise.resolve( @@ -250,9 +268,9 @@ describe('registerOrcaProfileHandlers', () => { await vi.advanceTimersByTimeAsync(150) - expect(destroySystemTrayMock).toHaveBeenCalledOnce() expect(appRelaunchMock).toHaveBeenCalledOnce() - expect(appExitMock).toHaveBeenCalledWith(0) + expect(appQuitMock).toHaveBeenCalledOnce() + expect(appExitMock).not.toHaveBeenCalled() }) it('rejects transfers that would mutate the active target profile offline', async () => { @@ -260,7 +278,7 @@ describe('registerOrcaProfileHandlers', () => { activeProfileId: 'work', profiles: [] }) - registerOrcaProfileHandlers({ flush: vi.fn() } as never) + registerOrcaProfileHandlers(makeStoreMock() as never) await expect( Promise.resolve( diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 9c55917a983..6e8d03a4386 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -1,6 +1,5 @@ import { app, ipcMain } from 'electron' import type { Store } from '../persistence' -import { destroySystemTray } from '../tray/system-tray' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -23,8 +22,10 @@ import type { import { createLocalOrcaProfile, getOrcaProfileListState, + seedNewOrcaProfileTelemetryConsent, setActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer' import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence' import { normalizeExecutionHostId } from '../../shared/execution-host' @@ -146,9 +147,11 @@ async function runBeforeProfileRelaunch( function scheduleProfileRelaunch(): void { setTimeout(() => { - destroySystemTray() app.relaunch() - app.exit(0) + // Why: app.quit() (not app.exit) so before-quit/will-quit still run — + // renderer scrollback capture, PTY kill, stats flush, and daemon final + // checkpoints must not be skipped on a profile switch. + app.quit() }, 150) } @@ -158,14 +161,18 @@ export function registerOrcaProfileHandlers( ): void { ipcMain.handle('orcaProfiles:list', (): OrcaProfileListState => getOrcaProfileListState()) - ipcMain.handle('orcaProfiles:authStatus', (): OrcaProfileAuthStatus => - getCurrentOrcaProfileAuthStatus(app.getPath('userData')) + ipcMain.handle( + 'orcaProfiles:authStatus', + (): OrcaProfileAuthStatus => getCurrentOrcaProfileAuthStatus(getProfileUserDataPath()) ) ipcMain.handle( 'orcaProfiles:createLocal', - (_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => - createLocalOrcaProfile(args) + (_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => { + const result = createLocalOrcaProfile(args) + seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry) + return result + } ) ipcMain.handle( @@ -201,10 +208,16 @@ export function registerOrcaProfileHandlers( throw new Error('active_target_orca_profile_transfer_requires_relaunch') } if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) { - await runBeforeProfileRelaunch(options.onBeforeRelaunch) + // Why: transfer before any relaunch side effect so a duplicate-target + // or validation failure cannot strand the app in a quitting state. + // flush→transfer→freeze runs synchronously with no interleaving, and + // the freeze keeps late sync saves from resurrecting the moved + // project from stale memory before the relaunch. store.flush() - const result = transferOrcaProfileProject(args, app.getPath('userData')) + const result = transferOrcaProfileProject(args, getProfileUserDataPath()) if (result.status === 'transferred') { + store.freezeWrites() + await runBeforeProfileRelaunch(options.onBeforeRelaunch) setActiveOrcaProfile(args.targetProfileId) scheduleProfileRelaunch() return { ...result, willRelaunch: true } @@ -212,7 +225,7 @@ export function registerOrcaProfileHandlers( return result } store.flush() - return transferOrcaProfileProject(args, app.getPath('userData')) + return transferOrcaProfileProject(args, getProfileUserDataPath()) } ) @@ -221,40 +234,48 @@ export function registerOrcaProfileHandlers( (_event, rawArgs: FindOrcaProfileProjectsByPathArgs): FindOrcaProfileProjectsByPathResult => findOrcaProfileProjectsByPath( findProjectsByPathArgsFromUnknown(rawArgs), - app.getPath('userData') + getProfileUserDataPath() ) ) ipcMain.handle( 'orcaProfiles:connectCurrent', async (): Promise => - connectCurrentOrcaProfile(app.getPath('userData')) + connectCurrentOrcaProfile(getProfileUserDataPath()) ) ipcMain.handle( 'orcaProfiles:createCloudLinked', - async (_event, rawArgs?: CreateCloudLinkedOrcaProfileArgs): Promise => - createCloudLinkedOrcaProfile( - app.getPath('userData'), + async ( + _event, + rawArgs?: CreateCloudLinkedOrcaProfileArgs + ): Promise => { + const result = await createCloudLinkedOrcaProfile( + getProfileUserDataPath(), createCloudLinkedProfileArgsFromUnknown(rawArgs) ) + if (result.status === 'created') { + seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry) + } + return result + } ) ipcMain.handle( 'orcaProfiles:refreshAuth', async (): Promise => - refreshCurrentOrcaProfileAuth(app.getPath('userData')) + refreshCurrentOrcaProfileAuth(getProfileUserDataPath()) ) ipcMain.handle( 'orcaProfiles:signOutCurrent', async (): Promise => - signOutCurrentOrcaProfile(app.getPath('userData')) + signOutCurrentOrcaProfile(getProfileUserDataPath()) ) ipcMain.handle( 'orcaProfiles:selectOrg', async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise => - selectCurrentOrcaProfileOrg(app.getPath('userData'), orgIdFromUnknown(rawArgs)) + selectCurrentOrcaProfileOrg(getProfileUserDataPath(), orgIdFromUnknown(rawArgs)) ) } diff --git a/src/main/orca-profiles/profile-cloud-auth-config.test.ts b/src/main/orca-profiles/profile-cloud-auth-config.test.ts index 56e35796269..d18ff159fd5 100644 --- a/src/main/orca-profiles/profile-cloud-auth-config.test.ts +++ b/src/main/orca-profiles/profile-cloud-auth-config.test.ts @@ -1,10 +1,16 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { allowsPlaintextOrcaCloudSession, getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config' +vi.mock('electron', () => ({ + app: { + isPackaged: false + } +})) + describe('Orca cloud auth config', () => { it('reports unconfigured without both API URL and client ID', () => { expect(getOrcaCloudAuthConfig({})).toEqual({ @@ -45,6 +51,27 @@ describe('Orca cloud auth config', () => { expect(state.configured).toBe(true) }) + it('rejects loopback HTTP endpoints in packaged builds', () => { + expect( + getOrcaCloudAuthConfig( + { + ORCA_CLOUD_API_URL: 'http://localhost:4100', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }, + true + ) + ).toMatchObject({ configured: false }) + + const httpsState = getOrcaCloudAuthConfig( + { + ORCA_CLOUD_API_URL: 'https://orca-cloud.example', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }, + true + ) + expect(httpsState.configured).toBe(true) + }) + it('rejects non-HTTPS non-loopback API URLs', () => { expect( getOrcaCloudAuthConfig({ @@ -69,6 +96,15 @@ describe('Orca cloud auth config', () => { ).toBe(false) }) + it('ignores dev flags in packaged builds even without NODE_ENV', () => { + // Why: packaged main bundles never define NODE_ENV, so packaged-ness must + // gate the escape hatches on its own. + expect(allowsPlaintextOrcaCloudSession({ ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1' }, true)).toBe( + false + ) + expect(isOrcaCloudDevAuthEnabled({ ORCA_CLOUD_DEV_AUTH: '1' }, true)).toBe(false) + }) + it('allows local dev auth only outside production', () => { expect( isOrcaCloudDevAuthEnabled({ diff --git a/src/main/orca-profiles/profile-cloud-auth-config.ts b/src/main/orca-profiles/profile-cloud-auth-config.ts index 3d471e17e97..15f9c2e6d24 100644 --- a/src/main/orca-profiles/profile-cloud-auth-config.ts +++ b/src/main/orca-profiles/profile-cloud-auth-config.ts @@ -1,3 +1,5 @@ +import { app } from 'electron' + export type OrcaCloudAuthConfig = { apiBaseUrl: string authorizeEndpoint: string @@ -13,7 +15,17 @@ export type OrcaCloudAuthConfig = { const DEFAULT_SCOPE = 'openid profile email offline_access' -function cleanUrl(value: string | undefined): string | null { +// Why: packaged main bundles never define NODE_ENV, so packaged-ness is the +// only reliable production signal for gating dev-only auth escape hatches. +function isPackagedOrcaBuild(): boolean { + try { + return app?.isPackaged === true + } catch { + return false + } +} + +function cleanUrl(value: string | undefined, allowLoopbackHttp: boolean): string | null { const trimmed = value?.trim() if (!trimmed) { return null @@ -24,7 +36,7 @@ function cleanUrl(value: string | undefined): string | null { parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '[::1]' - if (parsed.protocol !== 'https:' && !loopbackHost) { + if (parsed.protocol !== 'https:' && !(loopbackHost && allowLoopbackHttp)) { return null } return parsed.toString().replace(/\/$/, '') @@ -38,9 +50,15 @@ function endpoint(baseUrl: string, path: string): string { } export function getOrcaCloudAuthConfig( - env: NodeJS.ProcessEnv = process.env + env: NodeJS.ProcessEnv = process.env, + packaged: boolean = isPackagedOrcaBuild() ): { configured: true; config: OrcaCloudAuthConfig } | { configured: false; setupMessage: string } { - const apiBaseUrl = cleanUrl(env.ORCA_CLOUD_API_URL) + // Why: loopback HTTP endpoints are a local-development convenience only; + // packaged builds must not accept plain-HTTP token endpoints via env vars. + const allowLoopbackHttp = !packaged + const cleanEndpointUrl = (value: string | undefined): string | null => + cleanUrl(value, allowLoopbackHttp) + const apiBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_API_URL) const clientId = env.ORCA_CLOUD_CLIENT_ID?.trim() if (!apiBaseUrl || !clientId) { return { @@ -49,36 +67,49 @@ export function getOrcaCloudAuthConfig( } } - const authBaseUrl = cleanUrl(env.ORCA_CLOUD_AUTH_URL) ?? apiBaseUrl + const authBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_AUTH_URL) ?? apiBaseUrl return { configured: true, config: { apiBaseUrl, authorizeEndpoint: - cleanUrl(env.ORCA_CLOUD_AUTHORIZE_URL) ?? + cleanEndpointUrl(env.ORCA_CLOUD_AUTHORIZE_URL) ?? endpoint(authBaseUrl, '/v1/desktop/auth/authorize'), sessionEndpoint: - cleanUrl(env.ORCA_CLOUD_SESSION_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/session'), + cleanEndpointUrl(env.ORCA_CLOUD_SESSION_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/session'), refreshEndpoint: - cleanUrl(env.ORCA_CLOUD_REFRESH_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/refresh'), + cleanEndpointUrl(env.ORCA_CLOUD_REFRESH_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/refresh'), capabilitiesEndpoint: - cleanUrl(env.ORCA_CLOUD_CAPABILITIES_URL) ?? + cleanEndpointUrl(env.ORCA_CLOUD_CAPABILITIES_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/capabilities'), profileEndpoint: - cleanUrl(env.ORCA_CLOUD_PROFILE_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/profile'), - orgEndpoint: cleanUrl(env.ORCA_CLOUD_ORG_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/org'), + cleanEndpointUrl(env.ORCA_CLOUD_PROFILE_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/profile'), + orgEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_ORG_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/org'), logoutEndpoint: - cleanUrl(env.ORCA_CLOUD_LOGOUT_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/logout'), + cleanEndpointUrl(env.ORCA_CLOUD_LOGOUT_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/logout'), clientId, scope: env.ORCA_CLOUD_AUTH_SCOPE?.trim() || DEFAULT_SCOPE } } } -export function allowsPlaintextOrcaCloudSession(env: NodeJS.ProcessEnv = process.env): boolean { - return env.ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION === '1' && env.NODE_ENV !== 'production' +export function allowsPlaintextOrcaCloudSession( + env: NodeJS.ProcessEnv = process.env, + packaged: boolean = isPackagedOrcaBuild() +): boolean { + return ( + env.ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION === '1' && env.NODE_ENV !== 'production' && !packaged + ) } -export function isOrcaCloudDevAuthEnabled(env: NodeJS.ProcessEnv = process.env): boolean { - return env.ORCA_CLOUD_DEV_AUTH === '1' && env.NODE_ENV !== 'production' +export function isOrcaCloudDevAuthEnabled( + env: NodeJS.ProcessEnv = process.env, + packaged: boolean = isPackagedOrcaBuild() +): boolean { + return env.ORCA_CLOUD_DEV_AUTH === '1' && env.NODE_ENV !== 'production' && !packaged } diff --git a/src/main/orca-profiles/profile-cloud-client.ts b/src/main/orca-profiles/profile-cloud-client.ts index 341d7f1cd68..2a06cb3921d 100644 --- a/src/main/orca-profiles/profile-cloud-client.ts +++ b/src/main/orca-profiles/profile-cloud-client.ts @@ -150,18 +150,21 @@ function normalizeSessionResponse(value: unknown): OrcaCloudSessionExchangeRespo } } -async function postJson( - url: string, - body: unknown, - accessToken?: string -): Promise { +const CLOUD_REQUEST_TIMEOUT_MS = 30_000 + +async function postJson(url: string, body: unknown, accessToken?: string): Promise { const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}) }, - body: JSON.stringify(body) + body: JSON.stringify(body), + // Why: these are fixed first-party token endpoints; following a redirect + // would re-send refresh tokens/code verifiers to another origin, and a + // stalled server must not hang the renderer's awaited IPC call forever. + redirect: 'error', + signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS) }) if (!response.ok) { throw new OrcaCloudRequestError(response.status) @@ -193,11 +196,7 @@ export async function refreshOrcaCloudCapabilities( cloud?: unknown organizations?: unknown capabilities: unknown - }>( - config.capabilitiesEndpoint, - {}, - session.accessToken - ) + }>(config.capabilitiesEndpoint, {}, session.accessToken) return { cloud: response.cloud === undefined ? undefined : normalizeCloudSummary(response.cloud), organizations: normalizeOrganizations(response.organizations), @@ -242,11 +241,7 @@ export async function selectOrcaCloudOrg( cloud: unknown organizations?: unknown capabilities: unknown - }>( - config.orgEndpoint, - { orgId }, - session.accessToken - ) + }>(config.orgEndpoint, { orgId }, session.accessToken) return { cloud: normalizeCloudSummary(response.cloud), organizations: normalizeOrganizations(response.organizations), diff --git a/src/main/orca-profiles/profile-cloud-pkce.ts b/src/main/orca-profiles/profile-cloud-pkce.ts index abb070ea6b2..8adf3f3e587 100644 --- a/src/main/orca-profiles/profile-cloud-pkce.ts +++ b/src/main/orca-profiles/profile-cloud-pkce.ts @@ -14,11 +14,7 @@ export type OrcaCloudAuthorizationCode = { const AUTH_TIMEOUT_MS = 5 * 60 * 1000 function base64Url(buffer: Buffer): string { - return buffer - .toString('base64') - .replaceAll('+', '-') - .replaceAll('/', '_') - .replaceAll('=', '') + return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') } function createCodeVerifier(): string { @@ -31,6 +27,9 @@ function createCodeChallenge(verifier: string): string { function closeServer(server: Server): void { try { + // Why: keep-alive sockets from the browser can delay 'close' (and the + // timeout cleanup) until the browser drops the connection. + server.closeAllConnections?.() server.close() } catch { // Already closed. @@ -134,7 +133,9 @@ export function beginOrcaCloudPkceFlow( authorizeUrl.searchParams.set('code_challenge_method', 'S256') authorizeUrl.searchParams.set('local_profile_id', localProfileId) void shell.openExternal(authorizeUrl.toString()).catch((error) => { - rejectFlow(error instanceof Error ? error : new Error('orca_cloud_auth_browser_open_failed')) + rejectFlow( + error instanceof Error ? error : new Error('orca_cloud_auth_browser_open_failed') + ) }) }) }) diff --git a/src/main/orca-profiles/profile-cloud-session-refresh.ts b/src/main/orca-profiles/profile-cloud-session-refresh.ts index 4bc6acf27f2..404cba47bd4 100644 --- a/src/main/orca-profiles/profile-cloud-session-refresh.ts +++ b/src/main/orca-profiles/profile-cloud-session-refresh.ts @@ -25,28 +25,69 @@ function shouldRefreshCloudSession(session: OrcaCloudSession, now = Date.now()): export function isOrcaCloudAuthFailure(error: unknown): boolean { return ( - error instanceof OrcaCloudRequestError && - (error.statusCode === 401 || error.statusCode === 403) + error instanceof OrcaCloudRequestError && (error.statusCode === 401 || error.statusCode === 403) ) } +const inflightCloudSessionRefreshes = new Map>() + +function cloudSessionRefreshKey(profileId: string, userDataPath: string): string { + return `${userDataPath}\0${profileId}` +} + +// Why: with refresh-token rotation, only the session that actually failed may +// clear the store; otherwise a loser of a concurrent refresh race would wipe +// the winner's freshly rotated session. +function clearCloudSessionIfUnchanged( + profileId: string, + userDataPath: string, + failed: OrcaCloudSession +): void { + const current = readOrcaCloudSession(profileId, userDataPath) + if (current.status === 'found' && current.session.refreshToken !== failed.refreshToken) { + return + } + clearOrcaCloudSession(profileId, userDataPath) +} + async function refreshStoredCloudSession( config: OrcaCloudAuthConfig, active: ActiveOrcaProfileState, userDataPath: string, session: OrcaCloudSession ): Promise { - const refreshed = await refreshOrcaCloudSession(config, session) - const nextSession = { - accessToken: refreshed.accessToken, - refreshToken: refreshed.refreshToken, - expiresAt: refreshed.expiresAt, - organizations: refreshed.organizations, - capabilities: refreshed.capabilities + // Why: refresh tokens rotate, so concurrent refreshes must single-flight; + // a second POST with the same refresh token can trip server reuse detection + // and revoke the whole token family. + const key = cloudSessionRefreshKey(active.profile.id, userDataPath) + const inflight = inflightCloudSessionRefreshes.get(key) + if (inflight) { + return inflight + } + const task = (async () => { + const current = readOrcaCloudSession(active.profile.id, userDataPath) + if (current.status === 'found' && current.session.refreshToken !== session.refreshToken) { + // Another caller already rotated this session; reuse its result. + return current.session + } + const refreshed = await refreshOrcaCloudSession(config, session) + const nextSession = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: refreshed.expiresAt, + organizations: refreshed.organizations, + capabilities: refreshed.capabilities + } + saveOrcaCloudSession(active.profile.id, userDataPath, nextSession) + linkOrcaProfileToCloud(active.profile.id, refreshed.cloud, userDataPath) + return nextSession + })() + inflightCloudSessionRefreshes.set(key, task) + try { + return await task + } finally { + inflightCloudSessionRefreshes.delete(key) } - saveOrcaCloudSession(active.profile.id, userDataPath, nextSession) - linkOrcaProfileToCloud(active.profile.id, refreshed.cloud, userDataPath) - return nextSession } export async function readFreshOrcaCloudSession( @@ -67,11 +108,8 @@ export async function readFreshOrcaCloudSession( session: await refreshStoredCloudSession(config, active, userDataPath, session.session) } } catch (error) { - if ( - error instanceof OrcaCloudRequestError && - (error.statusCode === 401 || error.statusCode === 403) - ) { - clearOrcaCloudSession(active.profile.id, userDataPath) + if (isOrcaCloudAuthFailure(error)) { + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session.session) return { status: 'reconnect-required' } } throw error @@ -91,7 +129,7 @@ export async function forceRefreshOrcaCloudSession( } } catch (error) { if (isOrcaCloudAuthFailure(error)) { - clearOrcaCloudSession(active.profile.id, userDataPath) + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session) return { status: 'reconnect-required' } } throw error @@ -126,8 +164,12 @@ export async function runWithFreshOrcaCloudSession( try { return { status: 'ok', value: await operation(refreshed.session) } } catch (retryError) { - if (isOrcaCloudAuthFailure(retryError)) { - clearOrcaCloudSession(active.profile.id, userDataPath) + // Why: a 401 after a successful refresh means the session itself is + // rejected. A 403 is an authorization (permission) failure — signing + // the user out for it would destroy a valid session, so let it surface + // as a failed operation instead. + if (retryError instanceof OrcaCloudRequestError && retryError.statusCode === 401) { + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, refreshed.session) return { status: 'reconnect-required' } } throw retryError diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index c0d597d9106..a2d3446a99c 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -153,4 +153,46 @@ describe('profile index store', () => { expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile') }) + + it('recovers a corrupted profile index from the backup copy', async () => { + const store = await loadProfileIndexStore() + store.ensureActiveOrcaProfile() + const created = store.createLocalOrcaProfile({ name: 'Work' }) + // Trigger one more write so the backup captures the two-profile index. + store.setActiveOrcaProfile(created.profile.id) + + const indexPath = store.getOrcaProfileIndexPath() + expect(existsSync(`${indexPath}.bak`)).toBe(true) + writeFileSync(indexPath, '{ not json', 'utf-8') + + const recovered = store.getOrcaProfileListState() + expect(recovered.profiles.map((profile) => profile.id)).toContain(created.profile.id) + expect(recovered.profiles.length).toBeGreaterThanOrEqual(2) + }) + + it('rejects profile ids that are not safe path segments', async () => { + const store = await loadProfileIndexStore() + const indexPath = store.getOrcaProfileIndexPath() + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: '../../escape', + profiles: [ + { + id: '../../escape', + name: 'Evil', + avatar: { kind: 'initials', initials: 'E', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] + } + mkdirSync(testState.dir, { recursive: true }) + writeFileSync(indexPath, JSON.stringify(index), 'utf-8') + + // The tampered entry is filtered; startup falls back to a fresh default. + const state = store.ensureActiveOrcaProfile() + expect(state.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID) + }) }) diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts index 53c3b74344a..903e7a85ff8 100644 --- a/src/main/orca-profiles/profile-index-store.ts +++ b/src/main/orca-profiles/profile-index-store.ts @@ -8,6 +8,7 @@ import { } from 'node:fs' import { randomUUID } from 'node:crypto' import { dirname } from 'node:path' +import type { GlobalSettings } from '../../shared/types' import { createDefaultLocalOrcaProfile, DEFAULT_LOCAL_ORCA_PROFILE_ID, @@ -60,7 +61,9 @@ function isProfileSummary(value: unknown): value is OrcaProfileSummary { const cloud = value.cloud return ( typeof value.id === 'string' && - value.id.length > 0 && + // Why: IDs from the on-disk index become filesystem path segments; a + // tampered index must not be able to escape the profiles directory. + /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value.id) && typeof value.name === 'string' && value.name.length > 0 && (value.kind === 'local' || value.kind === 'cloud-linked') && @@ -100,7 +103,7 @@ function sanitizeProfileName(value: unknown): string { return trimmed.length > 0 ? trimmed.slice(0, 80) : 'New Profile' } -export function readProfileIndex(indexPath: string): OrcaProfileIndex | null { +function readProfileIndexFile(indexPath: string): OrcaProfileIndex | null { try { return normalizeProfileIndex(JSON.parse(readFileSync(indexPath, 'utf-8'))) } catch { @@ -108,8 +111,23 @@ export function readProfileIndex(indexPath: string): OrcaProfileIndex | null { } } +export function readProfileIndex(indexPath: string): OrcaProfileIndex | null { + // Why: a torn/corrupt index must not silently reset the app to a single + // default profile — that would orphan every other profile's data directory. + return readProfileIndexFile(indexPath) ?? readProfileIndexFile(`${indexPath}.bak`) +} + export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): void { mkdirSync(dirname(indexPath), { recursive: true }) + // Why: only a still-parseable current index may refresh the backup; + // copying a corrupt file over the backup would destroy the recovery copy. + if (existsSync(indexPath) && readProfileIndexFile(indexPath)) { + try { + copyFileSync(indexPath, `${indexPath}.bak`) + } catch { + // Best-effort backup; the primary write below still proceeds. + } + } const tmpPath = `${indexPath}.tmp` writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8') renameSync(tmpPath, indexPath) @@ -120,7 +138,11 @@ function copyIfPresent(source: string, target: string): void { return } mkdirSync(dirname(target), { recursive: true }) - copyFileSync(source, target) + // Why: tmp+rename so a crash mid-copy cannot leave a truncated target that + // the exists() guard above would then treat as a completed migration. + const tmpTarget = `${target}.tmp` + copyFileSync(source, tmpTarget) + renameSync(tmpTarget, target) } function copyLegacyStateToProfile(userDataPath: string, profileId: string): void { @@ -135,6 +157,28 @@ function copyLegacyStateToProfile(userDataPath: string, profileId: string): void } } +// Why: a brand-new profile has no data file, which the telemetry cohort +// migration reads as a fresh install and defaults to opted-in. Copying the +// active profile's consent block keeps an opted-out user opted out (and keeps +// one installId per install) when they create additional profiles. +export function seedNewOrcaProfileTelemetryConsent( + profileId: string, + telemetry: GlobalSettings['telemetry'], + userDataPath = getProfileUserDataPath() +): void { + if (!telemetry) { + return + } + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + if (existsSync(dataFile)) { + return + } + mkdirSync(dirname(dataFile), { recursive: true }) + const tmpPath = `${dataFile}.tmp` + writeFileSync(tmpPath, JSON.stringify({ settings: { telemetry } }, null, 2), 'utf-8') + renameSync(tmpPath, dataFile) +} + function createInitialProfileIndex(now = Date.now()): OrcaProfileIndex { const profile = createDefaultLocalOrcaProfile(now) return { diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index c982bd03d7c..8380e31301b 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -170,7 +170,11 @@ function rekeyBrowserWorkspace( return { ...structuredClone(workspace), worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId), - sessionProfileId: null + // Why: both the session profile and the resolved partition string are + // source-profile-scoped; carrying either across would point the restored + // pane at a partition the target profile's allowlist rejects. + sessionProfileId: null, + sessionPartition: null } } diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts index 1e65ad60c99..3c3a4643387 100644 --- a/src/main/orca-profiles/profile-project-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -213,6 +213,7 @@ describe('profile project transfer', () => { id: 'browser-1', worktreeId: sourceWorktreeId, sessionProfileId: 'source-browser-profile', + sessionPartition: 'persist:orca-profile-personal-deadbeef-browser-default', url: 'https://example.com', title: 'Example', loading: false, @@ -258,7 +259,8 @@ describe('profile project transfer', () => { expect(target.sshTargets).toEqual([sshTarget]) expect(target.workspaceSession.browserTabsByWorktree?.[sourceWorktreeId]?.[0]).toMatchObject({ worktreeId: sourceWorktreeId, - sessionProfileId: null + sessionProfileId: null, + sessionPartition: null }) }) diff --git a/src/main/orca-profiles/profile-project-transfer.ts b/src/main/orca-profiles/profile-project-transfer.ts index 6a9f17f95c8..75022d3ad65 100644 --- a/src/main/orca-profiles/profile-project-transfer.ts +++ b/src/main/orca-profiles/profile-project-transfer.ts @@ -1,6 +1,6 @@ -import { - type TransferOrcaProfileProjectArgs, - type TransferOrcaProfileProjectResult +import type { + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult } from '../../shared/orca-profiles' import { getOrcaProfileListState } from './profile-index-store' import { readProfileState, writeProfileState } from './profile-project-state-file' diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 991f9be3606..9ae034d519a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2564,6 +2564,10 @@ export class Store { private writeTimer: ReturnType | null = null private pendingWrite: Promise | null = null private writeGeneration = 0 + // Why: after a profile transfer rewrites this store's file on disk behind + // its back, the stale in-memory state must never be persisted again — a + // late sync flush before the relaunch would resurrect the moved project. + private writesFrozen = false // Why: hash of the plaintext state as of the last successful write. Saves // triggered by mutations that net out to identical state skip the full // 1.6MB pretty-print + tmp write + rename. Hashing plaintext (not the @@ -3594,6 +3598,9 @@ export class Store { // Why: async writes avoid blocking the main Electron thread on every // debounced save during active use. private async writeToDiskAsync(): Promise { + if (this.writesFrozen) { + return + } const gen = this.writeGeneration const stateHash = this.computeStateHash() // Why: a mutation burst that nets out to already-persisted state (or a @@ -3647,6 +3654,9 @@ export class Store { // Why: synchronous variant kept only for flush() at shutdown, where the // process may exit before an async write completes. private writeToDiskSync(opts: { force?: boolean } = {}): void { + if (this.writesFrozen) { + return + } const stateHash = this.computeStateHash() // Why: skipping is safe under flushOrThrow's durability contract — a // matching hash means this exact state is already the file's content. @@ -6147,6 +6157,17 @@ export class Store { this.writeGithubCacheSnapshotSync() } + // Why: called after a project move rewrote this store's data file directly. + // From that point until relaunch, the in-memory state is stale and any + // write (debounced, sync, or shutdown flush) would undo the transfer. + freezeWrites(): void { + this.writesFrozen = true + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + } + // Why best-effort: the sidecar is a refetchable cache — a failed write only // costs a cold badge paint on next launch, never data. private writeGithubCacheSnapshotSync(): void { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 14edc53a999..92499014cff 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -853,7 +853,10 @@ function App(): React.JSX.Element { const startupStartedAt = performance.now() logRendererStartupDiagnostic('startup-chain-start') try { - await actions.fetchOrcaProfiles() + // Why: profile state only feeds the switcher and the add-project + // advisory — nothing in the hydration chain reads it synchronously, + // so it must not add a serial IPC round-trip before fetchSettings. + void actions.fetchOrcaProfiles() // Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId. // Load settings first so a persisted remote runtime does not boot against // the local filesystem and then hydrate stale local workspace state. diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx index c54fa103793..8f1577c7998 100644 --- a/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx +++ b/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx @@ -205,8 +205,10 @@ export function OrcaProfileManagementDialog({
{activeProfile.name}
{translate( - 'auto.components.orca.profiles.management.18dfc4a0f7', - '{{count}} projects', + repos.length === 1 + ? 'auto.components.orca.profiles.management.projectCountSingular' + : 'auto.components.orca.profiles.management.projectCountPlural', + repos.length === 1 ? '{{count}} project' : '{{count}} projects', { count: repos.length } )}
diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx index f7b650067b5..4b5678e52b1 100644 --- a/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx +++ b/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx @@ -30,8 +30,12 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { if (summary.liveTerminalTabCount > 0) { lines.push( translate( - 'auto.components.orca.profiles.project.transfer.confirm.terminal', - '{{count}} live terminal tabs', + summary.liveTerminalTabCount === 1 + ? 'auto.components.orca.profiles.project.transfer.confirm.terminalSingular' + : 'auto.components.orca.profiles.project.transfer.confirm.terminalPlural', + summary.liveTerminalTabCount === 1 + ? '{{count}} live terminal tab' + : '{{count}} live terminal tabs', { count: summary.liveTerminalTabCount } ) ) @@ -39,8 +43,10 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { if (summary.liveAgentCount > 0) { lines.push( translate( - 'auto.components.orca.profiles.project.transfer.confirm.agent', - '{{count}} active agents', + summary.liveAgentCount === 1 + ? 'auto.components.orca.profiles.project.transfer.confirm.agentSingular' + : 'auto.components.orca.profiles.project.transfer.confirm.agentPlural', + summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents', { count: summary.liveAgentCount } ) ) @@ -48,8 +54,12 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { if (summary.browserWorkspaceCount > 0) { lines.push( translate( - 'auto.components.orca.profiles.project.transfer.confirm.browser', - '{{count}} browser workspaces', + summary.browserWorkspaceCount === 1 + ? 'auto.components.orca.profiles.project.transfer.confirm.browserSingular' + : 'auto.components.orca.profiles.project.transfer.confirm.browserPlural', + summary.browserWorkspaceCount === 1 + ? '{{count}} browser workspace' + : '{{count}} browser workspaces', { count: summary.browserWorkspaceCount } ) ) @@ -131,10 +141,7 @@ export function OrcaProfileProjectTransferConfirmDialog({ diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx index 295f2bf44fc..8fb4e7f6abb 100644 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, Cloud, Laptop, Loader2, Plus, Settings2 } from 'lucide-react' import { useShallow } from 'zustand/react/shallow' import { Button } from '@/components/ui/button' @@ -23,6 +23,10 @@ import { OrcaProfileSignOutConfirmDialog } from './OrcaProfileSignOutConfirmDial import { OrcaProfileSwitchConfirmDialog } from './OrcaProfileSwitchConfirmDialog' import { getOrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' +function isWebClient(): boolean { + return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) +} + function getProfileSubtitle(profile: OrcaProfileSummary): string { if (profile.cloud?.activeOrgName) { return profile.cloud.activeOrgName @@ -69,13 +73,20 @@ export function OrcaProfileSwitcher({ [pendingSwitchProfileId, profiles] ) + // Why: one attempt per mount — retrying on every loading toggle would spin + // an unbounded IPC loop when the list call persistently fails. + const fetchAttemptedRef = useRef(false) useEffect(() => { - if (profiles.length === 0 && !loading) { + if (profiles.length === 0 && !loading && !fetchAttemptedRef.current) { + fetchAttemptedRef.current = true void fetchProfiles() } }, [fetchProfiles, loading, profiles.length]) - if (!activeProfile) { + // Why: paired web/mobile clients only see the desktop stub's fabricated + // profile list; showing a switcher there would misreport the active profile + // and none of its actions can work remotely. + if (isWebClient() || !activeProfile) { return null } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4a7317c4352..82dcca0ae28 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -244,7 +244,8 @@ "6d3318e813": "Failed to import repositories", "3be0f7df04": "Cannot open folder on selected runtime", "15cf5319ec": "{{path}} was checked on {{hostName}}, but that host did not report a usable folder.", - "2dcd706774": "Project also exists in another profile" + "2dcd706774": "Project also exists in another profile", + "presenceProfileOverflow": "{{names}} +{{count}} more" }, "settings": { "e12dab333b": "Failed to switch servers", @@ -259,6 +260,24 @@ }, "linear": { "37d36984d0": "Linear connection was superseded by a newer request." + }, + "orca": { + "profiles": { + "612f7f6861": "Failed to create profile", + "319d7cf39b": "Cloud profile created", + "d6e764e7db": "Reconnect this profile", + "f0c9e11a6d": "Failed to create cloud profile", + "8b8fa73174": "Orca Cloud sign-in is not configured", + "33290e88ed": "Failed to connect profile", + "9fcb07a796": "Profile connected", + "2f6c78a039": "Failed to refresh profile auth", + "a37b5e6d37": "Signed out of profile", + "83600521e7": "Failed to sign out", + "76deec8f58": "Failed to switch organization", + "7d4bc516ee": "Failed to switch profile", + "f518e89aa5": "Project already exists in that profile", + "f03ae7f27b": "Failed to transfer project" + } } } }, @@ -12344,16 +12363,14 @@ "816ce624b6": "Project copied", "2c45bda8d3": "Manage profiles", "2db945e4a0": "Copy or move projects from the active profile to another local profile.", - "18dfc4a0f7": "{{count}} projects", "8668cb2946": "No projects in this profile.", - "93034915ab": "Create another profile before copying projects." + "93034915ab": "Create another profile before copying projects.", + "projectCountSingular": "{{count}} project", + "projectCountPlural": "{{count}} projects" }, "project": { "transfer": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", "move": { "title": "Move project?", "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", @@ -12367,7 +12384,13 @@ "live": { "work": "Live work in this project" }, - "cancel": "Cancel" + "cancel": "Cancel", + "terminalSingular": "{{count}} live terminal tab", + "terminalPlural": "{{count}} live terminal tabs", + "agentSingular": "{{count}} active agent", + "agentPlural": "{{count}} active agents", + "browserSingular": "{{count}} browser workspace", + "browserPlural": "{{count}} browser workspaces" } } }, @@ -12381,9 +12404,6 @@ }, "switch": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", "target": "the selected profile", "title": "Switch profiles?", "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", @@ -12391,7 +12411,13 @@ "work": "Live work in this profile" }, "cancel": "Cancel", - "switch": "Switch profile" + "switch": "Switch profile", + "terminalSingular": "{{count}} live terminal tab", + "terminalPlural": "{{count}} live terminal tabs", + "agentSingular": "{{count}} active agent", + "agentPlural": "{{count}} active agents", + "browserSingular": "{{count}} browser workspace", + "browserPlural": "{{count}} browser workspaces" } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c4999b87edb..0c6c5720fee 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -244,7 +244,8 @@ "6d3318e813": "No se pudieron importar repositorios", "3be0f7df04": "No se puede abrir la carpeta en el host seleccionado", "15cf5319ec": "{{path}} se verificó en {{hostName}}, pero ese host no reportó ninguna carpeta utilizable.", - "2dcd706774": "Project also exists in another profile" + "2dcd706774": "El proyecto también existe en otro perfil", + "presenceProfileOverflow": "{{names}} y {{count}} más" }, "settings": { "e12dab333b": "No se pudo cambiar de servidor", @@ -259,6 +260,24 @@ }, "linear": { "37d36984d0": "La conexión de Linear fue reemplazada por una solicitud más reciente." + }, + "orca": { + "profiles": { + "612f7f6861": "No se pudo crear el perfil", + "319d7cf39b": "Perfil en la nube creado", + "d6e764e7db": "Reconecta este perfil", + "f0c9e11a6d": "No se pudo crear el perfil en la nube", + "8b8fa73174": "El inicio de sesión de Orca Cloud no está configurado", + "33290e88ed": "No se pudo conectar el perfil", + "9fcb07a796": "Perfil conectado", + "2f6c78a039": "No se pudo actualizar la autenticación del perfil", + "a37b5e6d37": "Sesión cerrada en el perfil", + "83600521e7": "No se pudo cerrar la sesión", + "76deec8f58": "No se pudo cambiar de organización", + "7d4bc516ee": "No se pudo cambiar de perfil", + "f518e89aa5": "El proyecto ya existe en ese perfil", + "f03ae7f27b": "No se pudo transferir el proyecto" + } } } }, @@ -12314,84 +12333,91 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "Cloud sign-in unavailable" + "unavailable": "Inicio de sesión en la nube no disponible" }, - "reconnect": "Reconnect profile", - "connect": "Connect profile", - "organization": "Organization", + "reconnect": "Reconectar perfil", + "connect": "Conectar perfil", + "organization": "Organización", "create": { "profile": { "for": { - "org": "Create profile for org" + "org": "Crear perfil para la organización" } } }, - "signout": "Sign out", - "16e3681072": "New local profile", - "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", - "f322e1f4d6": "Profile name", - "cfa59f8ad1": "Create and Switch", + "signout": "Cerrar sesión", + "16e3681072": "Nuevo perfil local", + "e3b91a3d90": "Crea un perfil vacío para proyectos y worktrees separados.", + "f322e1f4d6": "Nombre del perfil", + "cfa59f8ad1": "Crear y cambiar", "b4f9d1125d": "Local", - "4815f7d163": "Switch profile", - "d00d853e2a": "Manage profiles", - "c106c674fe": "New local profile" + "4815f7d163": "Cambiar de perfil", + "d00d853e2a": "Gestionar perfiles", + "c106c674fe": "Nuevo perfil local" }, "management": { - "04e7bd2a23": "Transfer", - "128c7dfe64": "Copy to", - "df8b7d876b": "Move to", - "9aa26347b3": "Project moved", - "816ce624b6": "Project copied", - "2c45bda8d3": "Manage profiles", - "2db945e4a0": "Copy or move projects from the active profile to another local profile.", - "18dfc4a0f7": "{{count}} projects", - "8668cb2946": "No projects in this profile.", - "93034915ab": "Create another profile before copying projects." + "04e7bd2a23": "Transferir", + "128c7dfe64": "Copiar a", + "df8b7d876b": "Mover a", + "9aa26347b3": "Proyecto movido", + "816ce624b6": "Proyecto copiado", + "2c45bda8d3": "Gestionar perfiles", + "2db945e4a0": "Copia o mueve proyectos del perfil activo a otro perfil local.", + "8668cb2946": "No hay proyectos en este perfil.", + "93034915ab": "Crea otro perfil antes de copiar proyectos.", + "projectCountSingular": "{{count}} proyecto", + "projectCountPlural": "{{count}} proyectos" }, "project": { "transfer": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", "move": { - "title": "Move project?", - "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", - "action": "Move project" + "title": "¿Mover el proyecto?", + "description": "Mueve {{repoName}} a {{targetName}}. Orca lo quita de {{activeProfileName}}, mantiene los archivos en su lugar y se reinicia en {{targetName}}.", + "action": "Mover proyecto" }, "copy": { - "title": "Copy project?", - "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", - "action": "Copy project" + "title": "¿Copiar el proyecto?", + "description": "Copia {{repoName}} a {{targetName}}. Ambos perfiles apuntarán a los mismos archivos con metadatos de Orca separados.", + "action": "Copiar proyecto" }, "live": { - "work": "Live work in this project" + "work": "Trabajo en curso en este proyecto" }, - "cancel": "Cancel" + "cancel": "Cancelar", + "terminalSingular": "{{count}} pestaña de terminal activa", + "terminalPlural": "{{count}} pestañas de terminal activas", + "agentSingular": "{{count}} agente activo", + "agentPlural": "{{count}} agentes activos", + "browserSingular": "{{count}} espacio de trabajo del navegador", + "browserPlural": "{{count}} espacios de trabajo del navegador" } } }, "signout": { "confirm": { - "title": "Sign out?", - "description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.", - "cancel": "Cancel", - "action": "Sign out" + "title": "¿Cerrar sesión?", + "description": "Cierra la sesión de {{profileName}} y conserva sus proyectos, worktrees y metadatos locales en este dispositivo.", + "cancel": "Cancelar", + "action": "Cerrar sesión" } }, "switch": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", - "target": "the selected profile", - "title": "Switch profiles?", - "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "target": "el perfil seleccionado", + "title": "¿Cambiar de perfil?", + "description": "Al cambiar a {{targetName}}, Orca se reiniciará y volverá a cargar el espacio de trabajo de {{activeProfileName}}.", "live": { - "work": "Live work in this profile" + "work": "Trabajo en curso en este perfil" }, - "cancel": "Cancel", - "switch": "Switch profile" + "cancel": "Cancelar", + "switch": "Cambiar de perfil", + "terminalSingular": "{{count}} pestaña de terminal activa", + "terminalPlural": "{{count}} pestañas de terminal activas", + "agentSingular": "{{count}} agente activo", + "agentPlural": "{{count}} agentes activos", + "browserSingular": "{{count}} espacio de trabajo del navegador", + "browserPlural": "{{count}} espacios de trabajo del navegador" } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 01f67be2090..75f49855611 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -244,7 +244,8 @@ "6d3318e813": "repos のインポートに失敗しました", "3be0f7df04": "選択したランタイムでフォルダーを開けません", "15cf5319ec": "{{path}} は {{hostName}} でチェックされましたが、そのホストは使用可能なフォルダーを報告しませんでした。", - "2dcd706774": "Project also exists in another profile" + "2dcd706774": "プロジェクトは別のプロファイルにも存在します", + "presenceProfileOverflow": "{{names}} 他 {{count}} 件" }, "settings": { "e12dab333b": "サーバーの切り替えに失敗しました", @@ -259,6 +260,24 @@ }, "linear": { "37d36984d0": "Linear 接続は新しいリクエストに置き換えられました。" + }, + "orca": { + "profiles": { + "612f7f6861": "プロファイルの作成に失敗しました", + "319d7cf39b": "クラウド プロファイルが作成されました", + "d6e764e7db": "このプロファイルを再接続してください", + "f0c9e11a6d": "クラウド プロファイルの作成に失敗しました", + "8b8fa73174": "Orca Cloud のサインインが構成されていません", + "33290e88ed": "プロファイルの接続に失敗しました", + "9fcb07a796": "プロファイルが接続されました", + "2f6c78a039": "プロファイル認証の更新に失敗しました", + "a37b5e6d37": "プロファイルからサインアウトしました", + "83600521e7": "サインアウトに失敗しました", + "76deec8f58": "組織の切り替えに失敗しました", + "7d4bc516ee": "プロファイルの切り替えに失敗しました", + "f518e89aa5": "プロジェクトはそのプロファイルに既に存在します", + "f03ae7f27b": "プロジェクトの転送に失敗しました" + } } } }, @@ -12314,84 +12333,91 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "Cloud sign-in unavailable" + "unavailable": "クラウド サインインは利用できません" }, - "reconnect": "Reconnect profile", - "connect": "Connect profile", - "organization": "Organization", + "reconnect": "プロファイルを再接続", + "connect": "プロファイルを接続", + "organization": "組織", "create": { "profile": { "for": { - "org": "Create profile for org" + "org": "組織用のプロファイルを作成" } } }, - "signout": "Sign out", - "16e3681072": "New local profile", - "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", - "f322e1f4d6": "Profile name", - "cfa59f8ad1": "Create and Switch", - "b4f9d1125d": "Local", - "4815f7d163": "Switch profile", - "d00d853e2a": "Manage profiles", - "c106c674fe": "New local profile" + "signout": "サインアウト", + "16e3681072": "新しいローカル プロファイル", + "e3b91a3d90": "プロジェクトとワークツリーを分けるための空のプロファイルを作成します。", + "f322e1f4d6": "プロファイル名", + "cfa59f8ad1": "作成して切り替え", + "b4f9d1125d": "ローカル", + "4815f7d163": "プロファイルを切り替え", + "d00d853e2a": "プロファイルを管理", + "c106c674fe": "新しいローカル プロファイル" }, "management": { - "04e7bd2a23": "Transfer", - "128c7dfe64": "Copy to", - "df8b7d876b": "Move to", - "9aa26347b3": "Project moved", - "816ce624b6": "Project copied", - "2c45bda8d3": "Manage profiles", - "2db945e4a0": "Copy or move projects from the active profile to another local profile.", - "18dfc4a0f7": "{{count}} projects", - "8668cb2946": "No projects in this profile.", - "93034915ab": "Create another profile before copying projects." + "04e7bd2a23": "転送", + "128c7dfe64": "コピー先", + "df8b7d876b": "移動先", + "9aa26347b3": "プロジェクトを移動しました", + "816ce624b6": "プロジェクトをコピーしました", + "2c45bda8d3": "プロファイルを管理", + "2db945e4a0": "アクティブなプロファイルから別のローカル プロファイルへプロジェクトをコピーまたは移動します。", + "8668cb2946": "このプロファイルにはプロジェクトがありません。", + "93034915ab": "プロジェクトをコピーする前に、別のプロファイルを作成してください。", + "projectCountSingular": "{{count}} 件のプロジェクト", + "projectCountPlural": "{{count}} 件のプロジェクト" }, "project": { "transfer": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", "move": { - "title": "Move project?", - "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", - "action": "Move project" + "title": "プロジェクトを移動しますか?", + "description": "{{repoName}} を {{targetName}} に移動します。Orca は {{activeProfileName}} から削除し、ファイルはそのまま残して {{targetName}} で再起動します。", + "action": "プロジェクトを移動" }, "copy": { - "title": "Copy project?", - "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", - "action": "Copy project" + "title": "プロジェクトをコピーしますか?", + "description": "{{repoName}} を {{targetName}} にコピーします。両方のプロファイルは同じファイルを参照し、Orca のメタデータは別々に保持されます。", + "action": "プロジェクトをコピー" }, "live": { - "work": "Live work in this project" + "work": "このプロジェクトで実行中の作業" }, - "cancel": "Cancel" + "cancel": "キャンセル", + "terminalSingular": "{{count}} 個の実行中ターミナル タブ", + "terminalPlural": "{{count}} 個の実行中ターミナル タブ", + "agentSingular": "{{count}} 個のアクティブなエージェント", + "agentPlural": "{{count}} 個のアクティブなエージェント", + "browserSingular": "{{count}} 個のブラウザ ワークスペース", + "browserPlural": "{{count}} 個のブラウザ ワークスペース" } } }, "signout": { "confirm": { - "title": "Sign out?", - "description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.", - "cancel": "Cancel", - "action": "Sign out" + "title": "サインアウトしますか?", + "description": "{{profileName}} からサインアウトし、そのプロジェクト、ワークツリー、ローカル メタデータはこのデバイスに保持します。", + "cancel": "キャンセル", + "action": "サインアウト" } }, "switch": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", - "target": "the selected profile", - "title": "Switch profiles?", - "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "target": "選択したプロファイル", + "title": "プロファイルを切り替えますか?", + "description": "{{targetName}} に切り替えると、Orca が再起動し、{{activeProfileName}} のワークスペースが再読み込みされます。", "live": { - "work": "Live work in this profile" + "work": "このプロファイルで実行中の作業" }, - "cancel": "Cancel", - "switch": "Switch profile" + "cancel": "キャンセル", + "switch": "プロファイルを切り替え", + "terminalSingular": "{{count}} 個の実行中ターミナル タブ", + "terminalPlural": "{{count}} 個の実行中ターミナル タブ", + "agentSingular": "{{count}} 個のアクティブなエージェント", + "agentPlural": "{{count}} 個のアクティブなエージェント", + "browserSingular": "{{count}} 個のブラウザ ワークスペース", + "browserPlural": "{{count}} 個のブラウザ ワークスペース" } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3bf94712fd6..eb3f9e8115b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -244,7 +244,8 @@ "6d3318e813": "repos를 가져오지 못했습니다.", "3be0f7df04": "선택한 런타임에서 폴더를 열 수 없습니다.", "15cf5319ec": "{{path}}이(가) {{hostName}}에서 확인되었지만 해당 호스트가 사용 가능한 폴더를 보고하지 않았습니다.", - "2dcd706774": "Project also exists in another profile" + "2dcd706774": "프로젝트가 다른 프로필에도 있습니다", + "presenceProfileOverflow": "{{names}} 외 {{count}}개" }, "settings": { "e12dab333b": "서버를 전환하지 못했습니다.", @@ -259,6 +260,24 @@ }, "linear": { "37d36984d0": "더 새로운 요청이 있어 Linear 연결 요청을 건너뛰었습니다." + }, + "orca": { + "profiles": { + "612f7f6861": "프로필을 생성하지 못했습니다", + "319d7cf39b": "클라우드 프로필이 생성되었습니다", + "d6e764e7db": "이 프로필을 다시 연결하세요", + "f0c9e11a6d": "클라우드 프로필을 생성하지 못했습니다", + "8b8fa73174": "Orca Cloud 로그인이 구성되어 있지 않습니다", + "33290e88ed": "프로필을 연결하지 못했습니다", + "9fcb07a796": "프로필이 연결되었습니다", + "2f6c78a039": "프로필 인증을 새로 고치지 못했습니다", + "a37b5e6d37": "프로필에서 로그아웃했습니다", + "83600521e7": "로그아웃하지 못했습니다", + "76deec8f58": "조직을 전환하지 못했습니다", + "7d4bc516ee": "프로필을 전환하지 못했습니다", + "f518e89aa5": "프로젝트가 해당 프로필에 이미 있습니다", + "f03ae7f27b": "프로젝트를 전송하지 못했습니다" + } } } }, @@ -12314,84 +12333,91 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "Cloud sign-in unavailable" + "unavailable": "클라우드 로그인을 사용할 수 없습니다" }, - "reconnect": "Reconnect profile", - "connect": "Connect profile", - "organization": "Organization", + "reconnect": "프로필 다시 연결", + "connect": "프로필 연결", + "organization": "조직", "create": { "profile": { "for": { - "org": "Create profile for org" + "org": "조직용 프로필 만들기" } } }, - "signout": "Sign out", - "16e3681072": "New local profile", - "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", - "f322e1f4d6": "Profile name", - "cfa59f8ad1": "Create and Switch", - "b4f9d1125d": "Local", - "4815f7d163": "Switch profile", - "d00d853e2a": "Manage profiles", - "c106c674fe": "New local profile" + "signout": "로그아웃", + "16e3681072": "새 로컬 프로필", + "e3b91a3d90": "프로젝트와 워크트리를 분리할 빈 프로필을 만듭니다.", + "f322e1f4d6": "프로필 이름", + "cfa59f8ad1": "만들고 전환", + "b4f9d1125d": "로컬", + "4815f7d163": "프로필 전환", + "d00d853e2a": "프로필 관리", + "c106c674fe": "새 로컬 프로필" }, "management": { - "04e7bd2a23": "Transfer", - "128c7dfe64": "Copy to", - "df8b7d876b": "Move to", - "9aa26347b3": "Project moved", - "816ce624b6": "Project copied", - "2c45bda8d3": "Manage profiles", - "2db945e4a0": "Copy or move projects from the active profile to another local profile.", - "18dfc4a0f7": "{{count}} projects", - "8668cb2946": "No projects in this profile.", - "93034915ab": "Create another profile before copying projects." + "04e7bd2a23": "전송", + "128c7dfe64": "복사 대상", + "df8b7d876b": "이동 대상", + "9aa26347b3": "프로젝트를 이동했습니다", + "816ce624b6": "프로젝트를 복사했습니다", + "2c45bda8d3": "프로필 관리", + "2db945e4a0": "활성 프로필의 프로젝트를 다른 로컬 프로필로 복사하거나 이동합니다.", + "8668cb2946": "이 프로필에는 프로젝트가 없습니다.", + "93034915ab": "프로젝트를 복사하려면 먼저 다른 프로필을 만드세요.", + "projectCountSingular": "{{count}}개의 프로젝트", + "projectCountPlural": "{{count}}개의 프로젝트" }, "project": { "transfer": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", "move": { - "title": "Move project?", - "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", - "action": "Move project" + "title": "프로젝트를 이동하시겠습니까?", + "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{activeProfileName}}에서 제거하고 파일은 그대로 유지한 채 {{targetName}}(으)로 다시 시작합니다.", + "action": "프로젝트 이동" }, "copy": { - "title": "Copy project?", - "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", - "action": "Copy project" + "title": "프로젝트를 복사하시겠습니까?", + "description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필은 같은 파일을 가리키며 Orca 메타데이터는 별도로 유지됩니다.", + "action": "프로젝트 복사" }, "live": { - "work": "Live work in this project" + "work": "이 프로젝트에서 진행 중인 작업" }, - "cancel": "Cancel" + "cancel": "취소", + "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", + "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", + "agentSingular": "{{count}}개의 활성 에이전트", + "agentPlural": "{{count}}개의 활성 에이전트", + "browserSingular": "{{count}}개의 브라우저 워크스페이스", + "browserPlural": "{{count}}개의 브라우저 워크스페이스" } } }, "signout": { "confirm": { - "title": "Sign out?", - "description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.", - "cancel": "Cancel", - "action": "Sign out" + "title": "로그아웃하시겠습니까?", + "description": "{{profileName}}에서 로그아웃하고 해당 프로젝트, 워크트리 및 로컬 메타데이터는 이 기기에 유지합니다.", + "cancel": "취소", + "action": "로그아웃" } }, "switch": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", - "target": "the selected profile", - "title": "Switch profiles?", - "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "target": "선택한 프로필", + "title": "프로필을 전환하시겠습니까?", + "description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 워크스페이스가 다시 로드됩니다.", "live": { - "work": "Live work in this profile" + "work": "이 프로필에서 진행 중인 작업" }, - "cancel": "Cancel", - "switch": "Switch profile" + "cancel": "취소", + "switch": "프로필 전환", + "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", + "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", + "agentSingular": "{{count}}개의 활성 에이전트", + "agentPlural": "{{count}}개의 활성 에이전트", + "browserSingular": "{{count}}개의 브라우저 워크스페이스", + "browserPlural": "{{count}}개의 브라우저 워크스페이스" } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d632de7f4fb..1f74fbacd94 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -244,9 +244,8 @@ "6d3318e813": "导入存储库失败", "3be0f7df04": "无法在所选运行时打开文件夹", "15cf5319ec": "已在 {{hostName}} 上检查 {{path}},但该主机未报告可用文件夹。", - "a8e4b3af5b": "项目已添加", - "6d3318e813": "导入存储库失败", - "2dcd706774": "Project also exists in another profile" + "2dcd706774": "该项目也存在于另一个配置文件中", + "presenceProfileOverflow": "{{names}} 及另外 {{count}} 个" }, "settings": { "e12dab333b": "切换服务器失败", @@ -261,6 +260,24 @@ }, "linear": { "37d36984d0": "Linear 连接已被新的请求取代。" + }, + "orca": { + "profiles": { + "612f7f6861": "创建配置文件失败", + "319d7cf39b": "已创建云配置文件", + "d6e764e7db": "请重新连接此配置文件", + "f0c9e11a6d": "创建云配置文件失败", + "8b8fa73174": "未配置 Orca Cloud 登录", + "33290e88ed": "连接配置文件失败", + "9fcb07a796": "配置文件已连接", + "2f6c78a039": "刷新配置文件身份验证失败", + "a37b5e6d37": "已退出配置文件", + "83600521e7": "退出登录失败", + "76deec8f58": "切换组织失败", + "7d4bc516ee": "切换配置文件失败", + "f518e89aa5": "该项目已存在于那个配置文件中", + "f03ae7f27b": "转移项目失败" + } } } }, @@ -12316,84 +12333,91 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "Cloud sign-in unavailable" + "unavailable": "云登录不可用" }, - "reconnect": "Reconnect profile", - "connect": "Connect profile", - "organization": "Organization", + "reconnect": "重新连接配置文件", + "connect": "连接配置文件", + "organization": "组织", "create": { "profile": { "for": { - "org": "Create profile for org" + "org": "为组织创建配置文件" } } }, - "signout": "Sign out", - "16e3681072": "New local profile", - "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", - "f322e1f4d6": "Profile name", - "cfa59f8ad1": "Create and Switch", - "b4f9d1125d": "Local", - "4815f7d163": "Switch profile", - "d00d853e2a": "Manage profiles", - "c106c674fe": "New local profile" + "signout": "退出登录", + "16e3681072": "新建本地配置文件", + "e3b91a3d90": "创建一个空配置文件,用于分开管理项目和工作树。", + "f322e1f4d6": "配置文件名称", + "cfa59f8ad1": "创建并切换", + "b4f9d1125d": "本地", + "4815f7d163": "切换配置文件", + "d00d853e2a": "管理配置文件", + "c106c674fe": "新建本地配置文件" }, "management": { - "04e7bd2a23": "Transfer", - "128c7dfe64": "Copy to", - "df8b7d876b": "Move to", - "9aa26347b3": "Project moved", - "816ce624b6": "Project copied", - "2c45bda8d3": "Manage profiles", - "2db945e4a0": "Copy or move projects from the active profile to another local profile.", - "18dfc4a0f7": "{{count}} projects", - "8668cb2946": "No projects in this profile.", - "93034915ab": "Create another profile before copying projects." + "04e7bd2a23": "转移", + "128c7dfe64": "复制到", + "df8b7d876b": "移动到", + "9aa26347b3": "项目已移动", + "816ce624b6": "项目已复制", + "2c45bda8d3": "管理配置文件", + "2db945e4a0": "将项目从当前配置文件复制或移动到另一个本地配置文件。", + "8668cb2946": "此配置文件中没有项目。", + "93034915ab": "复制项目前,请先创建另一个配置文件。", + "projectCountSingular": "{{count}} 个项目", + "projectCountPlural": "{{count}} 个项目" }, "project": { "transfer": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", "move": { - "title": "Move project?", - "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", - "action": "Move project" + "title": "移动项目?", + "description": "将 {{repoName}} 移动到 {{targetName}}。Orca 会将其从 {{activeProfileName}} 中移除,保留文件原位,并重新启动进入 {{targetName}}。", + "action": "移动项目" }, "copy": { - "title": "Copy project?", - "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", - "action": "Copy project" + "title": "复制项目?", + "description": "将 {{repoName}} 复制到 {{targetName}}。两个配置文件将指向相同的文件,但使用各自独立的 Orca 元数据。", + "action": "复制项目" }, "live": { - "work": "Live work in this project" + "work": "此项目中正在进行的工作" }, - "cancel": "Cancel" + "cancel": "取消", + "terminalSingular": "{{count}} 个正在运行的终端选项卡", + "terminalPlural": "{{count}} 个正在运行的终端选项卡", + "agentSingular": "{{count}} 个活动的智能体", + "agentPlural": "{{count}} 个活动的智能体", + "browserSingular": "{{count}} 个浏览器工作区", + "browserPlural": "{{count}} 个浏览器工作区" } } }, "signout": { "confirm": { - "title": "Sign out?", - "description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.", - "cancel": "Cancel", - "action": "Sign out" + "title": "退出登录?", + "description": "退出 {{profileName}},并在此设备上保留其项目、工作树和本地元数据。", + "cancel": "取消", + "action": "退出登录" } }, "switch": { "confirm": { - "terminal": "{{count}} live terminal tabs", - "agent": "{{count}} active agents", - "browser": "{{count}} browser workspaces", - "target": "the selected profile", - "title": "Switch profiles?", - "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "target": "所选配置文件", + "title": "切换配置文件?", + "description": "切换到 {{targetName}} 将重新启动 Orca 并重新加载 {{activeProfileName}} 的工作区。", "live": { - "work": "Live work in this profile" + "work": "此配置文件中正在进行的工作" }, - "cancel": "Cancel", - "switch": "Switch profile" + "cancel": "取消", + "switch": "切换配置文件", + "terminalSingular": "{{count}} 个正在运行的终端选项卡", + "terminalPlural": "{{count}} 个正在运行的终端选项卡", + "agentSingular": "{{count}} 个活动的智能体", + "agentPlural": "{{count}} 个活动的智能体", + "browserSingular": "{{count}} 个浏览器工作区", + "browserPlural": "{{count}} 个浏览器工作区" } } } diff --git a/src/renderer/src/store/slices/orca-profiles.test.ts b/src/renderer/src/store/slices/orca-profiles.test.ts index 624a7fea459..e7f9c8cd37d 100644 --- a/src/renderer/src/store/slices/orca-profiles.test.ts +++ b/src/renderer/src/store/slices/orca-profiles.test.ts @@ -154,6 +154,19 @@ describe('orca profile slice', () => { expect(store.getState().orcaProfileSwitching).toBe(true) }) + it('releases switching state when main reports the profile is already active', async () => { + // Why: a stale renderer activeOrcaProfileId must not lock the switcher + // forever when no relaunch is actually coming. + orcaProfilesApi.switchProfile.mockResolvedValue({ status: 'already-active' }) + const store = createTestStore() + store.setState({ activeOrcaProfileId: 'local-default' }) + + const result = await store.getState().switchOrcaProfile('local-work') + + expect(result).toEqual({ status: 'already-active' }) + expect(store.getState().orcaProfileSwitching).toBe(false) + }) + it('does not call main when switching to the active profile', async () => { const store = createTestStore() store.setState({ activeOrcaProfileId: 'local-default' }) diff --git a/src/renderer/src/store/slices/orca-profiles.ts b/src/renderer/src/store/slices/orca-profiles.ts index e54fd8e9ddf..bf00008aac5 100644 --- a/src/renderer/src/store/slices/orca-profiles.ts +++ b/src/renderer/src/store/slices/orca-profiles.ts @@ -2,20 +2,19 @@ import type { StateCreator } from 'zustand' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { - ConnectCurrentOrcaProfileResult, - CreateCloudLinkedOrcaProfileResult, OrcaProfileAuthStatus, OrcaProfileSummary, - RefreshCurrentOrcaProfileAuthResult, - SelectOrcaProfileOrgResult, - SignOutCurrentOrcaProfileResult, SwitchOrcaProfileResult, TransferOrcaProfileProjectArgs, TransferOrcaProfileProjectResult } from '../../../../shared/orca-profiles' import type { AppState } from '../types' +import { + createOrcaProfilesAuthActions, + type OrcaProfilesAuthActions +} from './orca-profiles-auth-actions' -export type OrcaProfilesSlice = { +export type OrcaProfilesSlice = OrcaProfilesAuthActions & { orcaProfiles: OrcaProfileSummary[] activeOrcaProfileId: string | null orcaProfileAuthStatus: OrcaProfileAuthStatus | null @@ -25,14 +24,6 @@ export type OrcaProfilesSlice = { fetchOrcaProfiles: () => Promise fetchOrcaProfileAuthStatus: () => Promise createLocalOrcaProfile: (name?: string) => Promise - createCloudLinkedOrcaProfile: (args: { - orgId?: string - name?: string - }) => Promise - connectCurrentOrcaProfile: () => Promise - refreshCurrentOrcaProfileAuth: () => Promise - signOutCurrentOrcaProfile: () => Promise - selectOrcaProfileOrg: (orgId: string) => Promise switchOrcaProfile: (profileId: string) => Promise transferOrcaProfileProject: ( args: TransferOrcaProfileProjectArgs @@ -41,7 +32,8 @@ export type OrcaProfilesSlice = { export const createOrcaProfilesSlice: StateCreator = ( set, - get + get, + api ) => ({ orcaProfiles: [], activeOrcaProfileId: null, @@ -101,192 +93,7 @@ export const createOrcaProfilesSlice: StateCreator { - try { - const result = await window.api.orcaProfiles.createCloudLinked(args) - set({ - orcaProfileAuthStatus: result.auth, - ...(result.status === 'created' - ? { - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles - } - : {}) - }) - if (result.status === 'created') { - toast.success( - translate('auto.store.slices.orca.profiles.319d7cf39b', 'Cloud profile created') - ) - } else if (result.status === 'reconnect-required') { - toast.error( - translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') - ) - } else if (result.status === 'failed') { - toast.error( - translate( - 'auto.store.slices.orca.profiles.f0c9e11a6d', - 'Failed to create cloud profile' - ), - { description: result.error } - ) - } - return result - } catch (err) { - console.error('Failed to create Orca cloud profile:', err) - toast.error( - translate('auto.store.slices.orca.profiles.f0c9e11a6d', 'Failed to create cloud profile'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - return null - } - }, - - connectCurrentOrcaProfile: async () => { - if (get().orcaProfileConnecting) { - return null - } - set({ orcaProfileConnecting: true }) - try { - const result = await window.api.orcaProfiles.connectCurrent() - set({ - orcaProfileConnecting: false, - orcaProfileAuthStatus: result.auth, - ...(result.status === 'connected' - ? { - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles - } - : {}) - }) - if (result.status === 'unconfigured') { - toast.error( - translate( - 'auto.store.slices.orca.profiles.8b8fa73174', - 'Orca Cloud sign-in is not configured' - ), - { - description: result.auth.setupMessage - } - ) - } else if (result.status === 'failed') { - toast.error( - translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'), - { description: result.error } - ) - } else if (result.status === 'connected') { - toast.success( - translate('auto.store.slices.orca.profiles.9fcb07a796', 'Profile connected') - ) - } - return result - } catch (err) { - console.error('Failed to connect Orca profile:', err) - set({ orcaProfileConnecting: false }) - toast.error( - translate('auto.store.slices.orca.profiles.33290e88ed', 'Failed to connect profile'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - return null - } - }, - - refreshCurrentOrcaProfileAuth: async () => { - try { - const result = await window.api.orcaProfiles.refreshAuth() - set({ - orcaProfileAuthStatus: result.auth, - ...(result.status === 'refreshed' - ? { - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles - } - : {}) - }) - if (result.status === 'reconnect-required') { - toast.error( - translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') - ) - } else if (result.status === 'failed') { - toast.error( - translate( - 'auto.store.slices.orca.profiles.2f6c78a039', - 'Failed to refresh profile auth' - ), - { description: result.error } - ) - } - return result - } catch (err) { - console.error('Failed to refresh Orca profile auth:', err) - toast.error( - translate('auto.store.slices.orca.profiles.2f6c78a039', 'Failed to refresh profile auth'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - return null - } - }, - - signOutCurrentOrcaProfile: async () => { - try { - const result = await window.api.orcaProfiles.signOutCurrent() - set({ - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles, - orcaProfileAuthStatus: result.auth - }) - toast.success( - translate('auto.store.slices.orca.profiles.a37b5e6d37', 'Signed out of profile') - ) - return result - } catch (err) { - console.error('Failed to sign out of Orca profile:', err) - toast.error(translate('auto.store.slices.orca.profiles.83600521e7', 'Failed to sign out'), { - description: err instanceof Error ? err.message : String(err) - }) - return null - } - }, - - selectOrcaProfileOrg: async (orgId) => { - try { - const result = await window.api.orcaProfiles.selectOrg({ orgId }) - set({ - orcaProfileAuthStatus: result.auth, - ...(result.status === 'selected' - ? { - activeOrcaProfileId: result.activeProfileId, - orcaProfiles: result.profiles - } - : {}) - }) - if (result.status === 'reconnect-required') { - toast.error( - translate('auto.store.slices.orca.profiles.d6e764e7db', 'Reconnect this profile') - ) - } else if (result.status === 'failed') { - toast.error( - translate('auto.store.slices.orca.profiles.76deec8f58', 'Failed to switch organization'), - { description: result.error } - ) - } - return result - } catch (err) { - console.error('Failed to switch Orca profile org:', err) - toast.error( - translate('auto.store.slices.orca.profiles.76deec8f58', 'Failed to switch organization'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - return null - } - }, + ...createOrcaProfilesAuthActions(set, get, api), switchOrcaProfile: async (profileId) => { if (!profileId || profileId === get().activeOrcaProfileId) { @@ -294,7 +101,13 @@ export const createOrcaProfilesSlice: StateCreator { const findProjectProfiles = window.api.orcaProfiles?.findProjectProfiles - if (!findProjectProfiles) { + // Why: without a loaded active profile ID the scan cannot exclude the + // current profile and would false-positive on the project just added. + if (!findProjectProfiles || !activeOrcaProfileId) { return } try { @@ -287,10 +293,7 @@ async function warnIfProjectKnownInAnotherProfile( return } toast.warning( - translate( - 'auto.store.slices.repos.2dcd706774', - 'Project also exists in another profile' - ), + translate('auto.store.slices.repos.2dcd706774', 'Project also exists in another profile'), { description } ) } catch (err) { @@ -2110,9 +2113,9 @@ export const createRepoSlice: StateCreator = (set, description: repo.displayName } ) - if (target.kind === 'local') { - await warnIfProjectKnownInAnotherProfile(repo, get().activeOrcaProfileId) - } + // Why: the design requires the cross-profile advisory for SSH-added + // projects too — the presence lookup already keys on connection/host. + await warnIfProjectKnownInAnotherProfile(repo, get().activeOrcaProfileId) } return repo } catch (err) {