fable review

This commit is contained in:
Neil
2026-07-04 22:49:11 -07:00
parent e9dd5cbafd
commit e51d2edb9e
28 changed files with 830 additions and 560 deletions
+4 -1
View File
@@ -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
}
+30 -11
View File
@@ -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', {
+30 -12
View File
@@ -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<string, (_event: unknown, args?: unknown) => 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<typeof vi.fn>
getSettings: () => Record<string, never>
} {
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(
+39 -18
View File
@@ -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<ConnectCurrentOrcaProfileResult> =>
connectCurrentOrcaProfile(app.getPath('userData'))
connectCurrentOrcaProfile(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:createCloudLinked',
async (_event, rawArgs?: CreateCloudLinkedOrcaProfileArgs): Promise<CreateCloudLinkedOrcaProfileResult> =>
createCloudLinkedOrcaProfile(
app.getPath('userData'),
async (
_event,
rawArgs?: CreateCloudLinkedOrcaProfileArgs
): Promise<CreateCloudLinkedOrcaProfileResult> => {
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<RefreshCurrentOrcaProfileAuthResult> =>
refreshCurrentOrcaProfileAuth(app.getPath('userData'))
refreshCurrentOrcaProfileAuth(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:signOutCurrent',
async (): Promise<SignOutCurrentOrcaProfileResult> =>
signOutCurrentOrcaProfile(app.getPath('userData'))
signOutCurrentOrcaProfile(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:selectOrg',
async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise<SelectOrcaProfileOrgResult> =>
selectCurrentOrcaProfileOrg(app.getPath('userData'), orgIdFromUnknown(rawArgs))
selectCurrentOrcaProfileOrg(getProfileUserDataPath(), orgIdFromUnknown(rawArgs))
)
}
@@ -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({
@@ -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
}
+11 -16
View File
@@ -150,18 +150,21 @@ function normalizeSessionResponse(value: unknown): OrcaCloudSessionExchangeRespo
}
}
async function postJson<T>(
url: string,
body: unknown,
accessToken?: string
): Promise<T> {
const CLOUD_REQUEST_TIMEOUT_MS = 30_000
async function postJson<T>(url: string, body: unknown, accessToken?: string): Promise<T> {
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),
+7 -6
View File
@@ -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')
)
})
})
})
@@ -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<string, Promise<OrcaCloudSession>>()
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<OrcaCloudSession> {
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<T>(
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
@@ -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)
})
})
+47 -3
View File
@@ -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 {
@@ -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
}
}
@@ -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
})
})
@@ -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'
+21
View File
@@ -2564,6 +2564,10 @@ export class Store {
private writeTimer: ReturnType<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | 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<void> {
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 {
+4 -1
View File
@@ -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.
@@ -205,8 +205,10 @@ export function OrcaProfileManagementDialog({
<div className="truncate text-sm font-medium">{activeProfile.name}</div>
<div className="truncate text-xs text-muted-foreground">
{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 }
)}
</div>
@@ -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({
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onCancel} disabled={pending}>
{translate(
'auto.components.orca.profiles.project.transfer.confirm.cancel',
'Cancel'
)}
{translate('auto.components.orca.profiles.project.transfer.confirm.cancel', 'Cancel')}
</Button>
<Button size="sm" onClick={onConfirm} disabled={pending || !pendingTransfer}>
{pending ? <Loader2 className="size-4 animate-spin" /> : null}
@@ -17,24 +17,36 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] {
if (summary.liveTerminalTabCount > 0) {
lines.push(
translate(
'auto.components.orca.profiles.switch.confirm.terminal',
'{{count}} live terminal tabs',
summary.liveTerminalTabCount === 1
? 'auto.components.orca.profiles.switch.confirm.terminalSingular'
: 'auto.components.orca.profiles.switch.confirm.terminalPlural',
summary.liveTerminalTabCount === 1
? '{{count}} live terminal tab'
: '{{count}} live terminal tabs',
{ count: summary.liveTerminalTabCount }
)
)
}
if (summary.liveAgentCount > 0) {
lines.push(
translate('auto.components.orca.profiles.switch.confirm.agent', '{{count}} active agents', {
count: summary.liveAgentCount
})
translate(
summary.liveAgentCount === 1
? 'auto.components.orca.profiles.switch.confirm.agentSingular'
: 'auto.components.orca.profiles.switch.confirm.agentPlural',
summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents',
{ count: summary.liveAgentCount }
)
)
}
if (summary.browserWorkspaceCount > 0) {
lines.push(
translate(
'auto.components.orca.profiles.switch.confirm.browser',
'{{count}} browser workspaces',
summary.browserWorkspaceCount === 1
? 'auto.components.orca.profiles.switch.confirm.browserSingular'
: 'auto.components.orca.profiles.switch.confirm.browserPlural',
summary.browserWorkspaceCount === 1
? '{{count}} browser workspace'
: '{{count}} browser workspaces',
{ count: summary.browserWorkspaceCount }
)
)
@@ -70,10 +82,7 @@ export function OrcaProfileSwitchConfirmDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-muted-foreground" />
{translate(
'auto.components.orca.profiles.switch.confirm.title',
'Switch profiles?'
)}
{translate('auto.components.orca.profiles.switch.confirm.title', 'Switch profiles?')}
</DialogTitle>
<DialogDescription>
{translate(
@@ -101,15 +110,17 @@ export function OrcaProfileSwitchConfirmDialog({
) : null}
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={switching}>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
disabled={switching}
>
{translate('auto.components.orca.profiles.switch.confirm.cancel', 'Cancel')}
</Button>
<Button size="sm" onClick={onConfirm} disabled={switching}>
{switching ? <Loader2 className="size-4 animate-spin" /> : null}
{translate(
'auto.components.orca.profiles.switch.confirm.switch',
'Switch profile'
)}
{translate('auto.components.orca.profiles.switch.confirm.switch', 'Switch profile')}
</Button>
</DialogFooter>
</DialogContent>
@@ -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
}
+37 -11
View File
@@ -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"
}
}
}
+74 -48
View File
@@ -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"
}
}
}
+75 -49
View File
@@ -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}} 個のブラウザ ワークスペース"
}
}
}
+75 -49
View File
@@ -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}}개의 브라우저 워크스페이스"
}
}
}
+75 -51
View File
@@ -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}} 个浏览器工作区"
}
}
}
@@ -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' })
+15 -202
View File
@@ -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<void>
fetchOrcaProfileAuthStatus: () => Promise<OrcaProfileAuthStatus | null>
createLocalOrcaProfile: (name?: string) => Promise<OrcaProfileSummary | null>
createCloudLinkedOrcaProfile: (args: {
orgId?: string
name?: string
}) => Promise<CreateCloudLinkedOrcaProfileResult | null>
connectCurrentOrcaProfile: () => Promise<ConnectCurrentOrcaProfileResult | null>
refreshCurrentOrcaProfileAuth: () => Promise<RefreshCurrentOrcaProfileAuthResult | null>
signOutCurrentOrcaProfile: () => Promise<SignOutCurrentOrcaProfileResult | null>
selectOrcaProfileOrg: (orgId: string) => Promise<SelectOrcaProfileOrgResult | null>
switchOrcaProfile: (profileId: string) => Promise<SwitchOrcaProfileResult | null>
transferOrcaProfileProject: (
args: TransferOrcaProfileProjectArgs
@@ -41,7 +32,8 @@ export type OrcaProfilesSlice = {
export const createOrcaProfilesSlice: StateCreator<AppState, [], [], OrcaProfilesSlice> = (
set,
get
get,
api
) => ({
orcaProfiles: [],
activeOrcaProfileId: null,
@@ -101,192 +93,7 @@ export const createOrcaProfilesSlice: StateCreator<AppState, [], [], OrcaProfile
}
},
createCloudLinkedOrcaProfile: async (args) => {
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<AppState, [], [], OrcaProfile
}
set({ orcaProfileSwitching: true })
try {
return await window.api.orcaProfiles.switchProfile({ profileId })
const result = await window.api.orcaProfiles.switchProfile({ profileId })
if (result?.status !== 'relaunching') {
// Why: only a relaunch may keep the switcher locked; a stale
// "already-active" answer would otherwise disable it forever.
set({ orcaProfileSwitching: false })
}
return result
} catch (err) {
console.error('Failed to switch Orca profile:', err)
set({ orcaProfileSwitching: false })
+12 -9
View File
@@ -262,7 +262,11 @@ function formatProjectPresenceProfileNames(profileNames: readonly string[]): str
if (names.length <= 3) {
return names.join(', ')
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3} more`
// Why: the "+N more" overflow suffix is user-visible toast copy and must localize.
return translate('auto.store.slices.repos.presenceProfileOverflow', '{{names}} +{{count}} more', {
names: names.slice(0, 3).join(', '),
count: names.length - 3
})
}
async function warnIfProjectKnownInAnotherProfile(
@@ -270,7 +274,9 @@ async function warnIfProjectKnownInAnotherProfile(
activeOrcaProfileId: string | null
): Promise<void> {
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<AppState, [], [], RepoSlice> = (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) {