mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
fable review
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+17
-10
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}} 個のブラウザ ワークスペース"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}개의 브라우저 워크스페이스"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user