From 06afbc4a4be49271b3cd336ae952391f59a86ac8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:13:13 -0700 Subject: [PATCH] Add optional Orca account sign-in (#7515) * auth v1 * fable review * lint * Account menu with org membership management Default UX is a compact account menu (sign in, organization selection, sign out) that renders only when cloud auth is configured; adds an organization members dialog (invite, role, remove) gated on server-side role checks. The multi-profile switcher UI is preserved behind ORCA_MULTI_PROFILE_UI=1. Co-authored-by: Orca * Gate the optional account sign-in UI to dev builds The account switcher stays hidden in packaged builds while the feature is in progress. Dev builds still show it when the client env vars are set, and a dev-only Settings > Dev Tools > Orca Cloud section mirrors it. Co-authored-by: Orca --------- Co-authored-by: Orca --- ...owser-session-registry.persistence.test.ts | 23 + .../browser/browser-session-registry.test.ts | 29 ++ src/main/browser/browser-session-registry.ts | 113 +++-- .../browser/browser-session-startup.test.ts | 29 ++ src/main/browser/browser-session-startup.ts | 9 +- src/main/index.ts | 10 +- .../ipc/orca-profile-auth-handlers.test.ts | 171 ++++++++ .../orca-profile-org-members-handlers.test.ts | 116 +++++ .../ipc/orca-profile-org-members-handlers.ts | 128 ++++++ src/main/ipc/orca-profiles.test.ts | 321 ++++++++++++++ src/main/ipc/orca-profiles.ts | 291 +++++++++++++ src/main/ipc/register-core-handlers.test.ts | 8 + src/main/ipc/register-core-handlers.ts | 4 + .../profile-cloud-auth-config.test.ts | 122 ++++++ .../profile-cloud-auth-config.ts | 115 +++++ .../profile-cloud-auth-status.ts | 56 +++ .../profile-cloud-client.test.ts | 249 +++++++++++ .../orca-profiles/profile-cloud-client.ts | 263 ++++++++++++ .../orca-profiles/profile-cloud-dev-auth.ts | 70 +++ .../profile-cloud-dev-org-members.ts | 130 ++++++ .../profile-cloud-dev-service.test.ts | 142 ++++++ .../profile-cloud-dev-service.ts | 92 ++++ src/main/orca-profiles/profile-cloud-index.ts | 143 +++++++ .../profile-cloud-org-members-client.test.ts | 150 +++++++ .../profile-cloud-org-members-client.ts | 209 +++++++++ .../profile-cloud-org-members-service.test.ts | 259 +++++++++++ .../profile-cloud-org-members-service.ts | 212 +++++++++ .../orca-profiles/profile-cloud-pkce.test.ts | 127 ++++++ src/main/orca-profiles/profile-cloud-pkce.ts | 142 ++++++ .../profile-cloud-service-auth-retry.test.ts | 249 +++++++++++ .../profile-cloud-service-refresh.test.ts | 256 +++++++++++ .../profile-cloud-service.test.ts | 287 +++++++++++++ .../orca-profiles/profile-cloud-service.ts | 300 +++++++++++++ .../profile-cloud-session-exchange.ts | 14 + .../profile-cloud-session-refresh.ts | 178 ++++++++ .../profile-cloud-session-store.test.ts | 172 ++++++++ .../profile-cloud-session-store.ts | 209 +++++++++ .../orca-profiles/profile-index-store.test.ts | 198 +++++++++ src/main/orca-profiles/profile-index-store.ts | 327 ++++++++++++++ .../profile-project-presence.test.ts | 145 +++++++ .../orca-profiles/profile-project-presence.ts | 51 +++ .../profile-project-session-state.ts | 155 +++++++ .../profile-project-session-transfer.ts | 215 ++++++++++ .../profile-project-source-removal.ts | 66 +++ .../profile-project-state-file.ts | 139 ++++++ .../profile-project-transfer-payload.ts | 294 +++++++++++++ .../profile-project-transfer.test.ts | 301 +++++++++++++ .../orca-profiles/profile-project-transfer.ts | 77 ++++ .../profile-project-worktree-identity.ts | 77 ++++ .../orca-profiles/profile-storage-paths.ts | 72 ++++ .../orca-profiles/profile-ui-scope.test.ts | 14 + src/main/orca-profiles/profile-ui-scope.ts | 6 + src/main/persistence.test.ts | 95 +++++ src/main/persistence.ts | 79 +++- src/main/terminal-scrollback-snapshots.ts | 85 ++-- src/preload/api-types.ts | 60 +++ src/preload/index.ts | 19 + src/renderer/src/App.tsx | 29 ++ src/renderer/src/assets/main.css | 5 + .../components/browser-pane/BrowserPane.tsx | 15 +- .../orca-profiles/OrcaProfileAvatar.tsx | 22 + .../OrcaProfileCloudMenuItems.tsx | 126 ++++++ .../orca-profiles/OrcaProfileCreateDialog.tsx | 66 +++ .../OrcaProfileManagementDialog.tsx | 272 ++++++++++++ .../orca-profiles/OrcaProfileOrgMemberRow.tsx | 135 ++++++ .../OrcaProfileOrgMembersDialog.test.tsx | 151 +++++++ .../OrcaProfileOrgMembersDialog.tsx | 300 +++++++++++++ ...rcaProfileProjectTransferConfirmDialog.tsx | 162 +++++++ .../OrcaProfileSignOutConfirmDialog.tsx | 60 +++ .../OrcaProfileSwitchConfirmDialog.tsx | 129 ++++++ .../OrcaProfileSwitcher.test.tsx | 172 ++++++++ .../orca-profiles/OrcaProfileSwitcher.tsx | 403 ++++++++++++++++++ .../orca-profile-org-members-labels.ts | 79 ++++ .../orca-profile-switch-liveness.test.ts | 194 +++++++++ .../orca-profile-switch-liveness.ts | 180 ++++++++ .../src/components/settings/DevToolsPane.tsx | 128 +++++- .../SourceControlActionRepoOverrideNote.tsx | 3 + .../sidebar/SidebarToolbar.test.tsx | 16 + .../src/components/sidebar/SidebarToolbar.tsx | 6 +- src/renderer/src/i18n/locales/en.json | 183 +++++++- src/renderer/src/i18n/locales/es.json | 183 +++++++- src/renderer/src/i18n/locales/ja.json | 183 +++++++- src/renderer/src/i18n/locales/ko.json | 183 +++++++- src/renderer/src/i18n/locales/zh.json | 183 +++++++- src/renderer/src/store/index.ts | 2 + .../src/store/slices/diffComments.test.ts | 2 + .../slices/orca-profiles-auth-actions.test.ts | 219 ++++++++++ .../slices/orca-profiles-auth-actions.ts | 211 +++++++++ .../src/store/slices/orca-profiles.test.ts | 265 ++++++++++++ .../src/store/slices/orca-profiles.ts | 153 +++++++ .../slices/repos-runtime-routing-fixture.ts | 117 +++++ src/renderer/src/store/slices/repos.test.ts | 160 +++---- src/renderer/src/store/slices/repos.ts | 48 +++ .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/types.ts | 2 + src/renderer/src/web/web-preload-api.ts | 65 +++ src/shared/orca-profiles.ts | 346 +++++++++++++++ tests/e2e/orca-profiles.spec.ts | 74 ++++ 98 files changed, 12898 insertions(+), 209 deletions(-) create mode 100644 src/main/ipc/orca-profile-auth-handlers.test.ts create mode 100644 src/main/ipc/orca-profile-org-members-handlers.test.ts create mode 100644 src/main/ipc/orca-profile-org-members-handlers.ts create mode 100644 src/main/ipc/orca-profiles.test.ts create mode 100644 src/main/ipc/orca-profiles.ts create mode 100644 src/main/orca-profiles/profile-cloud-auth-config.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-auth-config.ts create mode 100644 src/main/orca-profiles/profile-cloud-auth-status.ts create mode 100644 src/main/orca-profiles/profile-cloud-client.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-client.ts create mode 100644 src/main/orca-profiles/profile-cloud-dev-auth.ts create mode 100644 src/main/orca-profiles/profile-cloud-dev-org-members.ts create mode 100644 src/main/orca-profiles/profile-cloud-dev-service.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-dev-service.ts create mode 100644 src/main/orca-profiles/profile-cloud-index.ts create mode 100644 src/main/orca-profiles/profile-cloud-org-members-client.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-org-members-client.ts create mode 100644 src/main/orca-profiles/profile-cloud-org-members-service.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-org-members-service.ts create mode 100644 src/main/orca-profiles/profile-cloud-pkce.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-pkce.ts create mode 100644 src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-service-refresh.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-service.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-service.ts create mode 100644 src/main/orca-profiles/profile-cloud-session-exchange.ts create mode 100644 src/main/orca-profiles/profile-cloud-session-refresh.ts create mode 100644 src/main/orca-profiles/profile-cloud-session-store.test.ts create mode 100644 src/main/orca-profiles/profile-cloud-session-store.ts create mode 100644 src/main/orca-profiles/profile-index-store.test.ts create mode 100644 src/main/orca-profiles/profile-index-store.ts create mode 100644 src/main/orca-profiles/profile-project-presence.test.ts create mode 100644 src/main/orca-profiles/profile-project-presence.ts create mode 100644 src/main/orca-profiles/profile-project-session-state.ts create mode 100644 src/main/orca-profiles/profile-project-session-transfer.ts create mode 100644 src/main/orca-profiles/profile-project-source-removal.ts create mode 100644 src/main/orca-profiles/profile-project-state-file.ts create mode 100644 src/main/orca-profiles/profile-project-transfer-payload.ts create mode 100644 src/main/orca-profiles/profile-project-transfer.test.ts create mode 100644 src/main/orca-profiles/profile-project-transfer.ts create mode 100644 src/main/orca-profiles/profile-project-worktree-identity.ts create mode 100644 src/main/orca-profiles/profile-storage-paths.ts create mode 100644 src/main/orca-profiles/profile-ui-scope.test.ts create mode 100644 src/main/orca-profiles/profile-ui-scope.ts create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx create mode 100644 src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx create mode 100644 src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts create mode 100644 src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts create mode 100644 src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts create mode 100644 src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts create mode 100644 src/renderer/src/store/slices/orca-profiles-auth-actions.ts create mode 100644 src/renderer/src/store/slices/orca-profiles.test.ts create mode 100644 src/renderer/src/store/slices/orca-profiles.ts create mode 100644 src/renderer/src/store/slices/repos-runtime-routing-fixture.ts create mode 100644 src/shared/orca-profiles.ts create mode 100644 tests/e2e/orca-profiles.spec.ts diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 121df82de6d..8fc3ea18b6b 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -157,6 +157,29 @@ describe('BrowserSessionRegistry persistence', () => { expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true) }) + it('persists new browser session profiles under the active Orca profile directory', async () => { + const fsState = createFsState() + const profileMetaPath = '/user-data/profiles/local-work/browser-session-meta.json' + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.configureForOrcaProfile({ + orcaProfileId: 'local-work', + profileDirectory: '/user-data/profiles/local-work' + }) + const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser') + + expect(profile).not.toBeNull() + expect(fsState.files.has(profileMetaPath)).toBe(true) + expect(fsState.files.has(META_PATH)).toBe(false) + expect(JSON.parse(fsState.files.get(profileMetaPath) ?? '{}').profiles[0]).toMatchObject({ + id: profile!.id, + partition: profile!.partition, + label: 'Work Browser' + }) + }) + it('merges partition-keyed pending entries without clobbering unrelated entries', async () => { const fsState = createFsState() seedMeta(fsState, { diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 363094775c7..9de0a884f23 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -28,6 +28,11 @@ vi.mock('./browser-manager', () => ({ import { browserSessionRegistry } from './browser-session-registry' import { setupClientHintsOverride } from './browser-session-ua' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + getOrcaProfileBrowserDefaultPartition, + getOrcaProfileBrowserSessionPartition +} from '../../shared/orca-profiles' describe('BrowserSessionRegistry', () => { beforeEach(() => { @@ -280,6 +285,30 @@ describe('BrowserSessionRegistry', () => { expect(webAuthnCallback).toHaveBeenCalledWith('credential-1') }) + it('uses profile-owned partitions for non-default Orca profiles', () => { + const orcaProfileId = 'local-work' + browserSessionRegistry.configureForOrcaProfile({ + orcaProfileId, + profileDirectory: '/profiles/local-work' + }) + + expect(browserSessionRegistry.getDefaultProfile().partition).toBe( + getOrcaProfileBrowserDefaultPartition(orcaProfileId) + ) + expect(browserSessionRegistry.isAllowedPartition(ORCA_BROWSER_PARTITION)).toBe(false) + + const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser') + expect(profile).not.toBeNull() + expect(profile!.partition).toBe( + getOrcaProfileBrowserSessionPartition(orcaProfileId, profile!.id) + ) + + browserSessionRegistry.configureForOrcaProfile({ + orcaProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profileDirectory: '/profiles/local-default' + }) + }) + describe('setupClientHintsOverride', () => { it('overrides sec-ch-ua headers for Edge UA', () => { const onBeforeSendHeaders = vi.fn() diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 98b944de729..cc974ff7a1a 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -14,8 +14,14 @@ import { unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + getOrcaProfileBrowserDefaultPartition, + getOrcaProfileBrowserPartitionSegment, + getOrcaProfileBrowserSessionPartition +} from '../../shared/orca-profiles' import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types' import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' @@ -36,6 +42,15 @@ type BrowserSessionMeta = { profiles: BrowserSessionProfile[] } +export type BrowserSessionRegistryProfileOptions = { + orcaProfileId: string + profileDirectory: string +} + +const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' +const LEGACY_BROWSER_SESSION_PARTITION_RE = + /^persist:orca-browser-session-[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/ + // Why: the registry is the single source of truth for which Electron partitions // are valid. will-attach-webview consults it to decide whether a guest's // requested partition is allowed. This prevents a compromised renderer from @@ -43,13 +58,28 @@ type BrowserSessionMeta = { class BrowserSessionRegistry { private readonly profiles = new Map() + private activeOrcaProfileId = DEFAULT_LOCAL_ORCA_PROFILE_ID + private metadataPathOverride: string | null = null + private defaultPartition = ORCA_BROWSER_PARTITION constructor() { + this.resetDefaultProfile() + } + + configureForOrcaProfile(options: BrowserSessionRegistryProfileOptions): void { + this.activeOrcaProfileId = options.orcaProfileId + this.metadataPathOverride = join(options.profileDirectory, BROWSER_SESSION_META_FILE_NAME) + this.defaultPartition = getOrcaProfileBrowserDefaultPartition(options.orcaProfileId) + this.profiles.clear() + this.resetDefaultProfile() + } + + private resetDefaultProfile(): void { const persisted = this.loadPersistedSource() this.profiles.set('default', { id: 'default', scope: 'default', - partition: ORCA_BROWSER_PARTITION, + partition: this.defaultPartition, label: 'Default', source: persisted }) @@ -60,7 +90,9 @@ class BrowserSessionRegistry { // status. Cookies themselves persist in the Electron partition's SQLite DB, // but the registry is in-memory only. private get metadataPath(): string { - return join(app.getPath('userData'), 'browser-session-meta.json') + return ( + this.metadataPathOverride ?? join(app.getPath('userData'), BROWSER_SESSION_META_FILE_NAME) + ) } private loadPersistedSource(): BrowserSessionProfile['source'] { @@ -78,6 +110,7 @@ class BrowserSessionRegistry { try { const existing = this.loadPersistedMeta() const tmpPath = `${this.metadataPath}.tmp` + mkdirSync(dirname(this.metadataPath), { recursive: true }) writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates })) renameSync(tmpPath, this.metadataPath) } catch { @@ -108,8 +141,8 @@ class BrowserSessionRegistry { data && typeof data.userAgentByPartition === 'object' && data.userAgentByPartition ? { ...data.userAgentByPartition } : {} - if (legacyUserAgent && !userAgentByPartition[ORCA_BROWSER_PARTITION]) { - userAgentByPartition[ORCA_BROWSER_PARTITION] = legacyUserAgent + if (legacyUserAgent && !userAgentByPartition[this.defaultPartition]) { + userAgentByPartition[this.defaultPartition] = legacyUserAgent } const legacyPendingCookieDbPath = @@ -118,8 +151,8 @@ class BrowserSessionRegistry { data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports ? { ...data.pendingCookieImports } : {} - if (legacyPendingCookieDbPath && !pendingCookieImports[ORCA_BROWSER_PARTITION]) { - pendingCookieImports[ORCA_BROWSER_PARTITION] = legacyPendingCookieDbPath + if (legacyPendingCookieDbPath && !pendingCookieImports[this.defaultPartition]) { + pendingCookieImports[this.defaultPartition] = legacyPendingCookieDbPath } return { defaultSource: data?.defaultSource ?? null, @@ -168,10 +201,10 @@ class BrowserSessionRegistry { // hydrateFromPersisted skips the default partition and createProfile never // targets it. Without this, clipboard permissions and other guest policies // are denied by default in the default browser partition. - this.setupSessionPolicies(ORCA_BROWSER_PARTITION) + this.setupSessionPolicies(this.defaultPartition) const partitions = new Set([ - ORCA_BROWSER_PARTITION, + this.defaultPartition, ...this.listProfiles().map((p) => p.partition) ]) for (const partition of partitions) { @@ -208,9 +241,9 @@ class BrowserSessionRegistry { } // Why: replay writes to partition-derived file paths, so corrupted // metadata must pass the same validation as the webview allowlist. - const knownPartitions = new Set([ORCA_BROWSER_PARTITION]) + const knownPartitions = new Set([this.defaultPartition]) for (const profile of meta.profiles) { - if (BrowserSessionRegistry.isValidPersistedProfile(profile)) { + if (this.isValidPersistedProfile(profile)) { knownPartitions.add(profile.partition) } } @@ -270,7 +303,7 @@ class BrowserSessionRegistry { } this.persistMeta({ pendingCookieImports: remainingEntries, - pendingCookieDbPath: remainingEntries[ORCA_BROWSER_PARTITION] ?? null + pendingCookieDbPath: remainingEntries[this.defaultPartition] ?? null }) } catch { // best-effort — if this fails, CookieMonster loads the old DB @@ -282,7 +315,7 @@ class BrowserSessionRegistry { const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath } this.persistMeta({ pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null + pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null }) } @@ -296,7 +329,7 @@ class BrowserSessionRegistry { } this.persistMeta({ userAgentByPartition, - userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null + userAgent: userAgentByPartition[this.defaultPartition] ?? null }) } @@ -313,7 +346,7 @@ class BrowserSessionRegistry { } isAllowedPartition(partition: string): boolean { - if (partition === ORCA_BROWSER_PARTITION) { + if (partition === this.defaultPartition) { return true } return [...this.profiles.values()].some((p) => p.partition === partition) @@ -321,14 +354,17 @@ class BrowserSessionRegistry { resolvePartition(profileId: string | null | undefined): string { if (!profileId) { - return ORCA_BROWSER_PARTITION + return this.defaultPartition } - return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION + return this.profiles.get(profileId)?.partition ?? this.defaultPartition } 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 } @@ -336,7 +372,7 @@ class BrowserSessionRegistry { createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null { // Why: only the constructor may create the default profile. Allowing the // renderer to pass scope:'default' would create a second profile sharing - // ORCA_BROWSER_PARTITION, causing confusion on delete (clearing storage + // the active default partition, causing confusion on delete (clearing storage // for the shared partition). if (scope === 'default') { return null @@ -345,7 +381,7 @@ class BrowserSessionRegistry { // Why: partition names are deterministic from the profile id so main can // reconstruct the allowlist on restart from persisted profile metadata // without needing a separate partition→profile mapping. - const partition = `persist:orca-browser-session-${id}` + const partition = getOrcaProfileBrowserSessionPartition(this.activeOrcaProfileId, id) const profile: BrowserSessionProfile = { id, scope, @@ -391,9 +427,9 @@ class BrowserSessionRegistry { delete userAgentByPartition[profile.partition] this.persistMeta({ pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null, + pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null, userAgentByPartition, - userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null + userAgent: userAgentByPartition[this.defaultPartition] ?? null }) // Why: clearing the partition's storage prevents orphaned cookies/cache from @@ -423,9 +459,9 @@ class BrowserSessionRegistry { } const meta = this.loadPersistedMeta() const pendingCookieImports = { ...meta.pendingCookieImports } - delete pendingCookieImports[ORCA_BROWSER_PARTITION] + delete pendingCookieImports[this.defaultPartition] const userAgentByPartition = { ...meta.userAgentByPartition } - delete userAgentByPartition[ORCA_BROWSER_PARTITION] + delete userAgentByPartition[this.defaultPartition] this.persistMeta({ defaultSource: null, userAgent: null, @@ -434,7 +470,7 @@ class BrowserSessionRegistry { pendingCookieImports }) - const sess = session.fromPartition(ORCA_BROWSER_PARTITION) + const sess = session.fromPartition(this.defaultPartition) await sess.clearStorageData({ storages: ['cookies'] }) return true } catch { @@ -449,9 +485,7 @@ class BrowserSessionRegistry { // tampered file could inject an arbitrary partition into the allowlist that // will-attach-webview trusts, so we validate the expected shape before // registering anything. - private static readonly PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{36}$/ - - private static isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile { + private isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile { if (!profile || typeof profile !== 'object') { return false } @@ -462,17 +496,34 @@ class BrowserSessionRegistry { typeof candidate.id === 'string' && typeof candidate.partition === 'string' && typeof candidate.label === 'string' && - BrowserSessionRegistry.PARTITION_RE.test(candidate.partition) + this.isProfileOwnedSessionPartition(candidate.partition) ) } + private isProfileOwnedSessionPartition(partition: string): boolean { + if ( + this.activeOrcaProfileId === DEFAULT_LOCAL_ORCA_PROFILE_ID && + LEGACY_BROWSER_SESSION_PARTITION_RE.test(partition) + ) { + return true + } + + const segment = getOrcaProfileBrowserPartitionSegment(this.activeOrcaProfileId) + const prefix = `persist:orca-profile-${segment}-browser-session-` + if (!partition.startsWith(prefix)) { + return false + } + const profileId = partition.slice(prefix.length) + return /^[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/.test(profileId) + } + hydrateFromPersisted(profiles: BrowserSessionProfile[]): void { for (const profile of profiles) { - if (!BrowserSessionRegistry.isValidPersistedProfile(profile)) { + if (!this.isValidPersistedProfile(profile)) { continue } this.profiles.set(profile.id, profile) - if (profile.partition !== ORCA_BROWSER_PARTITION) { + if (profile.partition !== this.defaultPartition) { this.setupSessionPolicies(profile.partition) } } diff --git a/src/main/browser/browser-session-startup.test.ts b/src/main/browser/browser-session-startup.test.ts index e6f7860e325..720b0935fc9 100644 --- a/src/main/browser/browser-session-startup.test.ts +++ b/src/main/browser/browser-session-startup.test.ts @@ -1,20 +1,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' function installRegistryMock(): { + configureForOrcaProfileMock: ReturnType applyPendingCookieImportMock: ReturnType initializeBrowserSessionsFromPersistedStateMock: ReturnType } { + const configureForOrcaProfileMock = vi.fn() const applyPendingCookieImportMock = vi.fn() const initializeBrowserSessionsFromPersistedStateMock = vi.fn() vi.doMock('./browser-session-registry', () => ({ browserSessionRegistry: { + configureForOrcaProfile: configureForOrcaProfileMock, applyPendingCookieImport: applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedState: initializeBrowserSessionsFromPersistedStateMock } })) return { + configureForOrcaProfileMock, applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedStateMock } @@ -40,6 +44,31 @@ describe('initializeBrowserSessionsForApp', () => { ) }) + it('configures the active Orca profile before replaying browser sessions', async () => { + const { + configureForOrcaProfileMock, + applyPendingCookieImportMock, + initializeBrowserSessionsFromPersistedStateMock + } = installRegistryMock() + const { initializeBrowserSessionsForApp } = await import('./browser-session-startup') + + initializeBrowserSessionsForApp({ + orcaProfileId: 'local-work', + profileDirectory: '/profiles/local-work' + }) + + expect(configureForOrcaProfileMock).toHaveBeenCalledWith({ + orcaProfileId: 'local-work', + profileDirectory: '/profiles/local-work' + }) + expect(configureForOrcaProfileMock.mock.invocationCallOrder[0]).toBeLessThan( + applyPendingCookieImportMock.mock.invocationCallOrder[0] + ) + expect(applyPendingCookieImportMock.mock.invocationCallOrder[0]).toBeLessThan( + initializeBrowserSessionsFromPersistedStateMock.mock.invocationCallOrder[0] + ) + }) + it('initializes browser sessions once per app process', async () => { const { applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedStateMock } = installRegistryMock() diff --git a/src/main/browser/browser-session-startup.ts b/src/main/browser/browser-session-startup.ts index c23867c08c1..949b3173f70 100644 --- a/src/main/browser/browser-session-startup.ts +++ b/src/main/browser/browser-session-startup.ts @@ -1,12 +1,19 @@ import { browserSessionRegistry } from './browser-session-registry' +import type { BrowserSessionRegistryProfileOptions } from './browser-session-registry' let initialized = false -export function initializeBrowserSessionsForApp(): void { +export function initializeBrowserSessionsForApp( + activeProfile?: BrowserSessionRegistryProfileOptions +): void { if (initialized) { return } + if (activeProfile) { + browserSessionRegistry.configureForOrcaProfile(activeProfile) + } + // Why: cookie replay must happen before the first session.fromPartition() // call, otherwise Chromium opens the stale live cookie DB before import. browserSessionRegistry.applyPendingCookieImport() diff --git a/src/main/index.ts b/src/main/index.ts index d1d82a686ea..9e80bcc2944 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,6 +14,7 @@ import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' +import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { applyAppIcon } from './app-icon' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' @@ -582,6 +583,7 @@ if (hasSingleInstanceLock) { // orca-dev in dev mode) but before app.setName('Orca') inside whenReady // (which would change the resolved path on case-sensitive filesystems). initDataPath() + initOrcaProfilePaths() // Why: same timing constraint as initDataPath — capture the userData path // before app.setName changes it. See persistence.ts:20-28. initStatsPath() @@ -1646,7 +1648,8 @@ app.whenReady().then(async () => { electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) app.setName(devInstanceIdentity.name) - store = new Store() + const activeOrcaProfile = ensureActiveOrcaProfile() + store = new Store({ dataFile: activeOrcaProfile.dataFile }) logStartupMilestone('store-loaded') // Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude // CLI that survived the restart inside the daemon still holds the current @@ -1674,7 +1677,10 @@ app.whenReady().then(async () => { } // Why: browser sessions are used by desktop webviews and runtime profile // commands, so initialize them at app startup instead of a renderer IPC path. - initializeBrowserSessionsForApp() + initializeBrowserSessionsForApp({ + orcaProfileId: activeOrcaProfile.profile.id, + profileDirectory: activeOrcaProfile.profileDirectory + }) unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() agentAwakeService = new AgentAwakeService() agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) diff --git a/src/main/ipc/orca-profile-auth-handlers.test.ts b/src/main/ipc/orca-profile-auth-handlers.test.ts new file mode 100644 index 00000000000..73726dd2b52 --- /dev/null +++ b/src/main/ipc/orca-profile-auth-handlers.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + createCloudLinkedOrcaProfileMock, + connectCurrentOrcaProfileMock, + getCurrentOrcaProfileAuthStatusMock, + refreshCurrentOrcaProfileAuthMock, + selectCurrentOrcaProfileOrgMock, + signOutCurrentOrcaProfileMock +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + createCloudLinkedOrcaProfileMock: vi.fn(), + connectCurrentOrcaProfileMock: vi.fn(), + getCurrentOrcaProfileAuthStatusMock: vi.fn(), + refreshCurrentOrcaProfileAuthMock: vi.fn(), + selectCurrentOrcaProfileOrgMock: vi.fn(), + signOutCurrentOrcaProfileMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + exit: vi.fn(), + getPath: () => '/tmp/orca-user-data', + relaunch: vi.fn() + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('../tray/system-tray', () => ({ + destroySystemTray: vi.fn() +})) + +vi.mock('../orca-profiles/profile-index-store', () => ({ + createLocalOrcaProfile: vi.fn(), + getOrcaProfileListState: vi.fn(), + seedNewOrcaProfileTelemetryConsent: vi.fn(), + setActiveOrcaProfile: vi.fn() +})) + +vi.mock('../orca-profiles/profile-project-transfer', () => ({ + transferOrcaProfileProject: vi.fn() +})) + +vi.mock('../orca-profiles/profile-cloud-service', () => ({ + createCloudLinkedOrcaProfile: createCloudLinkedOrcaProfileMock, + connectCurrentOrcaProfile: connectCurrentOrcaProfileMock, + getCurrentOrcaProfileAuthStatus: getCurrentOrcaProfileAuthStatusMock, + refreshCurrentOrcaProfileAuth: refreshCurrentOrcaProfileAuthMock, + selectCurrentOrcaProfileOrg: selectCurrentOrcaProfileOrgMock, + signOutCurrentOrcaProfile: signOutCurrentOrcaProfileMock +})) + +import { registerOrcaProfileHandlers } from './orca-profiles' + +describe('registerOrcaProfileHandlers auth channels', () => { + beforeEach(() => { + handlers.clear() + createCloudLinkedOrcaProfileMock.mockReset() + connectCurrentOrcaProfileMock.mockReset() + getCurrentOrcaProfileAuthStatusMock.mockReset() + refreshCurrentOrcaProfileAuthMock.mockReset() + selectCurrentOrcaProfileOrgMock.mockReset() + signOutCurrentOrcaProfileMock.mockReset() + }) + + it('returns auth status for the current profile', async () => { + const status = { + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' + } + getCurrentOrcaProfileAuthStatusMock.mockReturnValue(status) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + await expect(Promise.resolve(handlers.get('orcaProfiles:authStatus')?.(null))).resolves.toBe( + status + ) + expect(getCurrentOrcaProfileAuthStatusMock).toHaveBeenCalledWith('/tmp/orca-user-data') + }) + + it('connects and signs out the current profile through the cloud service', async () => { + const connectResult = { status: 'unconfigured', auth: { activeProfileId: 'local-default' } } + const signOutResult = { status: 'signed-out', auth: { activeProfileId: 'local-default' } } + connectCurrentOrcaProfileMock.mockResolvedValue(connectResult) + signOutCurrentOrcaProfileMock.mockResolvedValue(signOutResult) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:connectCurrent')?.(null)) + ).resolves.toBe(connectResult) + await expect( + Promise.resolve(handlers.get('orcaProfiles:signOutCurrent')?.(null)) + ).resolves.toBe(signOutResult) + expect(connectCurrentOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data') + expect(signOutCurrentOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data') + }) + + it('refreshes profile auth through the cloud service', async () => { + const refreshResult = { status: 'refreshed', auth: { activeProfileId: 'local-default' } } + refreshCurrentOrcaProfileAuthMock.mockResolvedValue(refreshResult) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + 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(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:selectOrg')?.(null, { orgId: ' org-1 ' })) + ).resolves.toBe(selectResult) + expect(selectCurrentOrcaProfileOrgMock).toHaveBeenCalledWith('/tmp/orca-user-data', 'org-1') + + await expect( + Promise.resolve(handlers.get('orcaProfiles:selectOrg')?.(null, { orgId: ' ' })) + ).rejects.toThrow('invalid_orca_profile_org_selection') + }) + + it('creates cloud-linked profiles with trimmed optional args', async () => { + const createResult = { + status: 'created', + auth: { activeProfileId: 'local-default' }, + activeProfileId: 'local-default', + profiles: [], + profile: { id: 'cloud-1' } + } + createCloudLinkedOrcaProfileMock.mockResolvedValue(createResult) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:createCloudLinked')?.(null, { orgId: ' org-1 ', name: ' Acme ' }) + ) + ).resolves.toBe(createResult) + expect(createCloudLinkedOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + orgId: 'org-1', + name: 'Acme' + }) + }) +}) diff --git a/src/main/ipc/orca-profile-org-members-handlers.test.ts b/src/main/ipc/orca-profile-org-members-handlers.test.ts new file mode 100644 index 00000000000..6dc6775a638 --- /dev/null +++ b/src/main/ipc/orca-profile-org-members-handlers.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + listOrcaProfileOrgMembersMock, + inviteOrcaProfileOrgMemberMock, + revokeOrcaProfileOrgInviteMock, + changeOrcaProfileOrgMemberRoleMock, + removeOrcaProfileOrgMemberMock +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listOrcaProfileOrgMembersMock: vi.fn(), + inviteOrcaProfileOrgMemberMock: vi.fn(), + revokeOrcaProfileOrgInviteMock: vi.fn(), + changeOrcaProfileOrgMemberRoleMock: vi.fn(), + removeOrcaProfileOrgMemberMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('../orca-profiles/profile-storage-paths', () => ({ + getProfileUserDataPath: () => '/tmp/orca-user-data' +})) + +vi.mock('../orca-profiles/profile-cloud-org-members-service', () => ({ + listOrcaProfileOrgMembers: listOrcaProfileOrgMembersMock, + inviteOrcaProfileOrgMember: inviteOrcaProfileOrgMemberMock, + revokeOrcaProfileOrgInvite: revokeOrcaProfileOrgInviteMock, + changeOrcaProfileOrgMemberRole: changeOrcaProfileOrgMemberRoleMock, + removeOrcaProfileOrgMember: removeOrcaProfileOrgMemberMock +})) + +import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' + +function invoke(channel: string, args?: unknown): unknown { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`No handler for ${channel}`) + } + return handler({}, args) +} + +describe('registerOrcaProfileOrgMemberHandlers', () => { + beforeEach(() => { + handlers.clear() + listOrcaProfileOrgMembersMock.mockReset().mockResolvedValue({ status: 'ok', roster: {} }) + inviteOrcaProfileOrgMemberMock.mockReset().mockResolvedValue({ status: 'ok' }) + revokeOrcaProfileOrgInviteMock.mockReset().mockResolvedValue({ status: 'ok' }) + changeOrcaProfileOrgMemberRoleMock.mockReset().mockResolvedValue({ status: 'ok' }) + removeOrcaProfileOrgMemberMock.mockReset().mockResolvedValue({ status: 'ok' }) + registerOrcaProfileOrgMemberHandlers() + }) + + it('registers all five org-member channels', () => { + expect([...handlers.keys()].sort()).toEqual( + [ + 'orcaProfiles:orgInviteRevoke', + 'orcaProfiles:orgMemberChangeRole', + 'orcaProfiles:orgMemberInvite', + 'orcaProfiles:orgMemberRemove', + 'orcaProfiles:orgMembersList' + ].sort() + ) + }) + + it('forwards a valid invite to the service with a trimmed email', async () => { + await invoke('orcaProfiles:orgMemberInvite', { + orgId: 'org-1', + email: ' new@example.com ', + role: 'admin' + }) + expect(inviteOrcaProfileOrgMemberMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + orgId: 'org-1', + email: 'new@example.com', + role: 'admin' + }) + }) + + it('rejects an invite with a missing org id', async () => { + await expect( + invoke('orcaProfiles:orgMemberInvite', { email: 'a@b.com', role: 'member' }) + ).rejects.toThrow('invalid_orca_profile_org_selection') + expect(inviteOrcaProfileOrgMemberMock).not.toHaveBeenCalled() + }) + + it('rejects an invite with an unknown role', async () => { + await expect( + invoke('orcaProfiles:orgMemberInvite', { orgId: 'org-1', email: 'a@b.com', role: 'root' }) + ).rejects.toThrow('invalid_orca_org_role') + }) + + it('rejects a role change with a blank user id', async () => { + await expect( + invoke('orcaProfiles:orgMemberChangeRole', { orgId: 'org-1', userId: ' ', role: 'admin' }) + ).rejects.toThrow('invalid_orca_org_member_user') + }) + + it('forwards remove and revoke with validated args', async () => { + await invoke('orcaProfiles:orgMemberRemove', { orgId: 'org-1', userId: 'user-2' }) + expect(removeOrcaProfileOrgMemberMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + orgId: 'org-1', + userId: 'user-2' + }) + await invoke('orcaProfiles:orgInviteRevoke', { orgId: 'org-1', email: 'gone@b.com' }) + expect(revokeOrcaProfileOrgInviteMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + orgId: 'org-1', + email: 'gone@b.com' + }) + }) +}) diff --git a/src/main/ipc/orca-profile-org-members-handlers.ts b/src/main/ipc/orca-profile-org-members-handlers.ts new file mode 100644 index 00000000000..40eab31b7d4 --- /dev/null +++ b/src/main/ipc/orca-profile-org-members-handlers.ts @@ -0,0 +1,128 @@ +import { ipcMain } from 'electron' +import type { + OrcaOrgRole, + OrcaProfileOrgInviteRevokeArgs, + OrcaProfileOrgMemberChangeRoleArgs, + OrcaProfileOrgMemberInviteArgs, + OrcaProfileOrgMemberMutationResult, + OrcaProfileOrgMemberRemoveArgs, + OrcaProfileOrgMembersListArgs, + OrcaProfileOrgMembersListResult +} from '../../shared/orca-profiles' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { + changeOrcaProfileOrgMemberRole, + inviteOrcaProfileOrgMember, + listOrcaProfileOrgMembers, + removeOrcaProfileOrgMember, + revokeOrcaProfileOrgInvite +} from '../orca-profiles/profile-cloud-org-members-service' + +function orgMembersScopedArgs(args: unknown): { orgId: string; record: Record } { + if (!args || typeof args !== 'object') { + throw new Error('invalid_orca_profile_org_selection') + } + const record = args as Record + const orgId = typeof record.orgId === 'string' ? record.orgId.trim() : '' + if (!orgId) { + throw new Error('invalid_orca_profile_org_selection') + } + return { orgId, record } +} + +function orgRoleFromUnknown(value: unknown): OrcaOrgRole { + if (value === 'owner' || value === 'admin' || value === 'member') { + return value + } + throw new Error('invalid_orca_org_role') +} + +function orgEmailFromUnknown(value: unknown): string { + const email = typeof value === 'string' ? value.trim() : '' + if (!email) { + throw new Error('invalid_orca_org_member_email') + } + return email +} + +function orgUserIdFromUnknown(value: unknown): string { + const userId = typeof value === 'string' ? value.trim() : '' + if (!userId) { + throw new Error('invalid_orca_org_member_user') + } + return userId +} + +function orgMemberInviteArgsFromUnknown(args: unknown): OrcaProfileOrgMemberInviteArgs { + const { orgId, record } = orgMembersScopedArgs(args) + return { orgId, email: orgEmailFromUnknown(record.email), role: orgRoleFromUnknown(record.role) } +} + +function orgInviteRevokeArgsFromUnknown(args: unknown): OrcaProfileOrgInviteRevokeArgs { + const { orgId, record } = orgMembersScopedArgs(args) + return { orgId, email: orgEmailFromUnknown(record.email) } +} + +function orgMemberChangeRoleArgsFromUnknown(args: unknown): OrcaProfileOrgMemberChangeRoleArgs { + const { orgId, record } = orgMembersScopedArgs(args) + return { + orgId, + userId: orgUserIdFromUnknown(record.userId), + role: orgRoleFromUnknown(record.role) + } +} + +function orgMemberRemoveArgsFromUnknown(args: unknown): OrcaProfileOrgMemberRemoveArgs { + const { orgId, record } = orgMembersScopedArgs(args) + return { orgId, userId: orgUserIdFromUnknown(record.userId) } +} + +export function registerOrcaProfileOrgMemberHandlers(): void { + ipcMain.handle( + 'orcaProfiles:orgMembersList', + async ( + _event, + rawArgs: OrcaProfileOrgMembersListArgs + ): Promise => + listOrcaProfileOrgMembers(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId) + ) + + ipcMain.handle( + 'orcaProfiles:orgMemberInvite', + async ( + _event, + rawArgs: OrcaProfileOrgMemberInviteArgs + ): Promise => + inviteOrcaProfileOrgMember(getProfileUserDataPath(), orgMemberInviteArgsFromUnknown(rawArgs)) + ) + + ipcMain.handle( + 'orcaProfiles:orgInviteRevoke', + async ( + _event, + rawArgs: OrcaProfileOrgInviteRevokeArgs + ): Promise => + revokeOrcaProfileOrgInvite(getProfileUserDataPath(), orgInviteRevokeArgsFromUnknown(rawArgs)) + ) + + ipcMain.handle( + 'orcaProfiles:orgMemberChangeRole', + async ( + _event, + rawArgs: OrcaProfileOrgMemberChangeRoleArgs + ): Promise => + changeOrcaProfileOrgMemberRole( + getProfileUserDataPath(), + orgMemberChangeRoleArgsFromUnknown(rawArgs) + ) + ) + + ipcMain.handle( + 'orcaProfiles:orgMemberRemove', + async ( + _event, + rawArgs: OrcaProfileOrgMemberRemoveArgs + ): Promise => + removeOrcaProfileOrgMember(getProfileUserDataPath(), orgMemberRemoveArgsFromUnknown(rawArgs)) + ) +} diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts new file mode 100644 index 00000000000..291a3dfe2f2 --- /dev/null +++ b/src/main/ipc/orca-profiles.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + appExitMock, + appQuitMock, + appRelaunchMock, + destroySystemTrayMock, + createLocalOrcaProfileMock, + getOrcaProfileListStateMock, + seedNewOrcaProfileTelemetryConsentMock, + setActiveOrcaProfileMock, + transferOrcaProfileProjectMock +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + appExitMock: vi.fn(), + appQuitMock: vi.fn(), + appRelaunchMock: vi.fn(), + destroySystemTrayMock: vi.fn(), + createLocalOrcaProfileMock: vi.fn(), + getOrcaProfileListStateMock: vi.fn(), + seedNewOrcaProfileTelemetryConsentMock: vi.fn(), + setActiveOrcaProfileMock: vi.fn(), + transferOrcaProfileProjectMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + exit: appExitMock, + quit: appQuitMock, + relaunch: appRelaunchMock, + getPath: () => '/tmp/orca-user-data' + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('../tray/system-tray', () => ({ + destroySystemTray: destroySystemTrayMock +})) + +vi.mock('../orca-profiles/profile-index-store', () => ({ + createLocalOrcaProfile: createLocalOrcaProfileMock, + getOrcaProfileListState: getOrcaProfileListStateMock, + seedNewOrcaProfileTelemetryConsent: seedNewOrcaProfileTelemetryConsentMock, + setActiveOrcaProfile: setActiveOrcaProfileMock +})) + +function makeStoreMock(flush = vi.fn()): { + flush: typeof flush + freezeWrites: ReturnType + getSettings: () => Record +} { + return { flush, freezeWrites: vi.fn(), getSettings: () => ({}) } +} + +vi.mock('../orca-profiles/profile-project-transfer', () => ({ + transferOrcaProfileProject: transferOrcaProfileProjectMock +})) + +import { registerOrcaProfileHandlers } from './orca-profiles' + +describe('registerOrcaProfileHandlers', () => { + beforeEach(() => { + vi.useFakeTimers() + handlers.clear() + appExitMock.mockReset() + appQuitMock.mockReset() + appRelaunchMock.mockReset() + destroySystemTrayMock.mockReset() + createLocalOrcaProfileMock.mockReset() + getOrcaProfileListStateMock.mockReset() + seedNewOrcaProfileTelemetryConsentMock.mockReset() + setActiveOrcaProfileMock.mockReset() + transferOrcaProfileProjectMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('registers list and create handlers', async () => { + const listState = { + activeProfileId: 'local-default', + profiles: [{ id: 'local-default', name: 'Personal' }] + } + const createState = { + ...listState, + profile: { id: 'local-work', name: 'Work' } + } + getOrcaProfileListStateMock.mockReturnValue(listState) + createLocalOrcaProfileMock.mockReturnValue(createState) + + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ + ...listState, + multiProfileUi: false + }) + await expect( + Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(null, { name: 'Work' })) + ).resolves.toBe(createState) + expect(createLocalOrcaProfileMock).toHaveBeenCalledWith({ name: 'Work' }) + }) + + it('reports multiProfileUi when the env flag is set', async () => { + const previous = process.env.ORCA_MULTI_PROFILE_UI + process.env.ORCA_MULTI_PROFILE_UI = '1' + try { + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'local-default', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ + activeProfileId: 'local-default', + profiles: [], + multiProfileUi: true + }) + } finally { + if (previous === undefined) { + delete process.env.ORCA_MULTI_PROFILE_UI + } else { + process.env.ORCA_MULTI_PROFILE_UI = previous + } + } + }) + + it('marks the target profile active, flushes, and relaunches', async () => { + const flush = vi.fn() + const onBeforeRelaunch = vi.fn() + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'local-default', + profiles: [] + }) + setActiveOrcaProfileMock.mockReturnValue({ + activeProfileId: 'local-work', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) + + const resultPromise = Promise.resolve( + handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) + ) + + await expect(resultPromise).resolves.toEqual({ status: 'relaunching' }) + expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('local-work') + expect(flush).toHaveBeenCalledOnce() + expect(onBeforeRelaunch).toHaveBeenCalledOnce() + expect(flush.mock.invocationCallOrder[0]).toBeLessThan( + setActiveOrcaProfileMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + expect(appRelaunchMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(150) + + expect(appRelaunchMock).toHaveBeenCalledOnce() + // 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 () => { + const flush = vi.fn(() => { + throw new Error('flush_failed') + }) + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'local-default', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock(flush) as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })) + ).rejects.toThrow('flush_failed') + + expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() + expect(appRelaunchMock).not.toHaveBeenCalled() + }) + + it('does not relaunch when switching to the active profile', async () => { + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'local-default', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' })) + ).resolves.toEqual({ status: 'already-active' }) + + expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() + expect(appRelaunchMock).not.toHaveBeenCalled() + }) + + it('rejects invalid profile ids', async () => { + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' })) + ).rejects.toThrow('invalid_orca_profile_id') + }) + + it('transfers projects between inactive profiles after flushing active state', async () => { + const flush = vi.fn() + const result = { + status: 'transferred', + mode: 'copy', + sourceProfileId: 'personal', + targetProfileId: 'work', + sourceRepoId: 'repo-1', + targetRepoId: 'repo-2', + targetProjectId: 'repo:repo-2' + } + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'personal', + profiles: [] + }) + transferOrcaProfileProjectMock.mockReturnValue(result) + registerOrcaProfileHandlers(makeStoreMock(flush) as never) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(null, { + sourceProfileId: ' personal ', + targetProfileId: ' work ', + repoId: ' repo-1 ', + mode: 'copy' + }) + ) + ).resolves.toBe(result) + + expect(flush).toHaveBeenCalledOnce() + expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + '/tmp/orca-user-data' + ) + }) + + it('moves a project out of the active profile and relaunches into the target profile', async () => { + const flush = vi.fn() + const onBeforeRelaunch = vi.fn() + const result = { + status: 'transferred', + mode: 'move', + sourceProfileId: 'personal', + targetProfileId: 'work', + sourceRepoId: 'repo-1', + targetRepoId: 'repo-1', + targetProjectId: 'repo:repo-1' + } + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'personal', + profiles: [] + }) + transferOrcaProfileProjectMock.mockReturnValue(result) + registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(null, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ) + ).resolves.toEqual({ ...result, willRelaunch: true }) + + expect(onBeforeRelaunch).toHaveBeenCalledOnce() + expect(flush).toHaveBeenCalledOnce() + expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + '/tmp/orca-user-data' + ) + expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('work') + expect(appRelaunchMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(150) + + expect(appRelaunchMock).toHaveBeenCalledOnce() + expect(appQuitMock).toHaveBeenCalledOnce() + expect(appExitMock).not.toHaveBeenCalled() + }) + + it('rejects transfers that would mutate the active target profile offline', async () => { + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'work', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(null, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }) + ) + ).rejects.toThrow('active_target_orca_profile_transfer_requires_relaunch') + + expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts new file mode 100644 index 00000000000..fa6a57859e9 --- /dev/null +++ b/src/main/ipc/orca-profiles.ts @@ -0,0 +1,291 @@ +import { app, ipcMain } from 'electron' +import type { Store } from '../persistence' +import type { + CreateLocalOrcaProfileArgs, + CreateLocalOrcaProfileResult, + CreateCloudLinkedOrcaProfileArgs, + CreateCloudLinkedOrcaProfileResult, + FindOrcaProfileProjectsByPathArgs, + FindOrcaProfileProjectsByPathResult, + OrcaProfileListResult, + RefreshCurrentOrcaProfileAuthResult, + SwitchOrcaProfileArgs, + SwitchOrcaProfileResult, + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult, + ConnectCurrentOrcaProfileResult, + OrcaProfileAuthStatus, + SelectOrcaProfileOrgArgs, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult +} from '../../shared/orca-profiles' +import { + createLocalOrcaProfile, + getOrcaProfileListState, + seedNewOrcaProfileTelemetryConsent, + setActiveOrcaProfile +} from '../orca-profiles/profile-index-store' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope' +import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer' +import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence' +import { normalizeExecutionHostId } from '../../shared/execution-host' +import { + createCloudLinkedOrcaProfile, + connectCurrentOrcaProfile, + getCurrentOrcaProfileAuthStatus, + refreshCurrentOrcaProfileAuth, + selectCurrentOrcaProfileOrg, + signOutCurrentOrcaProfile +} from '../orca-profiles/profile-cloud-service' +import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' + +type RegisterOrcaProfileHandlersOptions = { + onBeforeRelaunch?: () => void | Promise +} + +function profileIdFromArgs(args: unknown): string { + if ( + !args || + typeof args !== 'object' || + typeof (args as SwitchOrcaProfileArgs).profileId !== 'string' + ) { + throw new Error('invalid_orca_profile_id') + } + const profileId = (args as SwitchOrcaProfileArgs).profileId.trim() + if (!profileId) { + throw new Error('invalid_orca_profile_id') + } + return profileId +} + +function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs { + if (!args || typeof args !== 'object') { + throw new Error('invalid_orca_profile_project_transfer') + } + const candidate = args as TransferOrcaProfileProjectArgs + const sourceProfileId = candidate.sourceProfileId?.trim() + const targetProfileId = candidate.targetProfileId?.trim() + const repoId = candidate.repoId?.trim() + const mode = candidate.mode + if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) { + throw new Error('invalid_orca_profile_project_transfer') + } + return { + sourceProfileId, + targetProfileId, + repoId, + mode + } +} + +function findProjectsByPathArgsFromUnknown(args: unknown): FindOrcaProfileProjectsByPathArgs { + if (!args || typeof args !== 'object') { + throw new Error('invalid_orca_profile_project_path') + } + const candidate = args as FindOrcaProfileProjectsByPathArgs + const path = typeof candidate.path === 'string' ? candidate.path.trim() : '' + if (!path) { + throw new Error('invalid_orca_profile_project_path') + } + let executionHostId: FindOrcaProfileProjectsByPathArgs['executionHostId'] = null + if (candidate.executionHostId !== null && candidate.executionHostId !== undefined) { + if (typeof candidate.executionHostId !== 'string') { + throw new Error('invalid_orca_profile_project_path') + } + executionHostId = normalizeExecutionHostId(candidate.executionHostId) + if (!executionHostId) { + throw new Error('invalid_orca_profile_project_path') + } + } + return { + path, + connectionId: + typeof candidate.connectionId === 'string' ? candidate.connectionId.trim() || null : null, + executionHostId, + excludeProfileId: + typeof candidate.excludeProfileId === 'string' + ? candidate.excludeProfileId.trim() || null + : null + } +} + +function orgIdFromUnknown(args: unknown): string { + if (!args || typeof args !== 'object') { + throw new Error('invalid_orca_profile_org_selection') + } + const orgId = (args as SelectOrcaProfileOrgArgs).orgId?.trim() + if (!orgId) { + throw new Error('invalid_orca_profile_org_selection') + } + return orgId +} + +function createCloudLinkedProfileArgsFromUnknown(args: unknown): CreateCloudLinkedOrcaProfileArgs { + if (!args || typeof args !== 'object') { + return {} + } + const candidate = args as CreateCloudLinkedOrcaProfileArgs + const orgId = typeof candidate.orgId === 'string' ? candidate.orgId.trim() : undefined + const name = typeof candidate.name === 'string' ? candidate.name.trim() : undefined + return { + ...(orgId ? { orgId } : {}), + ...(name ? { name } : {}) + } +} + +async function runBeforeProfileRelaunch( + onBeforeRelaunch?: () => void | Promise +): Promise { + try { + await onBeforeRelaunch?.() + } catch (error) { + console.warn( + '[orca-profiles] Pre-relaunch cleanup failed; continuing profile switch:', + error instanceof Error ? error.name : typeof error + ) + } +} + +function scheduleProfileRelaunch(): void { + setTimeout(() => { + app.relaunch() + // 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) +} + +export function registerOrcaProfileHandlers( + store: Store, + options: RegisterOrcaProfileHandlersOptions = {} +): void { + ipcMain.handle( + 'orcaProfiles:list', + (): OrcaProfileListResult => ({ + ...getOrcaProfileListState(), + multiProfileUi: isMultiProfileUiEnabled() + }) + ) + + ipcMain.handle( + 'orcaProfiles:authStatus', + (): OrcaProfileAuthStatus => getCurrentOrcaProfileAuthStatus(getProfileUserDataPath()) + ) + + ipcMain.handle( + 'orcaProfiles:createLocal', + (_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => { + const result = createLocalOrcaProfile(args) + seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry) + return result + } + ) + + ipcMain.handle( + 'orcaProfiles:switch', + async (_event, args: SwitchOrcaProfileArgs): Promise => { + const profileId = profileIdFromArgs(args) + const current = getOrcaProfileListState() + if (profileId === current.activeProfileId) { + return { status: 'already-active' } + } + + // Why: the current profile must be persisted before the global index + // points startup at the target profile. + await runBeforeProfileRelaunch(options.onBeforeRelaunch) + store.flush() + setActiveOrcaProfile(profileId) + + scheduleProfileRelaunch() + + return { status: 'relaunching' } + } + ) + + ipcMain.handle( + 'orcaProfiles:transferProject', + async ( + _event, + rawArgs: TransferOrcaProfileProjectArgs + ): Promise => { + const args = transferProjectArgsFromUnknown(rawArgs) + const current = getOrcaProfileListState() + if (args.targetProfileId === current.activeProfileId) { + throw new Error('active_target_orca_profile_transfer_requires_relaunch') + } + if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) { + // 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, getProfileUserDataPath()) + if (result.status === 'transferred') { + store.freezeWrites() + await runBeforeProfileRelaunch(options.onBeforeRelaunch) + setActiveOrcaProfile(args.targetProfileId) + scheduleProfileRelaunch() + return { ...result, willRelaunch: true } + } + return result + } + store.flush() + return transferOrcaProfileProject(args, getProfileUserDataPath()) + } + ) + + ipcMain.handle( + 'orcaProfiles:findProjectProfiles', + (_event, rawArgs: FindOrcaProfileProjectsByPathArgs): FindOrcaProfileProjectsByPathResult => + findOrcaProfileProjectsByPath( + findProjectsByPathArgsFromUnknown(rawArgs), + getProfileUserDataPath() + ) + ) + + ipcMain.handle( + 'orcaProfiles:connectCurrent', + async (): Promise => + connectCurrentOrcaProfile(getProfileUserDataPath()) + ) + + ipcMain.handle( + 'orcaProfiles:createCloudLinked', + async ( + _event, + rawArgs?: CreateCloudLinkedOrcaProfileArgs + ): Promise => { + const result = await createCloudLinkedOrcaProfile( + getProfileUserDataPath(), + createCloudLinkedProfileArgsFromUnknown(rawArgs) + ) + if (result.status === 'created') { + seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry) + } + return result + } + ) + + ipcMain.handle( + 'orcaProfiles:refreshAuth', + async (): Promise => + refreshCurrentOrcaProfileAuth(getProfileUserDataPath()) + ) + + ipcMain.handle( + 'orcaProfiles:signOutCurrent', + async (): Promise => + signOutCurrentOrcaProfile(getProfileUserDataPath()) + ) + + ipcMain.handle( + 'orcaProfiles:selectOrg', + async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise => + selectCurrentOrcaProfileOrg(getProfileUserDataPath(), orgIdFromUnknown(rawArgs)) + ) + + registerOrcaProfileOrgMemberHandlers() +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 6a73db56da6..ad836b8e178 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -31,6 +31,7 @@ const { registerRuntimeEnvironmentHandlersMock, registerEphemeralVmHandlersMock, registerAiVaultHandlersMock, + registerOrcaProfileHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, registerAgentTrustHandlersMock, @@ -90,6 +91,7 @@ const { registerRuntimeEnvironmentHandlersMock: vi.fn(), registerEphemeralVmHandlersMock: vi.fn(), registerAiVaultHandlersMock: vi.fn(), + registerOrcaProfileHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerAgentTrustHandlersMock: vi.fn(), @@ -283,6 +285,10 @@ vi.mock('./ai-vault', () => ({ registerAiVaultHandlers: registerAiVaultHandlersMock })) +vi.mock('./orca-profiles', () => ({ + registerOrcaProfileHandlers: registerOrcaProfileHandlersMock +})) + vi.mock('./codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) @@ -378,6 +384,7 @@ describe('registerCoreHandlers', () => { registerRuntimeEnvironmentHandlersMock.mockReset() registerEphemeralVmHandlersMock.mockReset() registerAiVaultHandlersMock.mockReset() + registerOrcaProfileHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerAgentTrustHandlersMock.mockReset() @@ -476,6 +483,7 @@ describe('registerCoreHandlers', () => { expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store) expect(registerLocalhostWorktreeLabelHandlersMock).toHaveBeenCalledWith(store) expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store) + expect(registerOrcaProfileHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) expect(registerUIHandlersMock).toHaveBeenCalledWith(store) expect(registerEmulatorFrameStreamHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index f651cd002d7..2946204f517 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -50,6 +50,7 @@ import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui' import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream' import { registerSpeechHandlers } from './speech' +import { registerOrcaProfileHandlers } from './orca-profiles' import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' import { registerAgentTrustHandlers } from './agent-trust' @@ -156,6 +157,9 @@ export function registerCoreHandlers( registerKeybindingHandlers(keybindings) } registerTelemetryHandlers(store) + registerOrcaProfileHandlers(store, { + onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch + }) registerBrowserHandlers() registerShellHandlers() registerPetHandlers() diff --git a/src/main/orca-profiles/profile-cloud-auth-config.test.ts b/src/main/orca-profiles/profile-cloud-auth-config.test.ts new file mode 100644 index 00000000000..d18ff159fd5 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-config.test.ts @@ -0,0 +1,122 @@ +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({ + configured: false, + setupMessage: 'Orca Cloud sign-in is not configured for this build.' + }) + }) + + it('builds default desktop auth endpoints from the API URL', () => { + const state = getOrcaCloudAuthConfig({ + ORCA_CLOUD_API_URL: 'https://orca-cloud.example/', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }) + + expect(state).toEqual({ + configured: true, + config: { + apiBaseUrl: 'https://orca-cloud.example', + authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize', + sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session', + refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh', + capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities', + profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', + orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', + logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + clientId: 'desktop-client', + scope: 'openid profile email offline_access' + } + }) + }) + + it('allows loopback HTTP endpoints for local desktop auth development', () => { + const state = getOrcaCloudAuthConfig({ + ORCA_CLOUD_API_URL: 'http://localhost:4100', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }) + + 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({ + ORCA_CLOUD_API_URL: 'http://orca-cloud.example', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }) + ).toMatchObject({ configured: false }) + }) + + it('allows dev plaintext sessions only outside production', () => { + expect( + allowsPlaintextOrcaCloudSession({ + ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1', + NODE_ENV: 'development' + }) + ).toBe(true) + expect( + allowsPlaintextOrcaCloudSession({ + ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1', + NODE_ENV: 'production' + }) + ).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({ + ORCA_CLOUD_DEV_AUTH: '1', + NODE_ENV: 'development' + }) + ).toBe(true) + expect( + isOrcaCloudDevAuthEnabled({ + ORCA_CLOUD_DEV_AUTH: '1', + NODE_ENV: 'production' + }) + ).toBe(false) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-auth-config.ts b/src/main/orca-profiles/profile-cloud-auth-config.ts new file mode 100644 index 00000000000..15f9c2e6d24 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-config.ts @@ -0,0 +1,115 @@ +import { app } from 'electron' + +export type OrcaCloudAuthConfig = { + apiBaseUrl: string + authorizeEndpoint: string + sessionEndpoint: string + refreshEndpoint: string + capabilitiesEndpoint: string + profileEndpoint: string + orgEndpoint: string + logoutEndpoint: string + clientId: string + scope: string +} + +const DEFAULT_SCOPE = 'openid profile email offline_access' + +// 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 + } + try { + const parsed = new URL(trimmed) + const loopbackHost = + parsed.hostname === '127.0.0.1' || + parsed.hostname === 'localhost' || + parsed.hostname === '[::1]' + if (parsed.protocol !== 'https:' && !(loopbackHost && allowLoopbackHttp)) { + return null + } + return parsed.toString().replace(/\/$/, '') + } catch { + return null + } +} + +function endpoint(baseUrl: string, path: string): string { + return new URL(path, `${baseUrl}/`).toString() +} + +export function getOrcaCloudAuthConfig( + env: NodeJS.ProcessEnv = process.env, + packaged: boolean = isPackagedOrcaBuild() +): { configured: true; config: OrcaCloudAuthConfig } | { configured: false; setupMessage: string } { + // 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 { + configured: false, + setupMessage: 'Orca Cloud sign-in is not configured for this build.' + } + } + + const authBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_AUTH_URL) ?? apiBaseUrl + return { + configured: true, + config: { + apiBaseUrl, + authorizeEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_AUTHORIZE_URL) ?? + endpoint(authBaseUrl, '/v1/desktop/auth/authorize'), + sessionEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_SESSION_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/session'), + refreshEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_REFRESH_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/refresh'), + capabilitiesEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_CAPABILITIES_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/capabilities'), + profileEndpoint: + 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: + 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, + 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, + packaged: boolean = isPackagedOrcaBuild() +): boolean { + return env.ORCA_CLOUD_DEV_AUTH === '1' && env.NODE_ENV !== 'production' && !packaged +} diff --git a/src/main/orca-profiles/profile-cloud-auth-status.ts b/src/main/orca-profiles/profile-cloud-auth-status.ts new file mode 100644 index 00000000000..400498d223e --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-status.ts @@ -0,0 +1,56 @@ +import type { OrcaProfileAuthStatus } from '../../shared/orca-profiles' +import type { ActiveOrcaProfileState } from './profile-index-store' +import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config' +import { readOrcaCloudSession } from './profile-cloud-session-store' + +export function getOrcaProfileAuthStatusFromProfile( + active: ActiveOrcaProfileState, + userDataPath: string +): OrcaProfileAuthStatus { + const configState = getOrcaCloudAuthConfig() + const devAuthEnabled = isOrcaCloudDevAuthEnabled() + const configured = configState.configured || devAuthEnabled + const cloud = active.profile.cloud + if (!cloud) { + return { + activeProfileId: active.profile.id, + configured, + state: configured ? 'local' : 'unconfigured', + persistence: 'none', + setupMessage: configured ? undefined : configState.setupMessage + } + } + + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (!configured) { + return { + activeProfileId: active.profile.id, + configured: false, + state: 'unconfigured', + persistence: session.status === 'found' ? session.persistence : 'none', + cloud, + credentialError: session.status === 'decrypt-failed' ? session.error : undefined, + setupMessage: configState.setupMessage + } + } + if (session.status === 'found') { + return { + activeProfileId: active.profile.id, + configured, + state: 'connected', + persistence: session.persistence, + cloud, + organizations: session.session.organizations, + capabilities: session.session.capabilities + } + } + + return { + activeProfileId: active.profile.id, + configured, + state: 'reconnect-required', + persistence: 'none', + cloud, + credentialError: session.status === 'decrypt-failed' ? session.error : undefined + } +} diff --git a/src/main/orca-profiles/profile-cloud-client.test.ts b/src/main/orca-profiles/profile-cloud-client.test.ts new file mode 100644 index 00000000000..f24ba0bb5bf --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-client.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import { + createOrcaCloudProfile, + exchangeOrcaCloudAuthCode, + refreshOrcaCloudCapabilities, + refreshOrcaCloudSession, + selectOrcaCloudOrg +} from './profile-cloud-client' + +const fetchMock = vi.fn() + +const config: OrcaCloudAuthConfig = { + apiBaseUrl: 'https://orca-cloud.example', + authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize', + sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session', + refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh', + capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities', + profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', + orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', + logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + clientId: 'desktop-client', + scope: 'openid profile email offline_access' +} + +const session: OrcaCloudSession = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 999, + capabilities: { flags: { share: true }, refreshedAt: 1 } +} + +function mockFetchJson(value: unknown): void { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => value + }) +} + +describe('Orca cloud client', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + it('normalizes session exchange organizations', async () => { + mockFetchJson({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 999, + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com' + }, + organizations: [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: '', name: 'Ignored' } + ], + capabilities: { + flags: { share: true }, + refreshedAt: 123 + } + }) + + await expect( + exchangeOrcaCloudAuthCode(config, { + code: 'code', + codeVerifier: 'verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state', + localProfileId: 'local-default' + }) + ).resolves.toMatchObject({ + organizations: [{ orgId: 'org-1', name: 'Acme', role: 'Admin' }] + }) + expect(fetchMock).toHaveBeenCalledWith( + config.sessionEndpoint, + expect.objectContaining({ + body: JSON.stringify({ + code: 'code', + codeVerifier: 'verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state', + localProfileId: 'local-default' + }) + }) + ) + }) + + it('normalizes organization selection response metadata', async () => { + mockFetchJson({ + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + activeOrgId: 'org-2', + activeOrgName: 'Personal' + }, + organizations: [ + { orgId: 'org-1', name: 'Acme' }, + { orgId: 'org-2', name: 'Personal' } + ], + capabilities: { + flags: { share: false, sso: true }, + refreshedAt: 456 + } + }) + + await expect(selectOrcaCloudOrg(config, session, 'org-2')).resolves.toEqual({ + cloud: expect.objectContaining({ activeOrgId: 'org-2', activeOrgName: 'Personal' }), + organizations: [ + { orgId: 'org-1', name: 'Acme', role: undefined }, + { orgId: 'org-2', name: 'Personal', role: undefined } + ], + capabilities: { flags: { share: false, sso: true }, refreshedAt: 456 } + }) + expect(fetchMock).toHaveBeenCalledWith( + config.orgEndpoint, + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'Bearer access-token' }) + }) + ) + }) + + it('creates cloud profiles with a profile-scoped session response', async () => { + mockFetchJson({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: 1000, + cloud: { + cloudProfileId: 'cloud-profile-2', + userId: 'user-1', + email: 'nina@example.com', + activeOrgId: 'org-1', + activeOrgName: 'Acme' + }, + organizations: [{ orgId: 'org-1', name: 'Acme' }], + capabilities: { + flags: { share: true }, + refreshedAt: 789 + } + }) + + await expect( + createOrcaCloudProfile(config, session, { orgId: 'org-1', name: 'Acme' }) + ).resolves.toMatchObject({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-2' }), + organizations: [{ orgId: 'org-1', name: 'Acme', role: undefined }] + }) + expect(fetchMock).toHaveBeenCalledWith( + config.profileEndpoint, + expect.objectContaining({ + body: JSON.stringify({ orgId: 'org-1', name: 'Acme' }) + }) + ) + }) + + it('refreshes session material without exposing refresh tokens in URLs', async () => { + mockFetchJson({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token', + expiresAt: 2000, + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com' + }, + capabilities: { + flags: { share: true }, + refreshedAt: 999 + } + }) + + await expect(refreshOrcaCloudSession(config, session)).resolves.toMatchObject({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token' + }) + expect(fetchMock).toHaveBeenCalledWith( + config.refreshEndpoint, + expect.objectContaining({ + body: JSON.stringify({ refreshToken: 'refresh-token' }) + }) + ) + }) + + it('refreshes capability flags and optional org metadata with the current access token', async () => { + mockFetchJson({ + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com' + }, + organizations: [], + capabilities: { + flags: { share: false, team: true }, + refreshedAt: 1001 + } + }) + + await expect(refreshOrcaCloudCapabilities(config, session)).resolves.toEqual({ + cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-1' }), + organizations: [], + capabilities: { + flags: { share: false, team: true }, + refreshedAt: 1001 + } + }) + expect(fetchMock).toHaveBeenCalledWith( + config.capabilitiesEndpoint, + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'Bearer access-token' }) + }) + ) + }) + + it('trims cloud metadata and drops blank active org fields', async () => { + mockFetchJson({ + cloud: { + cloudProfileId: ' cloud-profile-1 ', + userId: ' user-1 ', + email: ' nina@example.com ', + displayName: ' Nina ', + activeOrgId: ' ', + activeOrgName: '' + }, + capabilities: { + flags: {}, + refreshedAt: 1002 + } + }) + + await expect(refreshOrcaCloudCapabilities(config, session)).resolves.toMatchObject({ + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + displayName: 'Nina', + activeOrgId: undefined, + activeOrgName: undefined + } + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-client.ts b/src/main/orca-profiles/profile-cloud-client.ts new file mode 100644 index 00000000000..81d88f72e22 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-client.ts @@ -0,0 +1,263 @@ +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +type ExchangeCodeArgs = { + code: string + codeVerifier: string + nonce: string + redirectUri: string + state: string + localProfileId: string +} + +type CreateCloudProfileArgs = { + orgId?: string + name?: string +} + +type SelectOrgResponse = { + cloud: OrcaProfileCloudSummary + organizations?: OrcaCloudOrgSummary[] + capabilities: OrcaCloudCapabilities +} + +type CapabilityRefreshResponse = { + cloud?: OrcaProfileCloudSummary + organizations?: OrcaCloudOrgSummary[] + capabilities: OrcaCloudCapabilities +} + +export class OrcaCloudRequestError extends Error { + // Why: `errorCode` carries the server's JSON `{error}` discriminator (e.g. + // 'already_member', 'cannot_remove_self') so callers can distinguish the + // precise 4xx cause without re-reading the response body. + constructor( + public readonly statusCode: number, + public readonly errorCode?: string + ) { + super(`orca_cloud_request_failed_${statusCode}`) + this.name = 'OrcaCloudRequestError' + } +} + +function assertString(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new Error(`invalid_orca_cloud_${field}`) + } + const trimmed = value.trim() + if (!trimmed) { + throw new Error(`invalid_orca_cloud_${field}`) + } + return trimmed +} + +function optionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return trimmed || undefined +} + +function assertNumber(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`invalid_orca_cloud_${field}`) + } + return value +} + +function normalizeCapabilities(value: unknown): OrcaCloudCapabilities { + if (!value || typeof value !== 'object') { + return { flags: {}, refreshedAt: Date.now() } + } + const record = value as Record + const rawFlags = record.flags + const flags: Record = {} + if (rawFlags && typeof rawFlags === 'object' && !Array.isArray(rawFlags)) { + for (const [key, flag] of Object.entries(rawFlags)) { + if (typeof flag === 'boolean') { + flags[key] = flag + } + } + } + return { + flags, + refreshedAt: + typeof record.refreshedAt === 'number' && Number.isFinite(record.refreshedAt) + ? record.refreshedAt + : Date.now() + } +} + +function normalizeOrganizations(value: unknown): OrcaCloudOrgSummary[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + const organizations: OrcaCloudOrgSummary[] = [] + for (const item of value) { + if (!item || typeof item !== 'object') { + continue + } + const record = item as Record + if (typeof record.orgId !== 'string' || typeof record.name !== 'string') { + continue + } + const orgId = record.orgId.trim() + const name = record.name.trim() + if (!orgId || !name) { + continue + } + organizations.push({ + orgId, + name, + role: typeof record.role === 'string' && record.role.trim() ? record.role.trim() : undefined + }) + } + return organizations +} + +function normalizeCloudSummary(value: unknown): OrcaProfileCloudSummary { + if (!value || typeof value !== 'object') { + throw new Error('invalid_orca_cloud_profile') + } + const record = value as Record + return { + cloudProfileId: assertString(record.cloudProfileId, 'profile_id'), + userId: assertString(record.userId, 'user_id'), + email: assertString(record.email, 'email'), + displayName: optionalString(record.displayName), + activeOrgId: optionalString(record.activeOrgId), + activeOrgName: optionalString(record.activeOrgName), + linkedAt: + typeof record.linkedAt === 'number' && Number.isFinite(record.linkedAt) + ? record.linkedAt + : Date.now() + } +} + +function normalizeSessionResponse(value: unknown): OrcaCloudSessionExchangeResponse { + if (!value || typeof value !== 'object') { + throw new Error('invalid_orca_cloud_session') + } + const record = value as Record + return { + accessToken: assertString(record.accessToken, 'access_token'), + refreshToken: assertString(record.refreshToken, 'refresh_token'), + expiresAt: assertNumber(record.expiresAt, 'expires_at'), + cloud: normalizeCloudSummary(record.cloud), + organizations: normalizeOrganizations(record.organizations), + capabilities: normalizeCapabilities(record.capabilities) + } +} + +const CLOUD_REQUEST_TIMEOUT_MS = 30_000 + +async function postJson(url: string, body: unknown, accessToken?: string): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}) + }, + body: JSON.stringify(body), + // 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) + } + return (await response.json()) as T +} + +export async function exchangeOrcaCloudAuthCode( + config: OrcaCloudAuthConfig, + args: ExchangeCodeArgs +): Promise { + return normalizeSessionResponse( + await postJson(config.sessionEndpoint, { + code: args.code, + codeVerifier: args.codeVerifier, + nonce: args.nonce, + redirectUri: args.redirectUri, + state: args.state, + localProfileId: args.localProfileId + }) + ) +} + +export async function refreshOrcaCloudCapabilities( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession +): Promise { + const response = await postJson<{ + cloud?: unknown + organizations?: unknown + capabilities: unknown + }>(config.capabilitiesEndpoint, {}, session.accessToken) + return { + cloud: response.cloud === undefined ? undefined : normalizeCloudSummary(response.cloud), + organizations: normalizeOrganizations(response.organizations), + capabilities: normalizeCapabilities(response.capabilities) + } +} + +export async function refreshOrcaCloudSession( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession +): Promise { + return normalizeSessionResponse( + await postJson(config.refreshEndpoint, { + refreshToken: session.refreshToken + }) + ) +} + +export async function createOrcaCloudProfile( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: CreateCloudProfileArgs +): Promise { + return normalizeSessionResponse( + await postJson( + config.profileEndpoint, + { + orgId: args.orgId, + name: args.name + }, + session.accessToken + ) + ) +} + +export async function selectOrcaCloudOrg( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + orgId: string +): Promise { + const response = await postJson<{ + cloud: unknown + organizations?: unknown + capabilities: unknown + }>(config.orgEndpoint, { orgId }, session.accessToken) + return { + cloud: normalizeCloudSummary(response.cloud), + organizations: normalizeOrganizations(response.organizations), + capabilities: normalizeCapabilities(response.capabilities) + } +} + +export async function revokeOrcaCloudSession( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession +): Promise { + await postJson(config.logoutEndpoint, { refreshToken: session.refreshToken }, session.accessToken) +} diff --git a/src/main/orca-profiles/profile-cloud-dev-auth.ts b/src/main/orca-profiles/profile-cloud-dev-auth.ts new file mode 100644 index 00000000000..2383cea6d40 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-dev-auth.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +const DEV_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +function cleanEnvString(value: string | undefined, fallback: string): string { + const trimmed = value?.trim() + return trimmed || fallback +} + +function defaultDevOrganizations(): OrcaCloudOrgSummary[] { + return [ + { orgId: 'dev-personal', name: 'Personal', role: 'Owner' }, + { orgId: 'dev-acme', name: 'Acme Dev', role: 'Admin' } + ] +} + +function devCapabilities(): OrcaCloudCapabilities { + return { + flags: { + share: true, + team: true, + 'share.create': true, + 'share.manage': true, + 'relay.use': true, + 'team.member': true, + 'enterprise.sso': true + }, + refreshedAt: Date.now() + } +} + +function devToken(prefix: string): string { + return `${prefix}-${randomUUID()}` +} + +export function createDevOrcaCloudSession( + args: { + localProfileId?: string + cloudProfileId?: string + orgId?: string + } = {} +): OrcaCloudSessionExchangeResponse { + const organizations = defaultDevOrganizations() + const selectedOrg = organizations.find((organization) => organization.orgId === args.orgId) + const cloudProfileId = + args.cloudProfileId ?? + (args.localProfileId ? `dev-cloud-${args.localProfileId}` : `dev-cloud-${randomUUID()}`) + + return { + accessToken: devToken('dev-access'), + refreshToken: devToken('dev-refresh'), + expiresAt: Date.now() + DEV_SESSION_TTL_MS, + cloud: { + cloudProfileId, + userId: cleanEnvString(process.env.ORCA_CLOUD_DEV_USER_ID, 'dev-user'), + email: cleanEnvString(process.env.ORCA_CLOUD_DEV_EMAIL, 'dev@orca.local'), + displayName: cleanEnvString(process.env.ORCA_CLOUD_DEV_DISPLAY_NAME, 'Orca Dev'), + activeOrgId: selectedOrg?.orgId, + activeOrgName: selectedOrg?.name, + linkedAt: Date.now() + }, + organizations, + capabilities: devCapabilities() + } +} diff --git a/src/main/orca-profiles/profile-cloud-dev-org-members.ts b/src/main/orca-profiles/profile-cloud-dev-org-members.ts new file mode 100644 index 00000000000..4f61e03df6a --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-dev-org-members.ts @@ -0,0 +1,130 @@ +import type { + OrcaOrgMember, + OrcaOrgMembersRoster, + OrcaOrgPendingInvite, + OrcaProfileOrgMemberChangeRoleArgs, + OrcaProfileOrgMemberInviteArgs, + OrcaProfileOrgMemberMutationResult, + OrcaProfileOrgMemberRemoveArgs, + OrcaProfileOrgInviteRevokeArgs +} from '../../shared/orca-profiles' + +// Why: dev-auth mode has no server, so the whole teammate UI is exercised +// against this in-memory per-org roster. It mirrors the shape the real client +// returns (self as owner, one signed-in teammate, one never-signed-in teammate, +// one pending invite) and the mutation endpoints' status semantics. +type DevOrgRoster = { + members: OrcaOrgMember[] + pendingInvites: OrcaOrgPendingInvite[] +} + +const devRostersByOrg = new Map() + +function cleanEnvString(value: string | undefined, fallback: string): string { + const trimmed = value?.trim() + return trimmed || fallback +} + +function devSelf(): OrcaOrgMember { + return { + userId: cleanEnvString(process.env.ORCA_CLOUD_DEV_USER_ID, 'dev-user'), + email: cleanEnvString(process.env.ORCA_CLOUD_DEV_EMAIL, 'dev@orca.local'), + displayName: cleanEnvString(process.env.ORCA_CLOUD_DEV_DISPLAY_NAME, 'Orca Dev'), + role: 'owner' + } +} + +function seedDevRoster(): DevOrgRoster { + return { + members: [ + devSelf(), + { + userId: 'dev-teammate-1', + email: 'teammate@orca.local', + displayName: 'Dev Teammate', + role: 'admin' + }, + // Why: userId null exercises the "hasn't signed in to Orca yet" disabled row. + { userId: null, email: 'invited-member@orca.local', displayName: undefined, role: 'member' } + ], + pendingInvites: [{ email: 'pending@orca.local', role: 'member', createdAt: Date.now() }] + } +} + +function getDevRoster(orgId: string): DevOrgRoster { + const existing = devRostersByOrg.get(orgId) + if (existing) { + return existing + } + const seeded = seedDevRoster() + devRostersByOrg.set(orgId, seeded) + return seeded +} + +export function listDevOrcaCloudOrgMembers(orgId: string): OrcaOrgMembersRoster { + const roster = getDevRoster(orgId) + return { + members: roster.members.map((member) => ({ ...member })), + pendingInvites: roster.pendingInvites.map((invite) => ({ ...invite })), + viewerRole: 'owner', + canManageMembers: true + } +} + +export function inviteDevOrcaCloudOrgMember( + args: OrcaProfileOrgMemberInviteArgs +): OrcaProfileOrgMemberMutationResult { + const roster = getDevRoster(args.orgId) + const email = args.email.toLowerCase() + if (roster.members.some((member) => member.email.toLowerCase() === email)) { + return { status: 'conflict', reason: 'already_member' } + } + if (roster.pendingInvites.some((invite) => invite.email.toLowerCase() === email)) { + return { status: 'conflict', reason: 'already_invited' } + } + roster.pendingInvites.push({ email: args.email, role: args.role, createdAt: Date.now() }) + return { status: 'ok' } +} + +export function revokeDevOrcaCloudOrgInvite( + args: OrcaProfileOrgInviteRevokeArgs +): OrcaProfileOrgMemberMutationResult { + const roster = getDevRoster(args.orgId) + const email = args.email.toLowerCase() + const index = roster.pendingInvites.findIndex((invite) => invite.email.toLowerCase() === email) + if (index === -1) { + return { status: 'not-found' } + } + roster.pendingInvites.splice(index, 1) + return { status: 'ok' } +} + +export function changeDevOrcaCloudOrgMemberRole( + args: OrcaProfileOrgMemberChangeRoleArgs +): OrcaProfileOrgMemberMutationResult { + const roster = getDevRoster(args.orgId) + if (args.userId === devSelf().userId) { + return { status: 'invalid', reason: 'cannot_change_own_role' } + } + const member = roster.members.find((candidate) => candidate.userId === args.userId) + if (!member) { + return { status: 'not-found' } + } + member.role = args.role + return { status: 'ok' } +} + +export function removeDevOrcaCloudOrgMember( + args: OrcaProfileOrgMemberRemoveArgs +): OrcaProfileOrgMemberMutationResult { + const roster = getDevRoster(args.orgId) + if (args.userId === devSelf().userId) { + return { status: 'invalid', reason: 'cannot_remove_self' } + } + const index = roster.members.findIndex((candidate) => candidate.userId === args.userId) + if (index === -1) { + return { status: 'not-found' } + } + roster.members.splice(index, 1) + return { status: 'ok' } +} diff --git a/src/main/orca-profiles/profile-cloud-dev-service.test.ts b/src/main/orca-profiles/profile-cloud-dev-service.test.ts new file mode 100644 index 00000000000..8801320d13f --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-dev-service.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { + beginOrcaCloudPkceFlowMock, + exchangeOrcaCloudAuthCodeMock, + revokeOrcaCloudSessionMock, + safeStorageMock +} = vi.hoisted(() => ({ + beginOrcaCloudPkceFlowMock: vi.fn(), + exchangeOrcaCloudAuthCodeMock: vi.fn(), + revokeOrcaCloudSessionMock: vi.fn(), + safeStorageMock: { + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) + } +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +vi.mock('./profile-cloud-pkce', () => ({ + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock +})) + +vi.mock('./profile-cloud-client', () => ({ + createOrcaCloudProfile: vi.fn(), + exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilities: vi.fn(), + refreshOrcaCloudSession: vi.fn(), + revokeOrcaCloudSession: revokeOrcaCloudSessionMock, + selectOrcaCloudOrg: vi.fn() +})) + +import { + connectCurrentOrcaProfile, + createCloudLinkedOrcaProfile, + getCurrentOrcaProfileAuthStatus, + selectCurrentOrcaProfileOrg, + signOutCurrentOrcaProfile +} from './profile-cloud-service' + +describe('Orca cloud dev auth service', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-dev-auth-')) + beginOrcaCloudPkceFlowMock.mockReset() + exchangeOrcaCloudAuthCodeMock.mockReset() + revokeOrcaCloudSessionMock.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8')) + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + vi.unstubAllEnvs() + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '1') + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('connects the active profile without PKCE or cloud endpoints', async () => { + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + configured: true, + state: 'local' + }) + + const result = await connectCurrentOrcaProfile(userDataPath) + + expect(result.status).toBe('connected') + expect(beginOrcaCloudPkceFlowMock).not.toHaveBeenCalled() + expect(exchangeOrcaCloudAuthCodeMock).not.toHaveBeenCalled() + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'dev-cloud-local-default', + email: 'dev@orca.local' + }, + capabilities: { + flags: expect.objectContaining({ 'share.create': true }) + } + }) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toHaveLength(2) + }) + + it('selects dev organizations and creates org-scoped cloud profiles locally', async () => { + await connectCurrentOrcaProfile(userDataPath) + + const selected = await selectCurrentOrcaProfileOrg(userDataPath, 'dev-acme') + const created = await createCloudLinkedOrcaProfile(userDataPath, { + orgId: 'dev-acme', + name: 'Acme Dev' + }) + + expect(selected.status).toBe('selected') + expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud).toMatchObject({ + activeOrgId: 'dev-acme', + activeOrgName: 'Acme Dev' + }) + expect(created.status).toBe('created') + if (created.status === 'created') { + expect(created.profile).toMatchObject({ + name: 'Acme Dev', + kind: 'cloud-linked', + cloud: expect.objectContaining({ + activeOrgId: 'dev-acme', + activeOrgName: 'Acme Dev' + }) + }) + } + }) + + it('signs out locally without calling the cloud logout endpoint', async () => { + await connectCurrentOrcaProfile(userDataPath) + + const result = await signOutCurrentOrcaProfile(userDataPath) + + expect(result.status).toBe('signed-out') + expect(revokeOrcaCloudSessionMock).not.toHaveBeenCalled() + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + configured: true, + state: 'local', + persistence: 'none' + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-dev-service.ts b/src/main/orca-profiles/profile-cloud-dev-service.ts new file mode 100644 index 00000000000..bb7a6e3599c --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-dev-service.ts @@ -0,0 +1,92 @@ +import type { + CreateCloudLinkedOrcaProfileArgs, + OrcaProfileListState +} from '../../shared/orca-profiles' +import type { ActiveOrcaProfileState } from './profile-index-store' +import { createCloudLinkedOrcaProfileRecord, linkOrcaProfileToCloud } from './profile-cloud-index' +import { readOrcaCloudSession, saveOrcaCloudSessionExchange } from './profile-cloud-session-store' +import { createDevOrcaCloudSession } from './profile-cloud-dev-auth' + +type DevProfileListResult = OrcaProfileListState + +type DevCreateProfileResult = + | { + status: 'created' + list: ReturnType + } + | { status: 'reconnect-required' } + +type DevMutationResult = + | { + status: 'updated' + list: DevProfileListResult + } + | { status: 'reconnect-required' } + +export function connectDevOrcaCloudProfile( + active: ActiveOrcaProfileState, + userDataPath: string +): DevProfileListResult { + const session = createDevOrcaCloudSession({ localProfileId: active.profile.id }) + saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session) + return linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath) +} + +export function createDevCloudLinkedOrcaProfile( + active: ActiveOrcaProfileState, + userDataPath: string, + args: CreateCloudLinkedOrcaProfileArgs +): DevCreateProfileResult { + if (readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found') { + return { status: 'reconnect-required' } + } + const session = createDevOrcaCloudSession({ orgId: args.orgId }) + const list = createCloudLinkedOrcaProfileRecord(session.cloud, { name: args.name }, userDataPath) + saveOrcaCloudSessionExchange(list.profile.id, userDataPath, session) + return { status: 'created', list } +} + +export function refreshDevOrcaCloudProfile( + active: ActiveOrcaProfileState, + userDataPath: string +): DevMutationResult { + if ( + !active.profile.cloud || + readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found' + ) { + return { status: 'reconnect-required' } + } + const session = createDevOrcaCloudSession({ + localProfileId: active.profile.id, + cloudProfileId: active.profile.cloud.cloudProfileId, + orgId: active.profile.cloud.activeOrgId + }) + saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session) + return { + status: 'updated', + list: linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath) + } +} + +export function selectDevOrcaCloudOrg( + active: ActiveOrcaProfileState, + userDataPath: string, + orgId: string +): DevMutationResult { + if ( + !active.profile.cloud || + readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found' + ) { + return { status: 'reconnect-required' } + } + const session = createDevOrcaCloudSession({ + localProfileId: active.profile.id, + cloudProfileId: active.profile.cloud.cloudProfileId, + orgId + }) + saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session) + return { + status: 'updated', + list: linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath) + } +} diff --git a/src/main/orca-profiles/profile-cloud-index.ts b/src/main/orca-profiles/profile-cloud-index.ts new file mode 100644 index 00000000000..3d6843fcbb2 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-index.ts @@ -0,0 +1,143 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync } from 'node:fs' +import type { + OrcaProfileCloudSummary, + OrcaProfileListState, + OrcaProfileSummary +} from '../../shared/orca-profiles' +import { + getOrcaProfileDirectory, + getOrcaProfileIndexPath, + loadOrCreateProfileIndex, + writeProfileIndex +} from './profile-index-store' + +export type CreateCloudLinkedOrcaProfileRecordResult = OrcaProfileListState & { + profile: OrcaProfileSummary +} + +function sanitizeProfileName(value: unknown, fallback: string): string { + const trimmed = typeof value === 'string' ? value.trim() : '' + return (trimmed || fallback).slice(0, 80) +} + +function profileInitial(name: string): string { + return (name.match(/[A-Za-z0-9]/)?.[0] ?? 'C').toUpperCase() +} + +function toCloudLinkedProfile( + profile: OrcaProfileSummary, + cloud: OrcaProfileCloudSummary, + now: number +): OrcaProfileSummary { + return { + ...profile, + kind: 'cloud-linked', + cloud, + updatedAt: now, + lastOpenedAt: now + } +} + +function toLocalProfile(profile: OrcaProfileSummary, now: number): OrcaProfileSummary { + const { cloud: _cloud, ...localProfile } = profile + return { + ...localProfile, + kind: 'local', + updatedAt: now, + lastOpenedAt: now + } +} + +export function createCloudLinkedOrcaProfileRecord( + cloud: OrcaProfileCloudSummary, + args: { name?: string }, + userDataPath: string +): CreateCloudLinkedOrcaProfileRecordResult { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + const fallbackName = cloud.activeOrgName ?? cloud.displayName ?? cloud.email + const name = sanitizeProfileName(args.name, fallbackName) + const profile: OrcaProfileSummary = { + id: `cloud-${randomUUID()}`, + name, + avatar: { + kind: 'initials', + initials: profileInitial(name), + color: 'neutral' + }, + kind: 'cloud-linked', + createdAt: now, + updatedAt: now, + lastOpenedAt: now, + cloud + } + const nextIndex = { + ...index, + profiles: [...index.profiles, profile] + } + mkdirSync(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true }) + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles, + profile + } +} + +export function linkOrcaProfileToCloud( + profileId: string, + cloud: OrcaProfileCloudSummary, + userDataPath: string +): OrcaProfileListState { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + let found = false + const profiles = index.profiles.map((profile) => { + if (profile.id !== profileId) { + return profile + } + found = true + return toCloudLinkedProfile(profile, cloud, now) + }) + if (!found) { + throw new Error('unknown_orca_profile') + } + const nextIndex = { + ...index, + profiles + } + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles + } +} + +export function unlinkOrcaProfileFromCloud( + profileId: string, + userDataPath: string +): OrcaProfileListState { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + let found = false + const profiles = index.profiles.map((profile) => { + if (profile.id !== profileId) { + return profile + } + found = true + return toLocalProfile(profile, now) + }) + if (!found) { + throw new Error('unknown_orca_profile') + } + const nextIndex = { + ...index, + profiles + } + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles + } +} diff --git a/src/main/orca-profiles/profile-cloud-org-members-client.test.ts b/src/main/orca-profiles/profile-cloud-org-members-client.test.ts new file mode 100644 index 00000000000..bc3792742d2 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-org-members-client.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import { OrcaCloudRequestError } from './profile-cloud-client' +import { + changeOrcaCloudOrgMemberRole, + inviteOrcaCloudOrgMember, + listOrcaCloudOrgMembers, + removeOrcaCloudOrgMember, + revokeOrcaCloudOrgInvite +} from './profile-cloud-org-members-client' + +const fetchMock = vi.fn() + +const config: OrcaCloudAuthConfig = { + apiBaseUrl: 'https://orca-cloud.example', + authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize', + sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session', + refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh', + capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities', + profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', + orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', + logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + clientId: 'desktop-client', + scope: 'openid profile email offline_access' +} + +const session: OrcaCloudSession = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 999, + capabilities: { flags: {}, refreshedAt: 1 } +} + +function mockJsonResponse(value: unknown, init: { ok?: boolean; status?: number } = {}): void { + fetchMock.mockResolvedValue({ + ok: init.ok ?? true, + status: init.status ?? 200, + json: async () => value + }) +} + +describe('Orca cloud org members client', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + it('normalizes the roster, dropping malformed rows and defaulting the viewer role', async () => { + mockJsonResponse({ + members: [ + { userId: 'user-1', email: 'nina@example.com', displayName: 'Nina', role: 'admin' }, + { userId: null, email: 'pending-user@example.com', role: 'member' }, + { userId: 'user-3', email: 'weird@example.com', role: 'superadmin' }, + { userId: 'user-4' } + ], + pendingInvites: [{ email: 'invitee@example.com', role: 'member', createdAt: 1712000000000 }], + viewerRole: 'not-a-role', + canManageMembers: true + }) + + await expect(listOrcaCloudOrgMembers(config, session, 'org-1')).resolves.toEqual({ + members: [ + { userId: 'user-1', email: 'nina@example.com', displayName: 'Nina', role: 'admin' }, + { userId: null, email: 'pending-user@example.com', displayName: undefined, role: 'member' }, + { userId: 'user-3', email: 'weird@example.com', displayName: undefined, role: 'member' } + ], + pendingInvites: [{ email: 'invitee@example.com', role: 'member', createdAt: 1712000000000 }], + viewerRole: 'member', + canManageMembers: true + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://orca-cloud.example/v1/desktop/orgs/org-1/members', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ authorization: 'Bearer access-token' }) + }) + ) + }) + + it('percent-encodes the org id in the request URL', async () => { + mockJsonResponse({ + members: [], + pendingInvites: [], + viewerRole: 'owner', + canManageMembers: true + }) + await listOrcaCloudOrgMembers(config, session, 'org/with space') + expect(fetchMock).toHaveBeenCalledWith( + 'https://orca-cloud.example/v1/desktop/orgs/org%2Fwith%20space/members', + expect.any(Object) + ) + }) + + it('posts invites with an email and role body', async () => { + mockJsonResponse({ ok: true }) + await inviteOrcaCloudOrgMember(config, session, { + orgId: 'org-1', + email: 'new@example.com', + role: 'admin' + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://orca-cloud.example/v1/desktop/orgs/org-1/invites', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'new@example.com', role: 'admin' }) + }) + ) + }) + + it('carries the server error code on a 409 conflict', async () => { + mockJsonResponse({ error: 'already_invited' }, { ok: false, status: 409 }) + await expect( + inviteOrcaCloudOrgMember(config, session, { + orgId: 'org-1', + email: 'dupe@example.com', + role: 'member' + }) + ).rejects.toMatchObject({ statusCode: 409, errorCode: 'already_invited' }) + }) + + it('surfaces a 403 as an OrcaCloudRequestError without an error code when the body is empty', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: async () => { + throw new Error('no body') + } + }) + await expect( + changeOrcaCloudOrgMemberRole(config, session, { + orgId: 'org-1', + userId: 'user-2', + role: 'admin' + }) + ).rejects.toBeInstanceOf(OrcaCloudRequestError) + await expect( + removeOrcaCloudOrgMember(config, session, { orgId: 'org-1', userId: 'user-2' }) + ).rejects.toMatchObject({ statusCode: 403, errorCode: undefined }) + }) + + it('posts invite revocations by email', async () => { + mockJsonResponse({ ok: true }) + await revokeOrcaCloudOrgInvite(config, session, { orgId: 'org-1', email: 'gone@example.com' }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://orca-cloud.example/v1/desktop/orgs/org-1/invites/revoke', + expect.objectContaining({ body: JSON.stringify({ email: 'gone@example.com' }) }) + ) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-org-members-client.ts b/src/main/orca-profiles/profile-cloud-org-members-client.ts new file mode 100644 index 00000000000..cd1b106e489 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-org-members-client.ts @@ -0,0 +1,209 @@ +import type { + OrcaOrgMember, + OrcaOrgMembersRoster, + OrcaOrgPendingInvite, + OrcaOrgRole +} from '../../shared/orca-profiles' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import { OrcaCloudRequestError } from './profile-cloud-client' + +const CLOUD_REQUEST_TIMEOUT_MS = 30_000 +const ORG_ROLES: readonly OrcaOrgRole[] = ['owner', 'admin', 'member'] + +function isOrgRole(value: unknown): value is OrcaOrgRole { + return typeof value === 'string' && (ORG_ROLES as readonly string[]).includes(value) +} + +function normalizeRole(value: unknown, fallback: OrcaOrgRole): OrcaOrgRole { + return isOrgRole(value) ? value : fallback +} + +function optionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return trimmed || undefined +} + +function normalizeTimestamp(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string') { + const parsed = Date.parse(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + return Date.now() +} + +function normalizeMember(value: unknown): OrcaOrgMember | null { + if (!value || typeof value !== 'object') { + return null + } + const record = value as Record + const email = optionalString(record.email) + if (!email) { + return null + } + const userId = optionalString(record.userId) + return { + userId: userId ?? null, + email, + displayName: optionalString(record.displayName), + role: normalizeRole(record.role, 'member') + } +} + +function normalizePendingInvite(value: unknown): OrcaOrgPendingInvite | null { + if (!value || typeof value !== 'object') { + return null + } + const record = value as Record + const email = optionalString(record.email) + if (!email) { + return null + } + return { + email, + role: normalizeRole(record.role, 'member'), + createdAt: normalizeTimestamp(record.createdAt) + } +} + +function normalizeRoster(value: unknown): OrcaOrgMembersRoster { + if (!value || typeof value !== 'object') { + throw new Error('invalid_orca_org_members_roster') + } + const record = value as Record + const members = Array.isArray(record.members) + ? record.members + .map(normalizeMember) + .filter((member): member is OrcaOrgMember => member !== null) + : [] + const pendingInvites = Array.isArray(record.pendingInvites) + ? record.pendingInvites + .map(normalizePendingInvite) + .filter((invite): invite is OrcaOrgPendingInvite => invite !== null) + : [] + return { + members, + pendingInvites, + // Why: default to the least-privileged role so a malformed viewerRole can + // never widen the client-side management affordance; the server still + // enforces authorization on every mutation. + viewerRole: normalizeRole(record.viewerRole, 'member'), + canManageMembers: record.canManageMembers === true + } +} + +function orgMembersUrl(config: OrcaCloudAuthConfig, orgId: string, path: string): string { + return `${config.apiBaseUrl}/v1/desktop/orgs/${encodeURIComponent(orgId)}${path}` +} + +async function extractErrorCode(response: Response): Promise { + try { + const body = (await response.json()) as unknown + if ( + body && + typeof body === 'object' && + typeof (body as { error?: unknown }).error === 'string' + ) { + return (body as { error: string }).error.trim() || undefined + } + } catch { + // Non-JSON error body; the status code alone drives the caller's mapping. + } + return undefined +} + +// Why: these are fixed first-party endpoints bearing the profile's access token; +// following a redirect would leak that token to another origin, and a stalled +// server must not hang the renderer's awaited IPC call forever. +function requestInit(method: 'GET' | 'POST', accessToken: string, body?: unknown): RequestInit { + return { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + authorization: `Bearer ${accessToken}` + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + redirect: 'error', + signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS) + } +} + +async function requestOrgMembers( + url: string, + init: RequestInit, + parse: (value: unknown) => T +): Promise { + const response = await fetch(url, init) + if (!response.ok) { + throw new OrcaCloudRequestError(response.status, await extractErrorCode(response)) + } + return parse((await response.json()) as unknown) +} + +export async function listOrcaCloudOrgMembers( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + orgId: string +): Promise { + return requestOrgMembers( + orgMembersUrl(config, orgId, '/members'), + requestInit('GET', session.accessToken), + normalizeRoster + ) +} + +export async function inviteOrcaCloudOrgMember( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: { orgId: string; email: string; role: OrcaOrgRole } +): Promise { + await requestOrgMembers( + orgMembersUrl(config, args.orgId, '/invites'), + requestInit('POST', session.accessToken, { email: args.email, role: args.role }), + () => undefined + ) +} + +export async function revokeOrcaCloudOrgInvite( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: { orgId: string; email: string } +): Promise { + await requestOrgMembers( + orgMembersUrl(config, args.orgId, '/invites/revoke'), + requestInit('POST', session.accessToken, { email: args.email }), + () => undefined + ) +} + +export async function changeOrcaCloudOrgMemberRole( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: { orgId: string; userId: string; role: OrcaOrgRole } +): Promise { + await requestOrgMembers( + orgMembersUrl(config, args.orgId, '/members/role'), + requestInit('POST', session.accessToken, { userId: args.userId, role: args.role }), + () => undefined + ) +} + +export async function removeOrcaCloudOrgMember( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: { orgId: string; userId: string } +): Promise { + await requestOrgMembers( + orgMembersUrl(config, args.orgId, '/members/remove'), + requestInit('POST', session.accessToken, { userId: args.userId }), + () => undefined + ) +} diff --git a/src/main/orca-profiles/profile-cloud-org-members-service.test.ts b/src/main/orca-profiles/profile-cloud-org-members-service.test.ts new file mode 100644 index 00000000000..8d4011d79ff --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-org-members-service.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { OrcaOrgMembersRoster } from '../../shared/orca-profiles' +import { OrcaCloudRequestError } from './profile-cloud-client' + +const { + runWithFreshOrcaCloudSessionMock, + listOrcaCloudOrgMembersMock, + inviteOrcaCloudOrgMemberMock, + revokeOrcaCloudOrgInviteMock, + changeOrcaCloudOrgMemberRoleMock, + removeOrcaCloudOrgMemberMock +} = vi.hoisted(() => ({ + runWithFreshOrcaCloudSessionMock: vi.fn(), + listOrcaCloudOrgMembersMock: vi.fn(), + inviteOrcaCloudOrgMemberMock: vi.fn(), + revokeOrcaCloudOrgInviteMock: vi.fn(), + changeOrcaCloudOrgMemberRoleMock: vi.fn(), + removeOrcaCloudOrgMemberMock: vi.fn() +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { getPath: () => userDataPath } +})) + +vi.mock('./profile-cloud-session-refresh', () => ({ + runWithFreshOrcaCloudSessionMock, + runWithFreshOrcaCloudSession: runWithFreshOrcaCloudSessionMock +})) + +vi.mock('./profile-cloud-org-members-client', () => ({ + listOrcaCloudOrgMembers: listOrcaCloudOrgMembersMock, + inviteOrcaCloudOrgMember: inviteOrcaCloudOrgMemberMock, + revokeOrcaCloudOrgInvite: revokeOrcaCloudOrgInviteMock, + changeOrcaCloudOrgMemberRole: changeOrcaCloudOrgMemberRoleMock, + removeOrcaCloudOrgMember: removeOrcaCloudOrgMemberMock +})) + +import { + changeOrcaProfileOrgMemberRole, + inviteOrcaProfileOrgMember, + listOrcaProfileOrgMembers, + removeOrcaProfileOrgMember, + revokeOrcaProfileOrgInvite +} from './profile-cloud-org-members-service' + +const fakeSession = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3_600_000, + capabilities: { flags: {}, refreshedAt: 1 } +} + +// Why: mirror the real contract — invoke the operation with a live session and +// surface its resolved value; business 4xx are returned by the operation as +// values, never thrown, so the session layer never sees them. +function runOperationDirectly(): void { + runWithFreshOrcaCloudSessionMock.mockImplementation( + async ( + _config: unknown, + _active: unknown, + _path: unknown, + op: (session: unknown) => unknown + ) => ({ + status: 'ok', + value: await op(fakeSession) + }) + ) +} + +function configureCloudEnv(): void { + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client') +} + +const roster: OrcaOrgMembersRoster = { + members: [{ userId: 'user-1', email: 'nina@example.com', role: 'owner' }], + pendingInvites: [], + viewerRole: 'owner', + canManageMembers: true +} + +describe('Orca cloud org members service (configured)', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-org-members-')) + runWithFreshOrcaCloudSessionMock.mockReset() + listOrcaCloudOrgMembersMock.mockReset() + inviteOrcaCloudOrgMemberMock.mockReset() + revokeOrcaCloudOrgInviteMock.mockReset() + changeOrcaCloudOrgMemberRoleMock.mockReset() + removeOrcaCloudOrgMemberMock.mockReset() + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '') + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('reports unconfigured when cloud sign-in is not set up', async () => { + await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({ + status: 'unconfigured' + }) + expect(runWithFreshOrcaCloudSessionMock).not.toHaveBeenCalled() + }) + + it('returns the roster from the client', async () => { + configureCloudEnv() + runOperationDirectly() + listOrcaCloudOrgMembersMock.mockResolvedValue(roster) + + await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({ + status: 'ok', + roster + }) + expect(listOrcaCloudOrgMembersMock).toHaveBeenCalledWith( + expect.any(Object), + fakeSession, + 'org-1' + ) + }) + + it('maps a 409 already_member invite conflict', async () => { + configureCloudEnv() + runOperationDirectly() + inviteOrcaCloudOrgMemberMock.mockRejectedValue(new OrcaCloudRequestError(409, 'already_member')) + + await expect( + inviteOrcaProfileOrgMember(userDataPath, { orgId: 'org-1', email: 'a@b.com', role: 'member' }) + ).resolves.toEqual({ status: 'conflict', reason: 'already_member' }) + }) + + it('maps a 403 role change to forbidden', async () => { + configureCloudEnv() + runOperationDirectly() + changeOrcaCloudOrgMemberRoleMock.mockRejectedValue(new OrcaCloudRequestError(403)) + + await expect( + changeOrcaProfileOrgMemberRole(userDataPath, { + orgId: 'org-1', + userId: 'user-2', + role: 'admin' + }) + ).resolves.toEqual({ status: 'forbidden' }) + }) + + it('maps a 400 cannot_remove_self to an invalid result', async () => { + configureCloudEnv() + runOperationDirectly() + removeOrcaCloudOrgMemberMock.mockRejectedValue( + new OrcaCloudRequestError(400, 'cannot_remove_self') + ) + + await expect( + removeOrcaProfileOrgMember(userDataPath, { orgId: 'org-1', userId: 'user-1' }) + ).resolves.toEqual({ status: 'invalid', reason: 'cannot_remove_self' }) + }) + + it('maps a 404 revoke to not-found', async () => { + configureCloudEnv() + runOperationDirectly() + revokeOrcaCloudOrgInviteMock.mockRejectedValue(new OrcaCloudRequestError(404)) + + await expect( + revokeOrcaProfileOrgInvite(userDataPath, { orgId: 'org-1', email: 'gone@b.com' }) + ).resolves.toEqual({ status: 'not-found' }) + }) + + it('reports reconnect-required when the session layer cannot refresh', async () => { + configureCloudEnv() + runWithFreshOrcaCloudSessionMock.mockResolvedValue({ status: 'reconnect-required' }) + + await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({ + status: 'reconnect-required' + }) + }) +}) + +describe('Orca cloud org members service (dev auth)', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-org-members-dev-')) + runWithFreshOrcaCloudSessionMock.mockReset() + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '1') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('serves an in-memory roster the caller can manage', async () => { + const result = await listOrcaProfileOrgMembers(userDataPath, 'dev-list-org') + if (result.status !== 'ok') { + throw new Error(`Expected ok, got ${result.status}`) + } + expect(result.roster.canManageMembers).toBe(true) + expect(result.roster.viewerRole).toBe('owner') + expect(result.roster.members[0]).toMatchObject({ role: 'owner' }) + expect(result.roster.members.some((member) => member.userId === null)).toBe(true) + expect(result.roster.pendingInvites.length).toBeGreaterThan(0) + expect(runWithFreshOrcaCloudSessionMock).not.toHaveBeenCalled() + }) + + it('mutates the dev roster across invite and revoke', async () => { + const orgId = 'dev-mutate-org' + await expect( + inviteOrcaProfileOrgMember(userDataPath, { + orgId, + email: 'fresh@orca.local', + role: 'member' + }) + ).resolves.toEqual({ status: 'ok' }) + + const afterInvite = await listOrcaProfileOrgMembers(userDataPath, orgId) + if (afterInvite.status !== 'ok') { + throw new Error('expected ok') + } + expect(afterInvite.roster.pendingInvites.some((i) => i.email === 'fresh@orca.local')).toBe(true) + + await expect( + inviteOrcaProfileOrgMember(userDataPath, { + orgId, + email: 'fresh@orca.local', + role: 'member' + }) + ).resolves.toEqual({ status: 'conflict', reason: 'already_invited' }) + + await expect( + revokeOrcaProfileOrgInvite(userDataPath, { orgId, email: 'fresh@orca.local' }) + ).resolves.toEqual({ status: 'ok' }) + await expect( + revokeOrcaProfileOrgInvite(userDataPath, { orgId, email: 'fresh@orca.local' }) + ).resolves.toEqual({ status: 'not-found' }) + }) + + it('blocks changing the dev owner (self) role', async () => { + const orgId = 'dev-self-org' + const list = await listOrcaProfileOrgMembers(userDataPath, orgId) + if (list.status !== 'ok') { + throw new Error('expected ok') + } + const self = list.roster.members.find((member) => member.role === 'owner') + await expect( + changeOrcaProfileOrgMemberRole(userDataPath, { + orgId, + userId: self?.userId ?? 'dev-user', + role: 'member' + }) + ).resolves.toEqual({ status: 'invalid', reason: 'cannot_change_own_role' }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-org-members-service.ts b/src/main/orca-profiles/profile-cloud-org-members-service.ts new file mode 100644 index 00000000000..d115059a08f --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-org-members-service.ts @@ -0,0 +1,212 @@ +import type { + OrcaProfileOrgInviteRevokeArgs, + OrcaProfileOrgMemberChangeRoleArgs, + OrcaProfileOrgMemberInviteArgs, + OrcaProfileOrgMemberMutationResult, + OrcaProfileOrgMemberRemoveArgs, + OrcaProfileOrgMembersListResult +} from '../../shared/orca-profiles' +import type { ActiveOrcaProfileState } from './profile-index-store' +import { ensureActiveOrcaProfile } from './profile-index-store' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import { OrcaCloudRequestError } from './profile-cloud-client' +import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh' +import { + changeOrcaCloudOrgMemberRole, + inviteOrcaCloudOrgMember, + listOrcaCloudOrgMembers, + removeOrcaCloudOrgMember, + revokeOrcaCloudOrgInvite +} from './profile-cloud-org-members-client' +import { + changeDevOrcaCloudOrgMemberRole, + inviteDevOrcaCloudOrgMember, + listDevOrcaCloudOrgMembers, + removeDevOrcaCloudOrgMember, + revokeDevOrcaCloudOrgInvite +} from './profile-cloud-dev-org-members' + +type OrgCallResult = + | { status: 'ok'; value: T } + | { status: 'reconnect-required' } + | { status: 'request-error'; error: OrcaCloudRequestError } + | { status: 'failed'; error: string } + +// Why: only a 401 means the token itself is stale and should drive a session +// refresh/reconnect. 403/404/409/400 are business or permission outcomes the UI +// must interpret, so they are surfaced as values rather than thrown — otherwise +// runWithFreshOrcaCloudSession would treat a 403 as an auth failure and burn a +// pointless token refresh + retry before giving up. +async function runOrgMemberCall( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string, + call: (session: OrcaCloudSession) => Promise +): Promise> { + try { + const operation = await runWithFreshOrcaCloudSession( + config, + active, + userDataPath, + async (session) => { + try { + return { ok: true as const, value: await call(session) } + } catch (error) { + if (error instanceof OrcaCloudRequestError && error.statusCode !== 401) { + return { ok: false as const, error } + } + throw error + } + } + ) + if (operation.status !== 'ok') { + return { status: 'reconnect-required' } + } + const outcome = operation.value + return outcome.ok + ? { status: 'ok', value: outcome.value } + : { status: 'request-error', error: outcome.error } + } catch (error) { + return { status: 'failed', error: error instanceof Error ? error.message : String(error) } + } +} + +function mapMutationRequestError(error: OrcaCloudRequestError): OrcaProfileOrgMemberMutationResult { + switch (error.statusCode) { + case 403: + return { status: 'forbidden' } + case 404: + return { status: 'not-found' } + case 409: + return { + status: 'conflict', + reason: error.errorCode === 'already_member' ? 'already_member' : 'already_invited' + } + case 400: + return { + status: 'invalid', + reason: + error.errorCode === 'cannot_remove_self' ? 'cannot_remove_self' : 'cannot_change_own_role' + } + default: + return { status: 'failed', error: error.message } + } +} + +function mapMutationResult(result: OrgCallResult): OrcaProfileOrgMemberMutationResult { + switch (result.status) { + case 'ok': + return { status: 'ok' } + case 'reconnect-required': + return { status: 'reconnect-required' } + case 'request-error': + return mapMutationRequestError(result.error) + case 'failed': + return { status: 'failed', error: result.error } + } +} + +export async function listOrcaProfileOrgMembers( + userDataPath: string, + orgId: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return { status: 'ok', roster: listDevOrcaCloudOrgMembers(orgId) } + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + const result = await runOrgMemberCall(configState.config, active, userDataPath, (session) => + listOrcaCloudOrgMembers(configState.config, session, orgId) + ) + switch (result.status) { + case 'ok': + return { status: 'ok', roster: result.value } + case 'reconnect-required': + return { status: 'reconnect-required' } + case 'request-error': + return { status: 'failed', error: result.error.message } + case 'failed': + return { status: 'failed', error: result.error } + } +} + +export async function inviteOrcaProfileOrgMember( + userDataPath: string, + args: OrcaProfileOrgMemberInviteArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return inviteDevOrcaCloudOrgMember(args) + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + return mapMutationResult( + await runOrgMemberCall(configState.config, active, userDataPath, (session) => + inviteOrcaCloudOrgMember(configState.config, session, args) + ) + ) +} + +export async function revokeOrcaProfileOrgInvite( + userDataPath: string, + args: OrcaProfileOrgInviteRevokeArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return revokeDevOrcaCloudOrgInvite(args) + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + return mapMutationResult( + await runOrgMemberCall(configState.config, active, userDataPath, (session) => + revokeOrcaCloudOrgInvite(configState.config, session, args) + ) + ) +} + +export async function changeOrcaProfileOrgMemberRole( + userDataPath: string, + args: OrcaProfileOrgMemberChangeRoleArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return changeDevOrcaCloudOrgMemberRole(args) + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + return mapMutationResult( + await runOrgMemberCall(configState.config, active, userDataPath, (session) => + changeOrcaCloudOrgMemberRole(configState.config, session, args) + ) + ) +} + +export async function removeOrcaProfileOrgMember( + userDataPath: string, + args: OrcaProfileOrgMemberRemoveArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return removeDevOrcaCloudOrgMember(args) + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + return mapMutationResult( + await runOrgMemberCall(configState.config, active, userDataPath, (session) => + removeOrcaCloudOrgMember(configState.config, session, args) + ) + ) +} diff --git a/src/main/orca-profiles/profile-cloud-pkce.test.ts b/src/main/orca-profiles/profile-cloud-pkce.test.ts new file mode 100644 index 00000000000..03f6e5894b3 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-pkce.test.ts @@ -0,0 +1,127 @@ +import { get } from 'node:http' +import { describe, expect, it, beforeEach, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' + +const { openExternalMock } = vi.hoisted(() => ({ + openExternalMock: vi.fn() +})) + +vi.mock('electron', () => ({ + shell: { + openExternal: openExternalMock + } +})) + +import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce' + +type HttpResponse = { + body: string + statusCode: number | undefined +} + +const config: OrcaCloudAuthConfig = { + apiBaseUrl: 'https://orca-cloud.example', + authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize', + sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session', + refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh', + capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities', + profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', + orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', + logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + clientId: 'desktop-client', + scope: 'openid profile email offline_access' +} + +function readHttp(url: string): Promise { + return new Promise((resolve, reject) => { + const request = get(url, (response) => { + response.setEncoding('utf-8') + let body = '' + response.on('data', (chunk: string) => { + body += chunk + }) + response.on('end', () => { + resolve({ body, statusCode: response.statusCode }) + }) + }) + request.on('error', reject) + }) +} + +function callbackUrl(redirectUri: string, params: Record): string { + const url = new URL(redirectUri) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + return url.toString() +} + +async function startedFlow(): Promise<{ + authUrl: URL + flow: ReturnType + nonce: string + redirectUri: string + state: string +}> { + const flow = beginOrcaCloudPkceFlow(config, 'local-default') + await vi.waitFor(() => expect(openExternalMock).toHaveBeenCalledTimes(1)) + const authUrl = new URL(String(openExternalMock.mock.calls[0]?.[0])) + const nonce = authUrl.searchParams.get('nonce') + const redirectUri = authUrl.searchParams.get('redirect_uri') + const state = authUrl.searchParams.get('state') + if (!nonce || !redirectUri || !state) { + throw new Error('Expected PKCE flow to create nonce, redirect_uri, and state') + } + return { authUrl, flow, nonce, redirectUri, state } +} + +describe('Orca cloud PKCE flow', () => { + beforeEach(() => { + openExternalMock.mockReset() + openExternalMock.mockResolvedValue(undefined) + }) + + it('keeps the loopback listener alive after an invalid callback', async () => { + const { flow, redirectUri, state } = await startedFlow() + + const invalidResponse = await readHttp( + callbackUrl(redirectUri, { code: 'wrong-code', state: 'wrong-state' }) + ) + expect(invalidResponse.statusCode).toBe(400) + + const validResponse = await readHttp(callbackUrl(redirectUri, { code: 'real-code', state })) + expect(validResponse.statusCode).toBe(200) + await expect(flow).resolves.toMatchObject({ + code: 'real-code', + redirectUri, + state + }) + }) + + it('rejects a provider error that matches the flow state', async () => { + const { flow, redirectUri, state } = await startedFlow() + const observedFlow = flow.catch((error: unknown) => error) + + const response = await readHttp(callbackUrl(redirectUri, { error: 'access_denied', state })) + + expect(response.statusCode).toBe(400) + await expect(observedFlow).resolves.toMatchObject({ message: 'orca_cloud_auth_denied' }) + }) + + it('adds desktop PKCE parameters to the authorize URL', async () => { + const { authUrl, flow, nonce, redirectUri, state } = await startedFlow() + + expect(authUrl.searchParams.get('client_id')).toBe('desktop-client') + expect(authUrl.searchParams.get('response_type')).toBe('code') + expect(authUrl.searchParams.get('redirect_uri')).toBe(redirectUri) + expect(authUrl.searchParams.get('scope')).toBe('openid profile email offline_access') + expect(authUrl.searchParams.get('nonce')).toBe(nonce) + expect(authUrl.searchParams.get('state')).toBe(state) + expect(authUrl.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]+$/) + expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256') + expect(authUrl.searchParams.get('local_profile_id')).toBe('local-default') + + await readHttp(callbackUrl(redirectUri, { code: 'real-code', state })) + await expect(flow).resolves.toMatchObject({ code: 'real-code', nonce }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-pkce.ts b/src/main/orca-profiles/profile-cloud-pkce.ts new file mode 100644 index 00000000000..8adf3f3e587 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-pkce.ts @@ -0,0 +1,142 @@ +import { createHash, randomBytes } from 'node:crypto' +import { createServer, type Server, type ServerResponse } from 'node:http' +import { shell } from 'electron' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' + +export type OrcaCloudAuthorizationCode = { + code: string + codeVerifier: string + nonce: string + redirectUri: string + state: string +} + +const AUTH_TIMEOUT_MS = 5 * 60 * 1000 + +function base64Url(buffer: Buffer): string { + return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') +} + +function createCodeVerifier(): string { + return base64Url(randomBytes(32)) +} + +function createCodeChallenge(verifier: string): string { + return base64Url(createHash('sha256').update(verifier).digest()) +} + +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. + } +} + +export function beginOrcaCloudPkceFlow( + config: OrcaCloudAuthConfig, + localProfileId: string +): Promise { + const codeVerifier = createCodeVerifier() + const nonce = base64Url(randomBytes(32)) + const state = base64Url(randomBytes(32)) + + return new Promise((resolve, reject) => { + let settled = false + let redirectUri = '' + + function rejectFlow(error: Error): void { + if (settled) { + return + } + settled = true + reject(error) + closeServer(server) + } + + function resolveFlow(code: string): void { + if (settled) { + return + } + settled = true + resolve({ + code, + codeVerifier, + nonce, + redirectUri, + state + }) + closeServer(server) + } + + function writeInvalidCallback(response: ServerResponse): void { + response.writeHead(400) + response.end('Invalid Orca sign-in response.') + } + + const server = createServer((request, response) => { + try { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + if (url.pathname !== '/auth/callback') { + response.writeHead(404) + response.end('Not found') + return + } + const code = url.searchParams.get('code') + const returnedState = url.searchParams.get('state') + if (returnedState !== state) { + // Why: stray loopback probes must not be able to cancel the user's login. + writeInvalidCallback(response) + return + } + if (url.searchParams.has('error')) { + response.writeHead(400) + response.end('Orca sign-in was cancelled.') + rejectFlow(new Error('orca_cloud_auth_denied')) + return + } + if (!code) { + writeInvalidCallback(response) + return + } + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + response.end('Orca

You can return to Orca.

') + resolveFlow(code) + } catch (error) { + rejectFlow(error instanceof Error ? error : new Error('orca_cloud_auth_callback_failed')) + } + }) + + const timeout = setTimeout(() => { + rejectFlow(new Error('orca_cloud_auth_timeout')) + }, AUTH_TIMEOUT_MS) + server.once('close', () => clearTimeout(timeout)) + server.once('error', rejectFlow) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + rejectFlow(new Error('orca_cloud_auth_loopback_unavailable')) + return + } + redirectUri = `http://127.0.0.1:${address.port}/auth/callback` + const authorizeUrl = new URL(config.authorizeEndpoint) + authorizeUrl.searchParams.set('client_id', config.clientId) + authorizeUrl.searchParams.set('response_type', 'code') + authorizeUrl.searchParams.set('redirect_uri', redirectUri) + authorizeUrl.searchParams.set('scope', config.scope) + authorizeUrl.searchParams.set('nonce', nonce) + authorizeUrl.searchParams.set('state', state) + authorizeUrl.searchParams.set('code_challenge', createCodeChallenge(codeVerifier)) + 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') + ) + }) + }) + }) +} diff --git a/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts b/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts new file mode 100644 index 00000000000..045f31359f8 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +const { + beginOrcaCloudPkceFlowMock, + createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilitiesMock, + refreshOrcaCloudSessionMock, + selectOrcaCloudOrgMock, + OrcaCloudRequestErrorMock, + safeStorageMock +} = vi.hoisted(() => ({ + beginOrcaCloudPkceFlowMock: vi.fn(), + createOrcaCloudProfileMock: vi.fn(), + exchangeOrcaCloudAuthCodeMock: vi.fn(), + refreshOrcaCloudCapabilitiesMock: vi.fn(), + refreshOrcaCloudSessionMock: vi.fn(), + selectOrcaCloudOrgMock: vi.fn(), + OrcaCloudRequestErrorMock: class OrcaCloudRequestError extends Error { + constructor(public readonly statusCode: number) { + super(`orca_cloud_request_failed_${statusCode}`) + this.name = 'OrcaCloudRequestError' + } + }, + safeStorageMock: { + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) + } +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +vi.mock('./profile-cloud-pkce', () => ({ + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock +})) + +vi.mock('./profile-cloud-client', () => ({ + OrcaCloudRequestError: OrcaCloudRequestErrorMock, + createOrcaCloudProfile: createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilities: refreshOrcaCloudCapabilitiesMock, + refreshOrcaCloudSession: refreshOrcaCloudSessionMock, + revokeOrcaCloudSession: vi.fn(), + selectOrcaCloudOrg: selectOrcaCloudOrgMock +})) + +import { + connectCurrentOrcaProfile, + createCloudLinkedOrcaProfile, + getCurrentOrcaProfileAuthStatus, + refreshCurrentOrcaProfileAuth, + selectCurrentOrcaProfileOrg +} from './profile-cloud-service' + +const cloudSummary: OrcaProfileCloudSummary = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + displayName: 'Nina', + linkedAt: 10 +} + +const capabilities: OrcaCloudCapabilities = { + flags: { share: true }, + refreshedAt: 11 +} + +const organizations: OrcaCloudOrgSummary[] = [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } +] + +function futureExpiresAt(): number { + return Date.now() + 3_600_000 +} + +function configureCloudEnv(): void { + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client') +} + +function mockSuccessfulConnect(): void { + beginOrcaCloudPkceFlowMock.mockResolvedValue({ + code: 'auth-code', + codeVerifier: 'code-verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state' + }) + exchangeOrcaCloudAuthCodeMock.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: futureExpiresAt(), + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) +} + +function mockSuccessfulSessionRefresh(): void { + refreshOrcaCloudSessionMock.mockResolvedValue({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token', + expiresAt: futureExpiresAt(), + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) +} + +describe('Orca cloud profile auth-failure retry', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-auth-retry-')) + beginOrcaCloudPkceFlowMock.mockReset() + createOrcaCloudProfileMock.mockReset() + exchangeOrcaCloudAuthCodeMock.mockReset() + refreshOrcaCloudCapabilitiesMock.mockReset() + refreshOrcaCloudSessionMock.mockReset() + selectOrcaCloudOrgMock.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) + safeStorageMock.encryptString.mockImplementation((value: string) => + Buffer.from(value, 'utf-8') + ) + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('refreshes and retries cloud profile creation after an auth failure', async () => { + configureCloudEnv() + mockSuccessfulConnect() + mockSuccessfulSessionRefresh() + await connectCurrentOrcaProfile(userDataPath) + createOrcaCloudProfileMock + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401)) + .mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: futureExpiresAt(), + cloud: { ...cloudSummary, cloudProfileId: 'cloud-profile-2' }, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) + + const result = await createCloudLinkedOrcaProfile(userDataPath, { name: 'Acme' }) + + expect(result.status).toBe('created') + expect(createOrcaCloudProfileMock).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + expect.objectContaining({ accessToken: 'rotated-access-token' }), + { name: 'Acme' } + ) + }) + + it('refreshes and retries capability refresh after an auth failure', async () => { + configureCloudEnv() + mockSuccessfulConnect() + mockSuccessfulSessionRefresh() + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudCapabilitiesMock + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(403)) + .mockResolvedValue({ + capabilities: { flags: { share: false }, refreshedAt: 26 } satisfies OrcaCloudCapabilities + }) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + + expect(result.status).toBe('refreshed') + expect(refreshOrcaCloudCapabilitiesMock).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + expect.objectContaining({ accessToken: 'rotated-access-token' }) + ) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toEqual({ + flags: { share: false }, + refreshedAt: 26 + }) + }) + + it('requires reconnect when a retried capability refresh is still unauthorized', async () => { + configureCloudEnv() + mockSuccessfulConnect() + mockSuccessfulSessionRefresh() + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudCapabilitiesMock + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401)) + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401)) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + + expect(result.status).toBe('reconnect-required') + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'reconnect-required', + persistence: 'none', + cloud: cloudSummary + }) + }) + + it('refreshes and retries organization selection after an auth failure', async () => { + configureCloudEnv() + mockSuccessfulConnect() + mockSuccessfulSessionRefresh() + await connectCurrentOrcaProfile(userDataPath) + selectOrcaCloudOrgMock + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401)) + .mockResolvedValue({ + cloud: { ...cloudSummary, activeOrgId: 'org-1', activeOrgName: 'Acme' }, + organizations, + capabilities + }) + + const result = await selectCurrentOrcaProfileOrg(userDataPath, 'org-1') + + expect(result.status).toBe('selected') + expect(selectOrcaCloudOrgMock).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + expect.objectContaining({ accessToken: 'rotated-access-token' }), + 'org-1' + ) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-service-refresh.test.ts b/src/main/orca-profiles/profile-cloud-service-refresh.test.ts new file mode 100644 index 00000000000..e2e8fcbc1d4 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service-refresh.test.ts @@ -0,0 +1,256 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +const { + beginOrcaCloudPkceFlowMock, + createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilitiesMock, + refreshOrcaCloudSessionMock, + OrcaCloudRequestErrorMock, + safeStorageMock +} = vi.hoisted(() => ({ + beginOrcaCloudPkceFlowMock: vi.fn(), + createOrcaCloudProfileMock: vi.fn(), + exchangeOrcaCloudAuthCodeMock: vi.fn(), + refreshOrcaCloudCapabilitiesMock: vi.fn(), + refreshOrcaCloudSessionMock: vi.fn(), + OrcaCloudRequestErrorMock: class OrcaCloudRequestError extends Error { + constructor(public readonly statusCode: number) { + super(`orca_cloud_request_failed_${statusCode}`) + this.name = 'OrcaCloudRequestError' + } + }, + safeStorageMock: { + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) + } +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +vi.mock('./profile-cloud-pkce', () => ({ + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock +})) + +vi.mock('./profile-cloud-client', () => ({ + OrcaCloudRequestError: OrcaCloudRequestErrorMock, + createOrcaCloudProfile: createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilities: refreshOrcaCloudCapabilitiesMock, + refreshOrcaCloudSession: refreshOrcaCloudSessionMock, + revokeOrcaCloudSession: vi.fn(), + selectOrcaCloudOrg: vi.fn() +})) + +import { + connectCurrentOrcaProfile, + createCloudLinkedOrcaProfile, + getCurrentOrcaProfileAuthStatus, + refreshCurrentOrcaProfileAuth +} from './profile-cloud-service' + +const cloudSummary: OrcaProfileCloudSummary = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + displayName: 'Nina', + linkedAt: 10 +} + +const capabilities: OrcaCloudCapabilities = { + flags: { share: true }, + refreshedAt: 11 +} + +const organizations: OrcaCloudOrgSummary[] = [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } +] + +function futureExpiresAt(): number { + return Date.now() + 3_600_000 +} + +function configureCloudEnv(): void { + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client') +} + +function mockSuccessfulConnect(expiresAt = futureExpiresAt()): void { + beginOrcaCloudPkceFlowMock.mockResolvedValue({ + code: 'auth-code', + codeVerifier: 'code-verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state' + }) + exchangeOrcaCloudAuthCodeMock.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt, + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) +} + +describe('Orca cloud profile service session refresh', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-refresh-')) + beginOrcaCloudPkceFlowMock.mockReset() + createOrcaCloudProfileMock.mockReset() + exchangeOrcaCloudAuthCodeMock.mockReset() + refreshOrcaCloudCapabilitiesMock.mockReset() + refreshOrcaCloudSessionMock.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) + safeStorageMock.encryptString.mockImplementation((value: string) => + Buffer.from(value, 'utf-8') + ) + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('refreshes an expired access token before creating cloud profiles', async () => { + configureCloudEnv() + mockSuccessfulConnect(Date.now() - 1_000) + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudSessionMock.mockResolvedValue({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token', + expiresAt: futureExpiresAt(), + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) + createOrcaCloudProfileMock.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: futureExpiresAt(), + cloud: { + ...cloudSummary, + cloudProfileId: 'cloud-profile-2', + activeOrgId: 'org-1', + activeOrgName: 'Acme' + }, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) + + const result = await createCloudLinkedOrcaProfile(userDataPath, { + orgId: 'org-1', + name: 'Acme' + }) + + expect(result.status).toBe('created') + expect(refreshOrcaCloudSessionMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ refreshToken: 'refresh-token' }) + ) + expect(createOrcaCloudProfileMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ accessToken: 'rotated-access-token' }), + { orgId: 'org-1', name: 'Acme' } + ) + }) + + it('refreshes capability flags for the connected profile', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudCapabilitiesMock.mockResolvedValue({ + capabilities: { + flags: { share: false, team: true }, + refreshedAt: 25 + } + }) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + + expect(result.status).toBe('refreshed') + expect(refreshOrcaCloudCapabilitiesMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ accessToken: 'access-token' }) + ) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toEqual({ + flags: { share: false, team: true }, + refreshedAt: 25 + }) + }) + + it('clears stale active org metadata when capability refresh returns no active org', async () => { + configureCloudEnv() + mockSuccessfulConnect() + exchangeOrcaCloudAuthCodeMock.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: futureExpiresAt(), + cloud: { ...cloudSummary, activeOrgId: 'org-1', activeOrgName: 'Acme' }, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudCapabilitiesMock.mockResolvedValue({ + cloud: cloudSummary, + organizations: [], + capabilities: { + flags: { share: false }, + refreshedAt: 31 + } + }) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + const status = getCurrentOrcaProfileAuthStatus(userDataPath) + + expect(result.status).toBe('refreshed') + expect(status.cloud?.activeOrgId).toBeUndefined() + expect(status.cloud?.activeOrgName).toBeUndefined() + expect(status.organizations).toEqual([]) + expect(status.capabilities).toEqual({ + flags: { share: false }, + refreshedAt: 31 + }) + }) + + it('requires reconnect when an expired refresh token is rejected', async () => { + configureCloudEnv() + mockSuccessfulConnect(Date.now() - 1_000) + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudSessionMock.mockRejectedValue(new OrcaCloudRequestErrorMock(401)) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + + expect(result.status).toBe('reconnect-required') + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'reconnect-required', + persistence: 'none', + cloud: cloudSummary + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-service.test.ts b/src/main/orca-profiles/profile-cloud-service.test.ts new file mode 100644 index 00000000000..92e0064c2f5 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +const { + beginOrcaCloudPkceFlowMock, + createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCodeMock, + revokeOrcaCloudSessionMock, + selectOrcaCloudOrgMock, + safeStorageMock +} = vi.hoisted(() => ({ + beginOrcaCloudPkceFlowMock: vi.fn(), + createOrcaCloudProfileMock: vi.fn(), + exchangeOrcaCloudAuthCodeMock: vi.fn(), + revokeOrcaCloudSessionMock: vi.fn(), + selectOrcaCloudOrgMock: vi.fn(), + safeStorageMock: { + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) + } +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +vi.mock('./profile-cloud-pkce', () => ({ + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock +})) + +vi.mock('./profile-cloud-client', () => ({ + createOrcaCloudProfile: createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock, + revokeOrcaCloudSession: revokeOrcaCloudSessionMock, + selectOrcaCloudOrg: selectOrcaCloudOrgMock +})) + +import { + connectCurrentOrcaProfile, + createCloudLinkedOrcaProfile, + getCurrentOrcaProfileAuthStatus, + selectCurrentOrcaProfileOrg, + signOutCurrentOrcaProfile +} from './profile-cloud-service' + +const cloudSummary: OrcaProfileCloudSummary = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + displayName: 'Nina', + linkedAt: 10 +} + +const capabilities: OrcaCloudCapabilities = { + flags: { share: true }, + refreshedAt: 11 +} + +const organizations: OrcaCloudOrgSummary[] = [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } +] + +function configureCloudEnv(): void { + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client') +} + +function futureExpiresAt(): number { + return Date.now() + 3_600_000 +} + +function mockSuccessfulConnect(expiresAt = futureExpiresAt()): void { + beginOrcaCloudPkceFlowMock.mockResolvedValue({ + code: 'auth-code', + codeVerifier: 'code-verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state' + }) + exchangeOrcaCloudAuthCodeMock.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt, + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) +} + +describe('Orca cloud profile service', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-')) + beginOrcaCloudPkceFlowMock.mockReset() + createOrcaCloudProfileMock.mockReset() + exchangeOrcaCloudAuthCodeMock.mockReset() + revokeOrcaCloudSessionMock.mockReset() + selectOrcaCloudOrgMock.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) + safeStorageMock.encryptString.mockImplementation((value: string) => + Buffer.from(value, 'utf-8') + ) + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + revokeOrcaCloudSessionMock.mockResolvedValue(undefined) + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('reports local unconfigured auth without cloud setup', () => { + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' + }) + }) + + it('connects the active local profile without replacing its local profile ID', async () => { + configureCloudEnv() + mockSuccessfulConnect() + + const result = await connectCurrentOrcaProfile(userDataPath) + + if (result.status !== 'connected') { + throw new Error(`Expected connected result, got ${result.status}`) + } + expect(result.activeProfileId).toBe('local-default') + expect(result.profiles[0]).toMatchObject({ + id: 'local-default', + kind: 'cloud-linked', + cloud: cloudSummary + }) + expect(exchangeOrcaCloudAuthCodeMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ localProfileId: 'local-default', nonce: 'nonce' }) + ) + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'connected', + persistence: 'encrypted', + cloud: cloudSummary, + organizations, + capabilities + }) + }) + + it('treats provider-denied sign-in as a cancelled connect attempt', async () => { + configureCloudEnv() + beginOrcaCloudPkceFlowMock.mockRejectedValue(new Error('orca_cloud_auth_denied')) + + const result = await connectCurrentOrcaProfile(userDataPath) + + expect(result.status).toBe('cancelled') + expect(exchangeOrcaCloudAuthCodeMock).not.toHaveBeenCalled() + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'local', + persistence: 'none' + }) + }) + + it('does not report a saved cloud session as connected when cloud config is unavailable', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + configured: false, + state: 'unconfigured', + persistence: 'encrypted', + cloud: cloudSummary, + setupMessage: 'Orca Cloud sign-in is not configured for this build.' + }) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toBeUndefined() + expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toBeUndefined() + }) + + it('signs out by removing cloud metadata while keeping the local profile', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + + const result = await signOutCurrentOrcaProfile(userDataPath) + + expect(result.status).toBe('signed-out') + expect(result.activeProfileId).toBe('local-default') + expect(result.profiles[0]).toMatchObject({ id: 'local-default', kind: 'local' }) + expect(result.profiles[0]?.cloud).toBeUndefined() + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'local', + persistence: 'none' + }) + expect(revokeOrcaCloudSessionMock).toHaveBeenCalledOnce() + }) + + it('creates a new empty cloud-linked profile with its own cloud session', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + createOrcaCloudProfileMock.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: 1000, + cloud: { + ...cloudSummary, + cloudProfileId: 'cloud-profile-2', + activeOrgId: 'org-1', + activeOrgName: 'Acme' + }, + organizations, + capabilities: { flags: { share: true, team: true }, refreshedAt: 13 } + } satisfies OrcaCloudSessionExchangeResponse) + + const result = await createCloudLinkedOrcaProfile(userDataPath, { + orgId: 'org-1', + name: 'Acme' + }) + + if (result.status !== 'created') { + throw new Error(`Expected created result, got ${result.status}`) + } + expect(result.profile).toMatchObject({ + id: expect.stringMatching(/^cloud-/), + name: 'Acme', + kind: 'cloud-linked', + cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-2' }) + }) + expect(createOrcaCloudProfileMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ accessToken: 'access-token' }), + { orgId: 'org-1', name: 'Acme' } + ) + }) + + it('selects an organization for a connected profile', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + const orgCloudSummary = { + ...cloudSummary, + activeOrgId: 'org-1', + activeOrgName: 'Acme' + } + selectOrcaCloudOrgMock.mockResolvedValue({ + cloud: orgCloudSummary, + organizations, + capabilities: { flags: { share: true, sso: true }, refreshedAt: 12 } + }) + + const result = await selectCurrentOrcaProfileOrg(userDataPath, 'org-1') + + expect(result.status).toBe('selected') + expect(selectOrcaCloudOrgMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ accessToken: 'access-token' }), + 'org-1' + ) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud).toMatchObject({ + activeOrgId: 'org-1', + activeOrgName: 'Acme' + }) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toEqual(organizations) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-service.ts b/src/main/orca-profiles/profile-cloud-service.ts new file mode 100644 index 00000000000..df735a81220 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service.ts @@ -0,0 +1,300 @@ +import type { + ConnectCurrentOrcaProfileResult, + CreateCloudLinkedOrcaProfileArgs, + CreateCloudLinkedOrcaProfileResult, + OrcaProfileAuthStatus, + RefreshCurrentOrcaProfileAuthResult, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult +} from '../../shared/orca-profiles' +import { ensureActiveOrcaProfile, getOrcaProfileListState } from './profile-index-store' +import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config' +import { + clearOrcaCloudSession, + readOrcaCloudSession, + saveOrcaCloudSession, + saveOrcaCloudSessionExchange +} from './profile-cloud-session-store' +import { + createOrcaCloudProfile, + exchangeOrcaCloudAuthCode, + refreshOrcaCloudCapabilities, + revokeOrcaCloudSession, + selectOrcaCloudOrg +} from './profile-cloud-client' +import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce' +import { + createCloudLinkedOrcaProfileRecord, + linkOrcaProfileToCloud, + unlinkOrcaProfileFromCloud +} from './profile-cloud-index' +import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh' +import { + connectDevOrcaCloudProfile, + createDevCloudLinkedOrcaProfile, + refreshDevOrcaCloudProfile, + selectDevOrcaCloudOrg +} from './profile-cloud-dev-service' +import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status' + +function isUserCancelledAuthError(message: string): boolean { + return message === 'orca_cloud_auth_timeout' || message === 'orca_cloud_auth_denied' +} + +function activeAuth( + active: ReturnType, + userDataPath: string +): OrcaProfileAuthStatus { + return getOrcaProfileAuthStatusFromProfile(active, userDataPath) +} + +export function getCurrentOrcaProfileAuthStatus(userDataPath: string): OrcaProfileAuthStatus { + return getOrcaProfileAuthStatusFromProfile(ensureActiveOrcaProfile(userDataPath), userDataPath) +} + +export async function connectCurrentOrcaProfile( + userDataPath: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + const list = connectDevOrcaCloudProfile(active, userDataPath) + return { + status: 'connected', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } + } + + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { + status: 'unconfigured', + auth: activeAuth(active, userDataPath) + } + } + + try { + const code = await beginOrcaCloudPkceFlow(configState.config, active.profile.id) + const exchange = await exchangeOrcaCloudAuthCode(configState.config, { + ...code, + localProfileId: active.profile.id + }) + saveOrcaCloudSessionExchange(active.profile.id, userDataPath, exchange) + const list = linkOrcaProfileToCloud(active.profile.id, exchange.cloud, userDataPath) + return { + status: 'connected', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (isUserCancelledAuthError(message)) { + return { + status: 'cancelled', + auth: getCurrentOrcaProfileAuthStatus(userDataPath) + } + } + return { + status: 'failed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + error: message + } + } +} + +export async function signOutCurrentOrcaProfile( + userDataPath: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + const configState = getOrcaCloudAuthConfig() + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (!isOrcaCloudDevAuthEnabled() && configState.configured && session.status === 'found') { + await revokeOrcaCloudSession(configState.config, session.session).catch(() => undefined) + } + clearOrcaCloudSession(active.profile.id, userDataPath) + const list = unlinkOrcaProfileFromCloud(active.profile.id, userDataPath) + return { + status: 'signed-out', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } +} + +export async function createCloudLinkedOrcaProfile( + userDataPath: string, + args: CreateCloudLinkedOrcaProfileArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + const result = createDevCloudLinkedOrcaProfile(active, userDataPath, args) + if (result.status !== 'created') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + return { + status: 'created', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: result.list.activeProfileId, + profiles: result.list.profiles, + profile: result.list.profile + } + } + + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured', auth: activeAuth(active, userDataPath) } + } + try { + const operation = await runWithFreshOrcaCloudSession( + configState.config, + active, + userDataPath, + (session) => createOrcaCloudProfile(configState.config, session, args) + ) + if (operation.status !== 'ok') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + const created = operation.value + const list = createCloudLinkedOrcaProfileRecord( + created.cloud, + { name: args.name }, + userDataPath + ) + saveOrcaCloudSessionExchange(list.profile.id, userDataPath, created) + return { + status: 'created', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles, + profile: list.profile + } + } catch (error) { + return { + status: 'failed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + error: error instanceof Error ? error.message : String(error) + } + } +} + +export async function refreshCurrentOrcaProfileAuth( + userDataPath: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (!active.profile.cloud) { + return { status: 'local', auth: activeAuth(active, userDataPath) } + } + if (isOrcaCloudDevAuthEnabled()) { + const result = refreshDevOrcaCloudProfile(active, userDataPath) + if (result.status !== 'updated') { + return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) } + } + return { + status: 'refreshed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: result.list.activeProfileId, + profiles: result.list.profiles + } + } + + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured', auth: activeAuth(active, userDataPath) } + } + try { + const operation = await runWithFreshOrcaCloudSession( + configState.config, + active, + userDataPath, + (session) => refreshOrcaCloudCapabilities(configState.config, session) + ) + if (operation.status !== 'ok') { + return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) } + } + const refresh = operation.value + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (session.status !== 'found') { + return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) } + } + saveOrcaCloudSession(active.profile.id, userDataPath, { + ...session.session, + organizations: refresh.organizations ?? session.session.organizations, + capabilities: refresh.capabilities + }) + const list = refresh.cloud + ? linkOrcaProfileToCloud(active.profile.id, refresh.cloud, userDataPath) + : getOrcaProfileListState(userDataPath) + return { + status: 'refreshed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } + } catch (error) { + return { + status: 'failed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + error: error instanceof Error ? error.message : String(error) + } + } +} + +export async function selectCurrentOrcaProfileOrg( + userDataPath: string, + orgId: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + const result = selectDevOrcaCloudOrg(active, userDataPath, orgId) + if (result.status !== 'updated') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + return { + status: 'selected', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: result.list.activeProfileId, + profiles: result.list.profiles + } + } + + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured', auth: activeAuth(active, userDataPath) } + } + try { + const operation = await runWithFreshOrcaCloudSession( + configState.config, + active, + userDataPath, + (session) => selectOrcaCloudOrg(configState.config, session, orgId) + ) + if (operation.status !== 'ok') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + const selected = operation.value + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (session.status !== 'found') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + saveOrcaCloudSession(active.profile.id, userDataPath, { + ...session.session, + organizations: selected.organizations ?? session.session.organizations, + capabilities: selected.capabilities + }) + const list = linkOrcaProfileToCloud(active.profile.id, selected.cloud, userDataPath) + return { + status: 'selected', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } + } catch (error) { + return { + status: 'failed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + error: error instanceof Error ? error.message : String(error) + } + } +} diff --git a/src/main/orca-profiles/profile-cloud-session-exchange.ts b/src/main/orca-profiles/profile-cloud-session-exchange.ts new file mode 100644 index 00000000000..d3dff43816f --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-exchange.ts @@ -0,0 +1,14 @@ +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' + +export type OrcaCloudSessionExchangeResponse = { + accessToken: string + refreshToken: string + expiresAt: number + cloud: OrcaProfileCloudSummary + organizations?: OrcaCloudOrgSummary[] + capabilities: OrcaCloudCapabilities +} diff --git a/src/main/orca-profiles/profile-cloud-session-refresh.ts b/src/main/orca-profiles/profile-cloud-session-refresh.ts new file mode 100644 index 00000000000..404cba47bd4 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-refresh.ts @@ -0,0 +1,178 @@ +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { ActiveOrcaProfileState } from './profile-index-store' +import { + clearOrcaCloudSession, + type OrcaCloudSession, + readOrcaCloudSession, + saveOrcaCloudSession +} from './profile-cloud-session-store' +import { OrcaCloudRequestError, refreshOrcaCloudSession } from './profile-cloud-client' +import { linkOrcaProfileToCloud } from './profile-cloud-index' + +const CLOUD_SESSION_REFRESH_SKEW_MS = 60_000 + +export type FreshCloudSessionResult = + | { status: 'found'; session: OrcaCloudSession } + | { status: 'reconnect-required' } + +export type CloudSessionOperationResult = + | { status: 'ok'; value: T } + | { status: 'reconnect-required' } + +function shouldRefreshCloudSession(session: OrcaCloudSession, now = Date.now()): boolean { + return session.expiresAt <= now + CLOUD_SESSION_REFRESH_SKEW_MS +} + +export function isOrcaCloudAuthFailure(error: unknown): boolean { + return ( + error instanceof OrcaCloudRequestError && (error.statusCode === 401 || error.statusCode === 403) + ) +} + +const inflightCloudSessionRefreshes = new Map>() + +function cloudSessionRefreshKey(profileId: string, userDataPath: string): string { + return `${userDataPath}\0${profileId}` +} + +// Why: with refresh-token rotation, only the session that actually failed may +// clear the store; otherwise a loser of a concurrent refresh race would wipe +// the winner's freshly rotated session. +function clearCloudSessionIfUnchanged( + profileId: string, + userDataPath: string, + failed: OrcaCloudSession +): void { + const current = readOrcaCloudSession(profileId, userDataPath) + if (current.status === 'found' && current.session.refreshToken !== failed.refreshToken) { + return + } + clearOrcaCloudSession(profileId, userDataPath) +} + +async function refreshStoredCloudSession( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string, + session: OrcaCloudSession +): Promise { + // 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) + } +} + +export async function readFreshOrcaCloudSession( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string +): Promise { + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (session.status !== 'found') { + return { status: 'reconnect-required' } + } + if (!shouldRefreshCloudSession(session.session)) { + return { status: 'found', session: session.session } + } + try { + return { + status: 'found', + session: await refreshStoredCloudSession(config, active, userDataPath, session.session) + } + } catch (error) { + if (isOrcaCloudAuthFailure(error)) { + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session.session) + return { status: 'reconnect-required' } + } + throw error + } +} + +export async function forceRefreshOrcaCloudSession( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string, + session: OrcaCloudSession +): Promise { + try { + return { + status: 'found', + session: await refreshStoredCloudSession(config, active, userDataPath, session) + } + } catch (error) { + if (isOrcaCloudAuthFailure(error)) { + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session) + return { status: 'reconnect-required' } + } + throw error + } +} + +export async function runWithFreshOrcaCloudSession( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string, + operation: (session: OrcaCloudSession) => Promise +): Promise> { + const session = await readFreshOrcaCloudSession(config, active, userDataPath) + if (session.status !== 'found') { + return { status: 'reconnect-required' } + } + try { + return { status: 'ok', value: await operation(session.session) } + } catch (error) { + if (!isOrcaCloudAuthFailure(error)) { + throw error + } + const refreshed = await forceRefreshOrcaCloudSession( + config, + active, + userDataPath, + session.session + ) + if (refreshed.status !== 'found') { + return { status: 'reconnect-required' } + } + try { + return { status: 'ok', value: await operation(refreshed.session) } + } catch (retryError) { + // Why: a 401 after a successful refresh means the session itself is + // rejected. A 403 is an authorization (permission) failure — signing + // the user out for it would destroy a valid session, so let it surface + // as a failed operation instead. + if (retryError instanceof OrcaCloudRequestError && retryError.statusCode === 401) { + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, refreshed.session) + return { status: 'reconnect-required' } + } + throw retryError + } + } +} diff --git a/src/main/orca-profiles/profile-cloud-session-store.test.ts b/src/main/orca-profiles/profile-cloud-session-store.test.ts new file mode 100644 index 00000000000..ca32f0529f1 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-store.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { OrcaCloudSession } from './profile-cloud-session-store' + +const safeStorageMock = vi.hoisted(() => ({ + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +async function loadSessionStore() { + vi.resetModules() + return import('./profile-cloud-session-store') +} + +function makeSession(): OrcaCloudSession { + return { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 9_999, + organizations: [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } + ], + capabilities: { + flags: { share: true }, + refreshedAt: 123 + } + } +} + +function writePlaintextSessionFile(profileId: string, session: OrcaCloudSession): void { + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(profileDirectory, 'account-session.json.enc'), + JSON.stringify( + { + version: 1, + format: 'dev-plaintext-v1', + savedAt: 1, + session + }, + null, + 2 + ), + 'utf-8' + ) +} + +describe('Orca cloud session store', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-')) + vi.unstubAllEnvs() + safeStorageMock.decryptString.mockClear() + safeStorageMock.encryptString.mockClear() + safeStorageMock.isEncryptionAvailable.mockClear() + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('persists encrypted sessions and reports encrypted persistence from memory and disk', async () => { + const store = await loadSessionStore() + const session = makeSession() + + expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('encrypted') + expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'found', + session, + persistence: 'encrypted' + }) + + const reloaded = await loadSessionStore() + expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'found', + session, + persistence: 'encrypted' + }) + }) + + it('falls back to memory-only when encryption is unavailable and plaintext is not allowed', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const store = await loadSessionStore() + const session = makeSession() + + expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('memory-only') + expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'found', + session, + persistence: 'memory-only' + }) + + const reloaded = await loadSessionStore() + expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'missing', + persistence: 'none' + }) + }) + + it('scopes memory-only sessions by user-data path and profile ID', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const otherUserDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-other-')) + const store = await loadSessionStore() + const session = makeSession() + const otherSession = { ...session, accessToken: 'other-access-token' } + + try { + store.saveOrcaCloudSession('local-default', userDataPath, session) + store.saveOrcaCloudSession('local-default', otherUserDataPath, otherSession) + + expect(store.readOrcaCloudSession('local-default', userDataPath)).toMatchObject({ + status: 'found', + session + }) + expect(store.readOrcaCloudSession('local-default', otherUserDataPath)).toMatchObject({ + status: 'found', + session: otherSession + }) + } finally { + rmSync(otherUserDataPath, { recursive: true, force: true }) + } + }) + + it('writes explicit dev plaintext only when the dev escape hatch is enabled', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + vi.stubEnv('ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION', '1') + vi.stubEnv('NODE_ENV', 'development') + const store = await loadSessionStore() + const session = makeSession() + + expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('dev-plaintext') + + const saved = JSON.parse( + readFileSync(store.getOrcaCloudSessionPath('profile-1', userDataPath), 'utf-8') + ) as { format: string } + expect(saved.format).toBe('dev-plaintext-v1') + + const reloaded = await loadSessionStore() + expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'found', + session, + persistence: 'dev-plaintext' + }) + }) + + it('rejects dev plaintext files when the escape hatch is disabled', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + writePlaintextSessionFile('profile-1', makeSession()) + const store = await loadSessionStore() + + expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'decrypt-failed', + persistence: 'none', + error: 'Unsafe session format.' + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-session-store.ts b/src/main/orca-profiles/profile-cloud-session-store.ts new file mode 100644 index 00000000000..a8914b8f7a3 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-store.ts @@ -0,0 +1,209 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { safeStorage } from 'electron' +import { writeSecureJsonFile } from '../../shared/secure-file' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaCloudSessionPersistence +} from '../../shared/orca-profiles' +import { getOrcaProfileDirectory } from './profile-storage-paths' +import { allowsPlaintextOrcaCloudSession } from './profile-cloud-auth-config' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +export type OrcaCloudSession = { + accessToken: string + refreshToken: string + expiresAt: number + capabilities: OrcaCloudCapabilities + organizations?: OrcaCloudOrgSummary[] +} + +export type OrcaCloudSessionReadResult = + | { status: 'found'; session: OrcaCloudSession; persistence: OrcaCloudSessionPersistence } + | { status: 'missing'; persistence: 'none' } + | { status: 'decrypt-failed'; persistence: 'none'; error: string } + +type PersistedEncryptedSession = { + version: 1 + format: 'electron-safe-storage-v1' + savedAt: number + ciphertext: string +} + +type PersistedPlaintextSession = { + version: 1 + format: 'dev-plaintext-v1' + savedAt: number + session: OrcaCloudSession +} + +type CachedOrcaCloudSession = { + session: OrcaCloudSession + persistence: Exclude +} + +const memorySessions = new Map() + +function sessionCacheKey(profileId: string, userDataPath: string): string { + return `${userDataPath}\0${profileId}` +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isOrcaCloudSession(value: unknown): value is OrcaCloudSession { + if (!isObject(value) || !isObject(value.capabilities) || !isObject(value.capabilities.flags)) { + return false + } + if (value.organizations !== undefined && !isOrcaCloudOrganizations(value.organizations)) { + return false + } + return ( + typeof value.accessToken === 'string' && + value.accessToken.length > 0 && + typeof value.refreshToken === 'string' && + value.refreshToken.length > 0 && + typeof value.expiresAt === 'number' && + Number.isFinite(value.expiresAt) && + typeof value.capabilities.refreshedAt === 'number' && + Number.isFinite(value.capabilities.refreshedAt) + ) +} + +function isOrcaCloudOrganizations(value: unknown): value is OrcaCloudOrgSummary[] { + if (!Array.isArray(value)) { + return false + } + return value.every((organization) => { + if (!isObject(organization)) { + return false + } + return ( + typeof organization.orgId === 'string' && + organization.orgId.length > 0 && + typeof organization.name === 'string' && + organization.name.length > 0 && + (organization.role === undefined || typeof organization.role === 'string') + ) + }) +} + +export function getOrcaCloudSessionPath(profileId: string, userDataPath: string): string { + return join(getOrcaProfileDirectory(profileId, userDataPath), 'account-session.json.enc') +} + +export function saveOrcaCloudSession( + profileId: string, + userDataPath: string, + session: OrcaCloudSession +): OrcaCloudSessionPersistence { + const cacheKey = sessionCacheKey(profileId, userDataPath) + if (safeStorage.isEncryptionAvailable()) { + const encrypted: PersistedEncryptedSession = { + version: 1, + format: 'electron-safe-storage-v1', + savedAt: Date.now(), + ciphertext: safeStorage.encryptString(JSON.stringify(session)).toString('base64') + } + writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), encrypted) + memorySessions.set(cacheKey, { session, persistence: 'encrypted' }) + return 'encrypted' + } + + if (allowsPlaintextOrcaCloudSession()) { + const plaintext: PersistedPlaintextSession = { + version: 1, + format: 'dev-plaintext-v1', + savedAt: Date.now(), + session + } + writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), plaintext) + memorySessions.set(cacheKey, { session, persistence: 'dev-plaintext' }) + return 'dev-plaintext' + } + + // Why: Orca account refresh tokens must not silently fall back to plaintext + // in production. Memory-only keeps cloud features usable until restart. + memorySessions.set(cacheKey, { session, persistence: 'memory-only' }) + return 'memory-only' +} + +export function saveOrcaCloudSessionExchange( + profileId: string, + userDataPath: string, + exchange: OrcaCloudSessionExchangeResponse +): OrcaCloudSessionPersistence { + return saveOrcaCloudSession(profileId, userDataPath, { + accessToken: exchange.accessToken, + refreshToken: exchange.refreshToken, + expiresAt: exchange.expiresAt, + organizations: exchange.organizations, + capabilities: exchange.capabilities + }) +} + +export function readOrcaCloudSession( + profileId: string, + userDataPath: string +): OrcaCloudSessionReadResult { + const cacheKey = sessionCacheKey(profileId, userDataPath) + const memorySession = memorySessions.get(cacheKey) + if (memorySession) { + return { + status: 'found', + session: memorySession.session, + persistence: memorySession.persistence + } + } + + const path = getOrcaCloudSessionPath(profileId, userDataPath) + if (!existsSync(path)) { + return { status: 'missing', persistence: 'none' } + } + + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as + | PersistedEncryptedSession + | PersistedPlaintextSession + if (parsed.version !== 1) { + return { status: 'decrypt-failed', persistence: 'none', error: 'Unsupported session format.' } + } + if (parsed.format === 'electron-safe-storage-v1') { + if (!safeStorage.isEncryptionAvailable()) { + return { + status: 'decrypt-failed', + persistence: 'none', + error: 'OS-backed encryption is unavailable.' + } + } + const decrypted = safeStorage.decryptString(Buffer.from(parsed.ciphertext, 'base64')) + const session = JSON.parse(decrypted) as OrcaCloudSession + if (!isOrcaCloudSession(session)) { + return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' } + } + memorySessions.set(cacheKey, { session, persistence: 'encrypted' }) + return { status: 'found', session, persistence: 'encrypted' } + } + if (parsed.format === 'dev-plaintext-v1' && allowsPlaintextOrcaCloudSession()) { + if (!isOrcaCloudSession(parsed.session)) { + return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' } + } + memorySessions.set(cacheKey, { session: parsed.session, persistence: 'dev-plaintext' }) + return { status: 'found', session: parsed.session, persistence: 'dev-plaintext' } + } + return { status: 'decrypt-failed', persistence: 'none', error: 'Unsafe session format.' } + } catch { + return { + status: 'decrypt-failed', + persistence: 'none', + error: 'Could not decrypt saved Orca account session.' + } + } +} + +export function clearOrcaCloudSession(profileId: string, userDataPath: string): void { + memorySessions.delete(sessionCacheKey(profileId, userDataPath)) + rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true }) +} diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts new file mode 100644 index 00000000000..a2d3446a99c --- /dev/null +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + DEFAULT_LOCAL_ORCA_PROFILE_NAME, + ORCA_PROFILE_INDEX_SCHEMA_VERSION, + type OrcaProfileIndex +} from '../../shared/orca-profiles' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + } +})) + +async function loadProfileIndexStore() { + vi.resetModules() + return import('./profile-index-store') +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf-8')) +} + +describe('profile index store', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('creates the default local profile and copies legacy state without deleting it', async () => { + const legacyState = { schemaVersion: 1, repos: [{ id: 'repo-1' }] } + const legacyBackup = { schemaVersion: 1, repos: [{ id: 'backup-repo' }] } + const legacyBrowserSessionMeta = { + defaultSource: { browserFamily: 'chrome', importedAt: 1 }, + profiles: [] + } + writeFileSync(join(testState.dir, 'orca-data.json'), JSON.stringify(legacyState), 'utf-8') + writeFileSync( + join(testState.dir, 'orca-data.json.bak.0'), + JSON.stringify(legacyBackup), + 'utf-8' + ) + writeFileSync( + join(testState.dir, 'browser-session-meta.json'), + JSON.stringify(legacyBrowserSessionMeta), + 'utf-8' + ) + + const { ensureActiveOrcaProfile, getOrcaProfileIndexPath } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(activeProfile.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID) + expect(activeProfile.profile.name).toBe(DEFAULT_LOCAL_ORCA_PROFILE_NAME) + expect(activeProfile.dataFile).toBe( + join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') + ) + expect(readJson(activeProfile.dataFile)).toEqual(legacyState) + expect(readJson(`${activeProfile.dataFile}.bak.0`)).toEqual(legacyBackup) + expect( + readJson( + join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'browser-session-meta.json') + ) + ).toEqual(legacyBrowserSessionMeta) + expect(existsSync(join(testState.dir, 'orca-data.json'))).toBe(true) + + expect(readJson(getOrcaProfileIndexPath())).toMatchObject({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [expect.objectContaining({ id: DEFAULT_LOCAL_ORCA_PROFILE_ID, kind: 'local' })] + }) + }) + + it('uses an existing active profile data file without overwriting it from legacy state', async () => { + const profileId = 'work-profile' + const profileDirectory = join(testState.dir, 'profiles', profileId) + const profileData = { schemaVersion: 1, repos: [{ id: 'profile-repo' }] } + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync(join(profileDirectory, 'orca-data.json'), JSON.stringify(profileData), 'utf-8') + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ schemaVersion: 1, repos: [{ id: 'legacy-repo' }] }), + 'utf-8' + ) + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: profileId, + profiles: [ + { + id: profileId, + name: 'Work', + avatar: { kind: 'initials', initials: 'W', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] + } + writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8') + + const { ensureActiveOrcaProfile } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(activeProfile.profile.id).toBe(profileId) + expect(activeProfile.dataFile).toBe(join(profileDirectory, 'orca-data.json')) + expect(readJson(activeProfile.dataFile)).toEqual(profileData) + }) + + it('creates an empty local profile without copying legacy state into it', async () => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ schemaVersion: 1, repos: [{ id: 'legacy-repo' }] }), + 'utf-8' + ) + + const { createLocalOrcaProfile, getOrcaProfileDataFile, getOrcaProfileListState } = + await loadProfileIndexStore() + const created = createLocalOrcaProfile({ name: ' Work ' }) + + expect(created.profile.name).toBe('Work') + expect(created.profile.id).toMatch(/^local-/) + expect(created.activeProfileId).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID) + expect(created.profiles.map((profile) => profile.id)).toContain(created.profile.id) + expect(existsSync(getOrcaProfileDataFile(created.profile.id))).toBe(false) + expect(getOrcaProfileListState().profiles.map((profile) => profile.id)).toContain( + created.profile.id + ) + }) + + it('switches the active profile and updates last-opened metadata', async () => { + const { createLocalOrcaProfile, setActiveOrcaProfile } = await loadProfileIndexStore() + const created = createLocalOrcaProfile({ name: 'Work' }) + + const switched = setActiveOrcaProfile(created.profile.id) + + expect(switched.activeProfileId).toBe(created.profile.id) + expect(switched.profiles.find((profile) => profile.id === created.profile.id)).toMatchObject({ + id: created.profile.id, + lastOpenedAt: expect.any(Number) + }) + }) + + it('rejects switching to an unknown profile', async () => { + const { setActiveOrcaProfile } = await loadProfileIndexStore() + + expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile') + }) + + it('recovers a corrupted profile index from the backup copy', async () => { + const store = await loadProfileIndexStore() + store.ensureActiveOrcaProfile() + const created = store.createLocalOrcaProfile({ name: 'Work' }) + // Trigger one more write so the backup captures the two-profile index. + store.setActiveOrcaProfile(created.profile.id) + + const indexPath = store.getOrcaProfileIndexPath() + expect(existsSync(`${indexPath}.bak`)).toBe(true) + writeFileSync(indexPath, '{ not json', 'utf-8') + + const recovered = store.getOrcaProfileListState() + expect(recovered.profiles.map((profile) => profile.id)).toContain(created.profile.id) + expect(recovered.profiles.length).toBeGreaterThanOrEqual(2) + }) + + it('rejects profile ids that are not safe path segments', async () => { + const store = await loadProfileIndexStore() + const indexPath = store.getOrcaProfileIndexPath() + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: '../../escape', + profiles: [ + { + id: '../../escape', + name: 'Evil', + avatar: { kind: 'initials', initials: 'E', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] + } + mkdirSync(testState.dir, { recursive: true }) + writeFileSync(indexPath, JSON.stringify(index), 'utf-8') + + // The tampered entry is filtered; startup falls back to a fresh default. + const state = store.ensureActiveOrcaProfile() + expect(state.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID) + }) +}) diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts new file mode 100644 index 00000000000..903e7a85ff8 --- /dev/null +++ b/src/main/orca-profiles/profile-index-store.ts @@ -0,0 +1,327 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync +} 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, + DEFAULT_LOCAL_ORCA_PROFILE_NAME, + ORCA_PROFILE_INDEX_SCHEMA_VERSION, + type CreateLocalOrcaProfileArgs, + type CreateLocalOrcaProfileResult, + type OrcaProfileIndex, + type OrcaProfileListState, + type OrcaProfileSummary +} from '../../shared/orca-profiles' +import { + getOrcaProfileBrowserSessionMetaFile, + getOrcaProfileDataFile, + getOrcaProfileDirectory, + getOrcaProfileIndexPath, + getProfileUserDataPath, + LEGACY_BACKUP_COUNT, + legacyBackupPath, + legacyBrowserSessionMetaPath, + legacyDataFilePath, + profileBackupPath +} from './profile-storage-paths' + +export { + getOrcaProfileBrowserSessionMetaFile, + getOrcaProfileDataFile, + getOrcaProfileDirectory, + getOrcaProfileIndexPath, + getOrcaProfilesDirectory, + initOrcaProfilePaths +} from './profile-storage-paths' + +export type ActiveOrcaProfileState = { + index: OrcaProfileIndex + profile: OrcaProfileSummary + dataFile: string + profileDirectory: string +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isProfileSummary(value: unknown): value is OrcaProfileSummary { + if (!isObject(value)) { + return false + } + const avatar = value.avatar + const cloud = value.cloud + return ( + typeof value.id === 'string' && + // 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') && + typeof value.createdAt === 'number' && + typeof value.updatedAt === 'number' && + typeof value.lastOpenedAt === 'number' && + isObject(avatar) && + avatar.kind === 'initials' && + typeof avatar.initials === 'string' && + avatar.color === 'neutral' && + (cloud === undefined || isObject(cloud)) + ) +} + +function normalizeProfileIndex(raw: unknown): OrcaProfileIndex | null { + if (!isObject(raw) || !Array.isArray(raw.profiles)) { + return null + } + const profiles = raw.profiles.filter(isProfileSummary) + const activeProfileId = + typeof raw.activeProfileId === 'string' && + profiles.some((profile) => profile.id === raw.activeProfileId) + ? raw.activeProfileId + : profiles[0]?.id + if (!activeProfileId) { + return null + } + return { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId, + profiles + } +} + +function sanitizeProfileName(value: unknown): string { + const trimmed = typeof value === 'string' ? value.trim() : '' + return trimmed.length > 0 ? trimmed.slice(0, 80) : 'New Profile' +} + +function readProfileIndexFile(indexPath: string): OrcaProfileIndex | null { + try { + return normalizeProfileIndex(JSON.parse(readFileSync(indexPath, 'utf-8'))) + } catch { + return 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) +} + +function copyIfPresent(source: string, target: string): void { + if (!existsSync(source) || existsSync(target)) { + return + } + mkdirSync(dirname(target), { recursive: true }) + // 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 { + const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath) + copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile) + copyIfPresent( + legacyBrowserSessionMetaPath(userDataPath), + getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath) + ) + for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) { + copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i)) + } +} + +// 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 { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: profile.id, + profiles: [profile] + } +} + +export function loadOrCreateProfileIndex(userDataPath: string): OrcaProfileIndex { + const indexPath = getOrcaProfileIndexPath(userDataPath) + const index = existsSync(indexPath) ? readProfileIndex(indexPath) : null + if (index) { + return index + } + const nextIndex = createInitialProfileIndex() + writeProfileIndex(indexPath, nextIndex) + return nextIndex +} + +function getActiveProfile(index: OrcaProfileIndex): OrcaProfileSummary { + return ( + index.profiles.find((profile) => profile.id === index.activeProfileId) ?? + index.profiles[0] ?? + createDefaultLocalOrcaProfile(Date.now()) + ) +} + +export function ensureActiveOrcaProfile( + userDataPath = getProfileUserDataPath() +): ActiveOrcaProfileState { + const indexPath = getOrcaProfileIndexPath(userDataPath) + let index = existsSync(indexPath) ? readProfileIndex(indexPath) : null + let shouldWriteIndex = false + + if (!index) { + index = createInitialProfileIndex() + shouldWriteIndex = true + } + + const activeProfile = getActiveProfile(index) + if (activeProfile.id !== index.activeProfileId) { + index = { ...index, activeProfileId: activeProfile.id } + shouldWriteIndex = true + } + + const profileDirectory = getOrcaProfileDirectory(activeProfile.id, userDataPath) + mkdirSync(profileDirectory, { recursive: true }) + if (activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID) { + copyLegacyStateToProfile(userDataPath, activeProfile.id) + } + + if (shouldWriteIndex) { + writeProfileIndex(indexPath, index) + } + + return { + index, + profile: activeProfile, + dataFile: getOrcaProfileDataFile(activeProfile.id, userDataPath), + profileDirectory + } +} + +export function isDefaultLocalOrcaProfileId(profileId: string): boolean { + return profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID +} + +export function getOrcaProfileListState( + userDataPath = getProfileUserDataPath() +): OrcaProfileListState { + const { index } = ensureActiveOrcaProfile(userDataPath) + return { + activeProfileId: index.activeProfileId, + profiles: index.profiles + } +} + +export function createLocalOrcaProfile( + args: CreateLocalOrcaProfileArgs = {}, + userDataPath = getProfileUserDataPath() +): CreateLocalOrcaProfileResult { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + const name = sanitizeProfileName(args.name) + const profile: OrcaProfileSummary = { + id: `local-${randomUUID()}`, + name, + avatar: { + kind: 'initials', + initials: ( + name.match(/[A-Za-z0-9]/)?.[0] ?? DEFAULT_LOCAL_ORCA_PROFILE_NAME[0] + ).toUpperCase(), + color: 'neutral' + }, + kind: 'local', + createdAt: now, + updatedAt: now, + lastOpenedAt: now + } + const nextIndex: OrcaProfileIndex = { + ...index, + profiles: [...index.profiles, profile] + } + mkdirSync(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true }) + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles, + profile + } +} + +export function setActiveOrcaProfile( + profileId: string, + userDataPath = getProfileUserDataPath() +): OrcaProfileListState { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + let found = false + const profiles = index.profiles.map((profile) => { + if (profile.id !== profileId) { + return profile + } + found = true + return { + ...profile, + updatedAt: now, + lastOpenedAt: now + } + }) + if (!found) { + throw new Error('unknown_orca_profile') + } + const nextIndex: OrcaProfileIndex = { + ...index, + activeProfileId: profileId, + profiles + } + mkdirSync(getOrcaProfileDirectory(profileId, userDataPath), { recursive: true }) + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles + } +} diff --git a/src/main/orca-profiles/profile-project-presence.test.ts b/src/main/orca-profiles/profile-project-presence.test.ts new file mode 100644 index 00000000000..ebbc05b8e14 --- /dev/null +++ b/src/main/orca-profiles/profile-project-presence.test.ts @@ -0,0 +1,145 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { + ORCA_PROFILE_INDEX_SCHEMA_VERSION, + type OrcaProfileIndex, + type OrcaProfileKind +} from '../../shared/orca-profiles' +import type { PersistedState, Repo } from '../../shared/types' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + } +})) + +async function loadPresenceModule() { + vi.resetModules() + return import('./profile-project-presence') +} + +function profile( + id: string, + name: string, + kind: OrcaProfileKind = 'local' +): OrcaProfileIndex['profiles'][number] { + return { + id, + name, + avatar: { kind: 'initials', initials: name[0], color: 'neutral' }, + kind, + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } +} + +function writeIndex(activeProfileId = 'personal'): void { + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId, + profiles: [profile('personal', 'Personal'), profile('work', 'Work')] + } + writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8') +} + +function writeProfileState(profileId: string, repos: Repo[]): void { + const state: PersistedState = { + ...getDefaultPersistedState('/Users/tester'), + repos + } + const dataFile = join(testState.dir, 'profiles', profileId, 'orca-data.json') + mkdirSync(dirname(dataFile), { recursive: true }) + writeFileSync(dataFile, JSON.stringify(state), 'utf-8') +} + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/workspace/orca', + displayName: 'Orca', + badgeColor: '#33aa99', + addedAt: 100, + kind: 'git', + connectionId: null, + ...overrides + } +} + +describe('profile project presence', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-presence-')) + writeIndex() + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('finds matching projects in other profiles while excluding the active profile', async () => { + writeProfileState('personal', [ + makeRepo({ id: 'personal-repo', path: 'C:\\Code\\Orca', displayName: 'Personal Orca' }) + ]) + writeProfileState('work', [ + makeRepo({ id: 'work-repo', path: 'C:\\Code\\Orca', displayName: 'Work Orca' }) + ]) + + const { findOrcaProfileProjectsByPath } = await loadPresenceModule() + const result = findOrcaProfileProjectsByPath( + { + path: 'c:/code/orca/', + executionHostId: 'local', + excludeProfileId: 'personal' + }, + testState.dir + ) + + expect(result.projects).toEqual([ + { + profileId: 'work', + profileName: 'Work', + profileKind: 'local', + repoId: 'work-repo', + repoName: 'Work Orca' + } + ]) + }) + + it('keeps SSH projects separate from local projects with the same path', async () => { + writeProfileState('personal', [ + makeRepo({ id: 'local-repo', path: '/srv/orca', displayName: 'Local Orca' }) + ]) + writeProfileState('work', [ + makeRepo({ + id: 'ssh-repo', + path: '/srv/orca', + displayName: 'SSH Orca', + connectionId: 'builder', + executionHostId: 'ssh:builder' + }) + ]) + + const { findOrcaProfileProjectsByPath } = await loadPresenceModule() + const result = findOrcaProfileProjectsByPath( + { + path: '/srv/orca', + connectionId: 'builder', + executionHostId: 'ssh:builder' + }, + testState.dir + ) + + expect(result.projects).toEqual([ + expect.objectContaining({ + profileId: 'work', + repoId: 'ssh-repo', + repoName: 'SSH Orca' + }) + ]) + }) +}) diff --git a/src/main/orca-profiles/profile-project-presence.ts b/src/main/orca-profiles/profile-project-presence.ts new file mode 100644 index 00000000000..23f54884dee --- /dev/null +++ b/src/main/orca-profiles/profile-project-presence.ts @@ -0,0 +1,51 @@ +import type { + FindOrcaProfileProjectsByPathArgs, + FindOrcaProfileProjectsByPathResult +} from '../../shared/orca-profiles' +import { getOrcaProfileListState } from './profile-index-store' +import { readProfileState } from './profile-project-state-file' +import { repoPhysicalKey } from './profile-project-worktree-identity' + +function cleanOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +export function findOrcaProfileProjectsByPath( + args: FindOrcaProfileProjectsByPathArgs, + userDataPath: string +): FindOrcaProfileProjectsByPathResult { + const path = args.path.trim() + if (!path) { + throw new Error('invalid_orca_profile_project_path') + } + + const excludeProfileId = cleanOptionalString(args.excludeProfileId) + const candidateKey = repoPhysicalKey({ + path, + connectionId: cleanOptionalString(args.connectionId), + executionHostId: args.executionHostId ?? undefined + }) + const projects: FindOrcaProfileProjectsByPathResult['projects'] = [] + + for (const profile of getOrcaProfileListState(userDataPath).profiles) { + if (profile.id === excludeProfileId) { + continue + } + const state = readProfileState(profile.id, userDataPath) + for (const repo of state.repos) { + if (repoPhysicalKey(repo) !== candidateKey) { + continue + } + projects.push({ + profileId: profile.id, + profileName: profile.name, + profileKind: profile.kind, + repoId: repo.id, + repoName: repo.displayName || repo.path + }) + } + } + + return { projects } +} diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts new file mode 100644 index 00000000000..0d80880a19d --- /dev/null +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -0,0 +1,155 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { WorkspaceSessionState } from '../../shared/types' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { + isRepoWorktreeId, + ownerKeyBelongsToRepo, + removeRepoWorktreeRecord +} from './profile-project-worktree-identity' + +export function mergeHostWorkspaceSessions( + existing: Partial> | undefined, + incoming: Partial> +): Partial> { + const next: Partial> = { ...existing } + for (const [hostId, session] of Object.entries(incoming)) { + if (!session) { + continue + } + next[hostId as ExecutionHostId] = mergeWorkspaceSessions( + next[hostId as ExecutionHostId], + session + ) + } + return next +} + +export function mergeWorkspaceSessions( + existing: WorkspaceSessionState | undefined, + incoming: WorkspaceSessionState +): WorkspaceSessionState { + const base = existing ?? getDefaultWorkspaceSession() + return { + ...base, + tabsByWorktree: { ...base.tabsByWorktree, ...incoming.tabsByWorktree }, + terminalLayoutsByTabId: { + ...base.terminalLayoutsByTabId, + ...incoming.terminalLayoutsByTabId + }, + openFilesByWorktree: { ...base.openFilesByWorktree, ...incoming.openFilesByWorktree }, + browserTabsByWorktree: { + ...base.browserTabsByWorktree, + ...incoming.browserTabsByWorktree + }, + browserPagesByWorkspace: { + ...base.browserPagesByWorkspace, + ...incoming.browserPagesByWorkspace + }, + activeBrowserTabIdByWorktree: { + ...base.activeBrowserTabIdByWorktree, + ...incoming.activeBrowserTabIdByWorktree + }, + activeFileIdByWorktree: { + ...base.activeFileIdByWorktree, + ...incoming.activeFileIdByWorktree + }, + activeTabTypeByWorktree: { + ...base.activeTabTypeByWorktree, + ...incoming.activeTabTypeByWorktree + }, + activeTabIdByWorktree: { ...base.activeTabIdByWorktree, ...incoming.activeTabIdByWorktree }, + unifiedTabs: { ...base.unifiedTabs, ...incoming.unifiedTabs }, + tabGroups: { ...base.tabGroups, ...incoming.tabGroups }, + tabGroupLayouts: { ...base.tabGroupLayouts, ...incoming.tabGroupLayouts }, + activeGroupIdByWorktree: { + ...base.activeGroupIdByWorktree, + ...incoming.activeGroupIdByWorktree + }, + lastVisitedAtByWorktreeId: { + ...base.lastVisitedAtByWorktreeId, + ...incoming.lastVisitedAtByWorktreeId + }, + defaultTerminalTabsAppliedByWorktreeId: { + ...base.defaultTerminalTabsAppliedByWorktreeId, + ...incoming.defaultTerminalTabsAppliedByWorktreeId + }, + activeWorktreeIdsOnShutdown: [ + ...(base.activeWorktreeIdsOnShutdown ?? []), + ...(incoming.activeWorktreeIdsOnShutdown ?? []) + ], + activeWorktreeId: base.activeWorktreeId ?? incoming.activeWorktreeId, + activeWorkspaceKey: base.activeWorkspaceKey ?? incoming.activeWorkspaceKey, + activeTabId: base.activeTabId ?? incoming.activeTabId + } +} + +export function removeRepoFromHostWorkspaceSessions( + sessions: Partial> | undefined, + repoId: string +): Partial> { + const next: Partial> = {} + for (const [hostId, session] of Object.entries(sessions ?? {})) { + next[hostId as ExecutionHostId] = removeRepoFromWorkspaceSession(session, repoId) + } + return next +} + +export function removeRepoFromWorkspaceSession( + session: WorkspaceSessionState | undefined, + repoId: string +): WorkspaceSessionState { + const next = structuredClone(session ?? getDefaultWorkspaceSession()) + const removedTerminalTabIds = new Set() + for (const [ownerKey, tabs] of Object.entries(next.tabsByWorktree)) { + if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { + continue + } + tabs.forEach((tab) => removedTerminalTabIds.add(tab.id)) + delete next.tabsByWorktree[ownerKey] + } + for (const tabId of removedTerminalTabIds) { + delete next.terminalLayoutsByTabId[tabId] + } + next.openFilesByWorktree = removeRepoWorktreeRecord(next.openFilesByWorktree, repoId) + next.activeFileIdByWorktree = removeRepoWorktreeRecord(next.activeFileIdByWorktree, repoId) + const removedBrowserWorkspaceIds = new Set() + for (const [ownerKey, workspaces] of Object.entries(next.browserTabsByWorktree ?? {})) { + if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { + continue + } + workspaces.forEach((workspace) => removedBrowserWorkspaceIds.add(workspace.id)) + delete next.browserTabsByWorktree![ownerKey] + } + if (next.browserPagesByWorkspace) { + for (const workspaceId of removedBrowserWorkspaceIds) { + delete next.browserPagesByWorkspace[workspaceId] + } + } + next.activeBrowserTabIdByWorktree = removeRepoWorktreeRecord( + next.activeBrowserTabIdByWorktree, + repoId + ) + next.activeTabTypeByWorktree = removeRepoWorktreeRecord(next.activeTabTypeByWorktree, repoId) + next.activeTabIdByWorktree = removeRepoWorktreeRecord(next.activeTabIdByWorktree, repoId) + next.unifiedTabs = removeRepoWorktreeRecord(next.unifiedTabs, repoId) + next.tabGroups = removeRepoWorktreeRecord(next.tabGroups, repoId) + next.tabGroupLayouts = removeRepoWorktreeRecord(next.tabGroupLayouts, repoId) + next.activeGroupIdByWorktree = removeRepoWorktreeRecord(next.activeGroupIdByWorktree, repoId) + next.lastVisitedAtByWorktreeId = removeRepoWorktreeRecord(next.lastVisitedAtByWorktreeId, repoId) + next.defaultTerminalTabsAppliedByWorktreeId = removeRepoWorktreeRecord( + next.defaultTerminalTabsAppliedByWorktreeId, + repoId + ) + if (next.activeWorktreeId && isRepoWorktreeId(repoId, next.activeWorktreeId)) { + next.activeWorktreeId = null + } + const activeScope = next.activeWorkspaceKey ? parseWorkspaceKey(next.activeWorkspaceKey) : null + if (activeScope?.type === 'worktree' && isRepoWorktreeId(repoId, activeScope.worktreeId)) { + next.activeWorkspaceKey = null + } + next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( + (worktreeId) => !isRepoWorktreeId(repoId, worktreeId) + ) + return next +} diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts new file mode 100644 index 00000000000..8380e31301b --- /dev/null +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -0,0 +1,215 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { + BrowserPage, + BrowserWorkspace, + PersistedOpenFile, + Tab, + TabGroup, + TerminalTab, + WorkspaceSessionState +} from '../../shared/types' +import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { + isRepoWorktreeId, + rekeyOwnerKey, + rekeyWorktreeId +} from './profile-project-worktree-identity' + +export function extractHostSessionsForTransfer( + sessions: Partial> | undefined, + oldRepoId: string, + newRepoId: string +): Partial> { + const next: Partial> = {} + for (const [hostId, session] of Object.entries(sessions ?? {})) { + if (!session) { + continue + } + const transferred = extractSessionForTransfer(session, oldRepoId, newRepoId) + if (hasTransferredSessionState(transferred)) { + next[hostId as ExecutionHostId] = transferred + } + } + return next +} + +function hasTransferredSessionState(session: WorkspaceSessionState): boolean { + return ( + Object.keys(session.tabsByWorktree).length > 0 || + Object.keys(session.openFilesByWorktree ?? {}).length > 0 || + Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || + Object.keys(session.unifiedTabs ?? {}).length > 0 || + Object.keys(session.tabGroups ?? {}).length > 0 + ) +} + +export function extractSessionForTransfer( + session: WorkspaceSessionState | undefined, + oldRepoId: string, + newRepoId: string +): WorkspaceSessionState { + const source = session ?? getDefaultWorkspaceSession() + const transferred = getDefaultWorkspaceSession() + const copiedTerminalTabIds = new Set() + const copiedBrowserWorkspaceIds = new Set() + const mapOwnerRecord = ( + record: Record | undefined, + mapValue: (value: T) => T + ): Record => { + const next: Record = {} + for (const [ownerKey, value] of Object.entries(record ?? {})) { + const nextOwnerKey = rekeyOwnerKey(oldRepoId, newRepoId, ownerKey) + if (nextOwnerKey) { + next[nextOwnerKey] = mapValue(value) + } + } + return next + } + transferred.tabsByWorktree = mapOwnerRecord(source.tabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedTerminalTabIds.add(tab.id) + return rekeyTerminalTab(tab, oldRepoId, newRepoId) + }) + ) + transferred.openFilesByWorktree = mapOwnerRecord(source.openFilesByWorktree, (files) => + files.map((file) => rekeyOpenFile(file, oldRepoId, newRepoId)) + ) + transferred.activeFileIdByWorktree = mapOwnerRecord(source.activeFileIdByWorktree, (value) => + structuredClone(value) + ) + transferred.browserTabsByWorktree = mapOwnerRecord(source.browserTabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedBrowserWorkspaceIds.add(tab.id) + return rekeyBrowserWorkspace(tab, oldRepoId, newRepoId) + }) + ) + transferred.browserPagesByWorkspace = copyBrowserPages( + source.browserPagesByWorkspace, + copiedBrowserWorkspaceIds, + oldRepoId, + newRepoId + ) + transferred.activeBrowserTabIdByWorktree = mapOwnerRecord( + source.activeBrowserTabIdByWorktree, + (value) => structuredClone(value) + ) + transferred.activeTabTypeByWorktree = mapOwnerRecord(source.activeTabTypeByWorktree, (value) => + structuredClone(value) + ) + transferred.activeTabIdByWorktree = mapOwnerRecord(source.activeTabIdByWorktree, (value) => + structuredClone(value) + ) + transferred.unifiedTabs = mapOwnerRecord(source.unifiedTabs, (tabs) => + tabs.map((tab) => rekeyUnifiedTab(tab, oldRepoId, newRepoId)) + ) + transferred.tabGroups = mapOwnerRecord(source.tabGroups, (groups) => + groups.map((group) => rekeyTabGroup(group, oldRepoId, newRepoId)) + ) + transferred.tabGroupLayouts = mapOwnerRecord(source.tabGroupLayouts, (value) => + structuredClone(value) + ) + transferred.activeGroupIdByWorktree = mapOwnerRecord(source.activeGroupIdByWorktree, (value) => + structuredClone(value) + ) + transferred.lastVisitedAtByWorktreeId = mapOwnerRecord( + source.lastVisitedAtByWorktreeId, + (value) => structuredClone(value) + ) + transferred.defaultTerminalTabsAppliedByWorktreeId = mapOwnerRecord( + source.defaultTerminalTabsAppliedByWorktreeId, + (value) => structuredClone(value) + ) + transferred.terminalLayoutsByTabId = {} + for (const tabId of copiedTerminalTabIds) { + const layout = source.terminalLayoutsByTabId[tabId] + if (layout) { + transferred.terminalLayoutsByTabId[tabId] = structuredClone(layout) + } + } + transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown + ?.filter((worktreeId) => isRepoWorktreeId(oldRepoId, worktreeId)) + .map((worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId)) + if (source.activeWorktreeId && isRepoWorktreeId(oldRepoId, source.activeWorktreeId)) { + transferred.activeWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, source.activeWorktreeId) + } + const activeScope = source.activeWorkspaceKey + ? parseWorkspaceKey(source.activeWorkspaceKey) + : null + if (activeScope?.type === 'worktree' && isRepoWorktreeId(oldRepoId, activeScope.worktreeId)) { + transferred.activeWorkspaceKey = worktreeWorkspaceKey( + rekeyWorktreeId(oldRepoId, newRepoId, activeScope.worktreeId) + ) + } + return transferred +} + +function rekeyTerminalTab(tab: TerminalTab, oldRepoId: string, newRepoId: string): TerminalTab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyOpenFile( + file: PersistedOpenFile, + oldRepoId: string, + newRepoId: string +): PersistedOpenFile { + return { + ...structuredClone(file), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, file.worktreeId) + } +} + +function rekeyBrowserWorkspace( + workspace: BrowserWorkspace, + oldRepoId: string, + newRepoId: string +): BrowserWorkspace { + return { + ...structuredClone(workspace), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId), + // 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 + } +} + +function rekeyBrowserPage(page: BrowserPage, oldRepoId: string, newRepoId: string): BrowserPage { + return { + ...structuredClone(page), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId) + } +} + +function copyBrowserPages( + pagesByWorkspace: Record | undefined, + workspaceIds: ReadonlySet, + oldRepoId: string, + newRepoId: string +): Record { + const next: Record = {} + for (const [workspaceId, pages] of Object.entries(pagesByWorkspace ?? {})) { + if (workspaceIds.has(workspaceId)) { + next[workspaceId] = pages.map((page) => rekeyBrowserPage(page, oldRepoId, newRepoId)) + } + } + return next +} + +function rekeyUnifiedTab(tab: Tab, oldRepoId: string, newRepoId: string): Tab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyTabGroup(group: TabGroup, oldRepoId: string, newRepoId: string): TabGroup { + return { + ...structuredClone(group), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, group.worktreeId) + } +} diff --git a/src/main/orca-profiles/profile-project-source-removal.ts b/src/main/orca-profiles/profile-project-source-removal.ts new file mode 100644 index 00000000000..edb923e6293 --- /dev/null +++ b/src/main/orca-profiles/profile-project-source-removal.ts @@ -0,0 +1,66 @@ +import type { WorkspaceKey } from '../../shared/types' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { + rebuildRepoBackedProjectState, + type TransferProfileState +} from './profile-project-state-file' +import { + removeRepoFromHostWorkspaceSessions, + removeRepoFromWorkspaceSession +} from './profile-project-session-state' +import { isRepoWorktreeId, removeRepoWorktreeRecord } from './profile-project-worktree-identity' + +export function removeSourceRepo( + state: TransferProfileState, + repoId: string +): TransferProfileState { + const next: TransferProfileState = { + ...state, + repos: state.repos.filter((repo) => repo.id !== repoId), + sparsePresetsByRepo: { ...state.sparsePresetsByRepo }, + worktreeMeta: { ...state.worktreeMeta }, + worktreeLineageById: { ...state.worktreeLineageById }, + workspaceLineageByChildKey: { ...state.workspaceLineageByChildKey }, + workspaceSession: removeRepoFromWorkspaceSession(state.workspaceSession, repoId), + workspaceSessionsByHostId: removeRepoFromHostWorkspaceSessions( + state.workspaceSessionsByHostId, + repoId + ), + ui: { + ...state.ui, + lastActiveRepoId: state.ui.lastActiveRepoId === repoId ? null : state.ui.lastActiveRepoId, + lastActiveWorktreeId: + state.ui.lastActiveWorktreeId && isRepoWorktreeId(repoId, state.ui.lastActiveWorktreeId) + ? null + : state.ui.lastActiveWorktreeId, + filterRepoIds: state.ui.filterRepoIds?.filter((id) => id !== repoId) ?? [], + showDotfilesByWorktree: removeRepoWorktreeRecord(state.ui.showDotfilesByWorktree, repoId) + } + } + delete next.sparsePresetsByRepo[repoId] + removeRepoWorktreeMetadata(next, repoId) + return rebuildRepoBackedProjectState(next) +} + +function removeRepoWorktreeMetadata(state: TransferProfileState, repoId: string): void { + for (const key of Object.keys(state.worktreeMeta)) { + if (isRepoWorktreeId(repoId, key)) { + delete state.worktreeMeta[key] + } + } + for (const [key, lineage] of Object.entries(state.worktreeLineageById)) { + if (isRepoWorktreeId(repoId, key) || isRepoWorktreeId(repoId, lineage.parentWorktreeId)) { + delete state.worktreeLineageById[key] + } + } + for (const [key, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + const child = parseWorkspaceKey(key) + const parent = parseWorkspaceKey(lineage.parentWorkspaceKey) + if ( + (child?.type === 'worktree' && isRepoWorktreeId(repoId, child.worktreeId)) || + (parent?.type === 'worktree' && isRepoWorktreeId(repoId, parent.worktreeId)) + ) { + delete state.workspaceLineageByChildKey[key as WorkspaceKey] + } + } +} diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts new file mode 100644 index 00000000000..4169fea6ffc --- /dev/null +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname } from 'node:path' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import type { + PersistedState, + Project, + ProjectHostSetup, + Repo, + SparsePreset, + WorkspaceSessionState +} from '../../shared/types' +import { getOrcaProfileDataFile } from './profile-index-store' + +export type TransferProfileState = PersistedState + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function arrayOrEmpty(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : [] +} + +function recordOrEmpty(value: unknown): Record { + return isRecord(value) ? (value as Record) : {} +} + +export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { + const defaults = getDefaultPersistedState(homedir()) + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + if (!existsSync(dataFile)) { + return structuredClone(defaults) + } + const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as Partial + return rebuildRepoBackedProjectState({ + ...defaults, + ...parsed, + repos: arrayOrEmpty(parsed.repos), + projects: arrayOrEmpty(parsed.projects), + projectHostSetups: arrayOrEmpty(parsed.projectHostSetups), + projectGroups: arrayOrEmpty(parsed.projectGroups), + folderWorkspaces: arrayOrEmpty(parsed.folderWorkspaces), + sparsePresetsByRepo: recordOrEmpty(parsed.sparsePresetsByRepo), + worktreeMeta: recordOrEmpty(parsed.worktreeMeta), + worktreeLineageById: recordOrEmpty(parsed.worktreeLineageById), + workspaceLineageByChildKey: recordOrEmpty(parsed.workspaceLineageByChildKey), + settings: isRecord(parsed.settings) + ? { ...defaults.settings, ...parsed.settings } + : defaults.settings, + ui: isRecord(parsed.ui) ? { ...defaults.ui, ...parsed.ui } : defaults.ui, + githubCache: isRecord(parsed.githubCache) + ? { + pr: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).pr), + issue: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).issue) + } + : defaults.githubCache, + workspaceSession: isRecord(parsed.workspaceSession) + ? { ...getDefaultWorkspaceSession(), ...parsed.workspaceSession } + : defaults.workspaceSession, + workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId) + ? (parsed.workspaceSessionsByHostId as Partial< + Record + >) + : {}, + sshTargets: arrayOrEmpty(parsed.sshTargets), + sshRemotePtyLeases: arrayOrEmpty(parsed.sshRemotePtyLeases), + migrationUnsupportedPtyEntries: arrayOrEmpty(parsed.migrationUnsupportedPtyEntries), + legacyPaneKeyAliasEntries: arrayOrEmpty(parsed.legacyPaneKeyAliasEntries), + automations: arrayOrEmpty(parsed.automations), + automationRuns: arrayOrEmpty(parsed.automationRuns), + onboarding: isRecord(parsed.onboarding) + ? { ...defaults.onboarding, ...parsed.onboarding } + : defaults.onboarding, + featureInteractionTelemetryBuckets: isRecord(parsed.featureInteractionTelemetryBuckets) + ? parsed.featureInteractionTelemetryBuckets + : defaults.featureInteractionTelemetryBuckets + }) +} + +export function writeProfileState( + profileId: string, + userDataPath: string, + state: TransferProfileState +): void { + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + mkdirSync(dirname(dataFile), { recursive: true }) + const tmpPath = `${dataFile}.${process.pid}.${randomUUID()}.tmp` + writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8') + renameSync(tmpPath, dataFile) +} + +function isRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + currentRepoIds: ReadonlySet +): boolean { + return Boolean(setup.repoId && currentRepoIds.has(setup.repoId)) +} + +export function rebuildRepoBackedProjectState(state: TransferProfileState): TransferProfileState { + const projection = projectHostSetupProjectionFromRepos(state.repos) + const existingProjectsById = new Map(state.projects.map((project) => [project.id, project])) + const currentRepoIds = new Set(state.repos.map((repo) => repo.id)) + const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) + const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) + const independentSetups = state.projectHostSetups.filter((setup) => { + if (projectedSetupIds.has(setup.id)) { + return false + } + return !isRepoBackedProjectHostSetup(setup, currentRepoIds) + }) + const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) + const independentProjects = state.projects + .filter( + (project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id) + ) + .map((project) => ({ + ...project, + sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) + })) + const projectedProjects = projection.projects.map((project) => { + const existingProject = existingProjectsById.get(project.id) + return existingProject?.localWindowsRuntimePreference + ? { + ...project, + localWindowsRuntimePreference: existingProject.localWindowsRuntimePreference, + updatedAt: Math.max(project.updatedAt, existingProject.updatedAt) + } + : project + }) + return { + ...state, + projects: [...projectedProjects, ...independentProjects], + projectHostSetups: [...projection.setups, ...independentSetups] + } +} diff --git a/src/main/orca-profiles/profile-project-transfer-payload.ts b/src/main/orca-profiles/profile-project-transfer-payload.ts new file mode 100644 index 00000000000..68075a71a11 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-payload.ts @@ -0,0 +1,294 @@ +import { randomUUID } from 'node:crypto' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import type { SshTarget } from '../../shared/ssh-types' +import type { PersistedState, Repo, SparsePreset, WorkspaceKey } from '../../shared/types' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import type { TransferProfileState } from './profile-project-state-file' +import { rebuildRepoBackedProjectState } from './profile-project-state-file' +import { mergeHostWorkspaceSessions, mergeWorkspaceSessions } from './profile-project-session-state' +import { + extractHostSessionsForTransfer, + extractSessionForTransfer +} from './profile-project-session-transfer' +import { + isRepoWorktreeId, + rekeyWorktreeId, + rekeyWorkspaceKey +} from './profile-project-worktree-identity' + +export type TransferPayload = { + repo: Repo + sparsePresets: SparsePreset[] + worktreeMeta: PersistedState['worktreeMeta'] + worktreeLineageById: PersistedState['worktreeLineageById'] + workspaceLineageByChildKey: PersistedState['workspaceLineageByChildKey'] + workspaceSession?: PersistedState['workspaceSession'] + workspaceSessionsByHostId?: Partial> + sshTargets: SshTarget[] + targetProjectId: string | null +} + +export function createTargetRepo( + sourceRepo: Repo, + targetState: TransferProfileState, + copy: boolean +): Repo { + const targetRepoId = + !copy && !targetState.repos.some((repo) => repo.id === sourceRepo.id) + ? sourceRepo.id + : createUniqueRepoId(targetState) + const repo: Repo = { + ...sourceRepo, + id: targetRepoId, + projectGroupId: null, + addedAt: copy ? Date.now() : sourceRepo.addedAt + } + delete repo.projectGroupOrder + return repo +} + +function createUniqueRepoId(state: TransferProfileState): string { + const existingRepoIds = new Set(state.repos.map((repo) => repo.id)) + let candidate = randomUUID() + while (existingRepoIds.has(candidate)) { + candidate = randomUUID() + } + return candidate +} + +function collectTransferWorktreeIds(state: TransferProfileState, repoId: string): Set { + const ids = new Set() + const add = (value: string | null | undefined): void => { + if (value && isRepoWorktreeId(repoId, value)) { + ids.add(value) + } + } + Object.keys(state.worktreeMeta).forEach(add) + for (const lineage of Object.values(state.worktreeLineageById)) { + add(lineage.worktreeId) + add(lineage.parentWorktreeId) + } + for (const [key, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + const child = parseWorkspaceKey(key) + const parent = parseWorkspaceKey(lineage.parentWorkspaceKey) + if (child?.type === 'worktree') { + add(child.worktreeId) + } + if (parent?.type === 'worktree') { + add(parent.worktreeId) + } + } + collectSessionWorktreeIds(state.workspaceSession, repoId, ids) + for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + collectSessionWorktreeIds(session, repoId, ids) + } + Object.keys(state.ui?.showDotfilesByWorktree ?? {}).forEach(add) + return ids +} + +function collectSessionWorktreeIds( + session: PersistedState['workspaceSession'] | undefined, + repoId: string, + ids: Set +): void { + if (!session) { + return + } + const add = (value: string | null | undefined): void => { + if (value && isRepoWorktreeId(repoId, value)) { + ids.add(value) + } + } + const addOwnerKeys = (record: Record | undefined): void => { + for (const key of Object.keys(record ?? {})) { + if (isRepoWorktreeId(repoId, key)) { + ids.add(key) + } + const parsed = parseWorkspaceKey(key) + if (parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)) { + ids.add(parsed.worktreeId) + } + } + } + addOwnerKeys(session.tabsByWorktree) + addOwnerKeys(session.openFilesByWorktree) + addOwnerKeys(session.browserTabsByWorktree) + addOwnerKeys(session.activeBrowserTabIdByWorktree) + addOwnerKeys(session.activeTabTypeByWorktree) + addOwnerKeys(session.activeTabIdByWorktree) + addOwnerKeys(session.unifiedTabs) + addOwnerKeys(session.tabGroups) + addOwnerKeys(session.tabGroupLayouts) + addOwnerKeys(session.activeGroupIdByWorktree) + addOwnerKeys(session.lastVisitedAtByWorktreeId) + addOwnerKeys(session.defaultTerminalTabsAppliedByWorktreeId) + addOwnerKeys(session.activeFileIdByWorktree) + add(session.activeWorktreeId) + const activeScope = session.activeWorkspaceKey + ? parseWorkspaceKey(session.activeWorkspaceKey) + : null + if (activeScope?.type === 'worktree') { + add(activeScope.worktreeId) + } +} + +function rekeyWorktreeIdRecord( + record: Record, + worktreeIds: ReadonlySet, + oldRepoId: string, + newRepoId: string, + mapValue: (value: T) => T = (value) => structuredClone(value) +): Record { + const next: Record = {} + for (const [oldKey, value] of Object.entries(record)) { + if (worktreeIds.has(oldKey)) { + next[rekeyWorktreeId(oldRepoId, newRepoId, oldKey)] = mapValue(value) + } + } + return next +} + +function rekeyLineageRecord( + record: PersistedState['worktreeLineageById'], + worktreeIds: ReadonlySet, + oldRepoId: string, + newRepoId: string +): PersistedState['worktreeLineageById'] { + const next: PersistedState['worktreeLineageById'] = {} + for (const [oldKey, lineage] of Object.entries(record)) { + if (!worktreeIds.has(oldKey) && !worktreeIds.has(lineage.parentWorktreeId)) { + continue + } + const newKey = rekeyWorktreeId(oldRepoId, newRepoId, oldKey) + next[newKey] = { + ...structuredClone(lineage), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, lineage.worktreeId), + parentWorktreeId: rekeyWorktreeId(oldRepoId, newRepoId, lineage.parentWorktreeId) + } + } + return next +} + +function rekeyWorkspaceLineageRecord( + record: PersistedState['workspaceLineageByChildKey'], + oldRepoId: string, + newRepoId: string +): PersistedState['workspaceLineageByChildKey'] { + const next: PersistedState['workspaceLineageByChildKey'] = {} + for (const [oldKey, lineage] of Object.entries(record)) { + const newChildKey = rekeyWorkspaceKey(oldRepoId, newRepoId, oldKey as WorkspaceKey) + const newParentKey = rekeyWorkspaceKey(oldRepoId, newRepoId, lineage.parentWorkspaceKey) + if (newChildKey === oldKey && newParentKey === lineage.parentWorkspaceKey) { + continue + } + next[newChildKey] = { + ...structuredClone(lineage), + childWorkspaceKey: newChildKey, + parentWorkspaceKey: newParentKey + } + } + return next +} + +export function createTransferPayload(args: { + sourceState: TransferProfileState + sourceRepo: Repo + targetRepo: Repo + includeSessions: boolean +}): TransferPayload { + const { sourceState, sourceRepo, targetRepo, includeSessions } = args + const oldRepoId = sourceRepo.id + const newRepoId = targetRepo.id + const worktreeIds = collectTransferWorktreeIds(sourceState, oldRepoId) + const targetProjection = projectHostSetupProjectionFromRepos([targetRepo]) + const targetProjectId = + targetProjection.setups[0]?.projectId ?? targetProjection.projects[0]?.id ?? null + return { + repo: targetRepo, + sparsePresets: (sourceState.sparsePresetsByRepo[oldRepoId] ?? []).map((preset) => ({ + ...structuredClone(preset), + repoId: newRepoId + })), + worktreeMeta: rekeyWorktreeIdRecord( + sourceState.worktreeMeta, + worktreeIds, + oldRepoId, + newRepoId, + (meta) => ({ + ...structuredClone(meta), + ...(targetProjectId ? { projectId: targetProjectId } : {}), + hostId: getRepoExecutionHostId(targetRepo), + projectHostSetupId: targetRepo.id + }) + ), + worktreeLineageById: rekeyLineageRecord( + sourceState.worktreeLineageById, + worktreeIds, + oldRepoId, + newRepoId + ), + workspaceLineageByChildKey: rekeyWorkspaceLineageRecord( + sourceState.workspaceLineageByChildKey, + oldRepoId, + newRepoId + ), + ...(includeSessions + ? { + workspaceSession: extractSessionForTransfer( + sourceState.workspaceSession, + oldRepoId, + newRepoId + ), + workspaceSessionsByHostId: extractHostSessionsForTransfer( + sourceState.workspaceSessionsByHostId, + oldRepoId, + newRepoId + ) + } + : {}), + sshTargets: sourceRepo.connectionId + ? sourceState.sshTargets.filter((target) => target.id === sourceRepo.connectionId) + : [], + targetProjectId + } +} + +export function applyPayloadToTarget( + targetState: TransferProfileState, + payload: TransferPayload +): TransferProfileState { + const next: TransferProfileState = { + ...targetState, + repos: [...targetState.repos, payload.repo], + sparsePresetsByRepo: { + ...targetState.sparsePresetsByRepo, + ...(payload.sparsePresets.length > 0 ? { [payload.repo.id]: payload.sparsePresets } : {}) + }, + worktreeMeta: { ...targetState.worktreeMeta, ...payload.worktreeMeta }, + worktreeLineageById: { ...targetState.worktreeLineageById, ...payload.worktreeLineageById }, + workspaceLineageByChildKey: { + ...targetState.workspaceLineageByChildKey, + ...payload.workspaceLineageByChildKey + }, + sshTargets: mergeSshTargets(targetState.sshTargets, payload.sshTargets) + } + if (payload.workspaceSession) { + next.workspaceSession = mergeWorkspaceSessions( + targetState.workspaceSession, + payload.workspaceSession + ) + } + if (payload.workspaceSessionsByHostId) { + next.workspaceSessionsByHostId = mergeHostWorkspaceSessions( + targetState.workspaceSessionsByHostId, + payload.workspaceSessionsByHostId + ) + } + return rebuildRepoBackedProjectState(next) +} + +function mergeSshTargets(existing: SshTarget[], incoming: SshTarget[]): SshTarget[] { + const existingIds = new Set(existing.map((target) => target.id)) + return [...existing, ...incoming.filter((target) => !existingIds.has(target.id))] +} diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts new file mode 100644 index 00000000000..3c3a4643387 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -0,0 +1,301 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { + ORCA_PROFILE_INDEX_SCHEMA_VERSION, + type OrcaProfileIndex +} from '../../shared/orca-profiles' +import type { PersistedState, Repo, WorktreeMeta } from '../../shared/types' +import type { SshTarget } from '../../shared/ssh-types' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + } +})) + +async function loadTransferModule() { + vi.resetModules() + return import('./profile-project-transfer') +} + +function profile(id: string, name: string): OrcaProfileIndex['profiles'][number] { + return { + id, + name, + avatar: { kind: 'initials', initials: name[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } +} + +function writeIndex(activeProfileId = 'personal'): void { + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId, + profiles: [profile('personal', 'Personal'), profile('work', 'Work')] + } + writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8') +} + +function profileDataPath(profileId: string): string { + return join(testState.dir, 'profiles', profileId, 'orca-data.json') +} + +function writeProfileState(profileId: string, state: PersistedState): void { + const dataFile = profileDataPath(profileId) + mkdirSync(join(dataFile, '..'), { recursive: true }) + writeFileSync(dataFile, JSON.stringify(state, null, 2), 'utf-8') +} + +function readProfileState(profileId: string): PersistedState { + return JSON.parse(readFileSync(profileDataPath(profileId), 'utf-8')) as PersistedState +} + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/workspace/orca', + displayName: 'Orca', + badgeColor: '#33aa99', + addedAt: 100, + kind: 'git', + connectionId: null, + ...overrides + } +} + +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: 'Feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10, + lastActivityAt: 123, + ...overrides + } +} + +function makeState(overrides: Partial = {}): PersistedState { + const defaults = getDefaultPersistedState('/Users/tester') + return { + ...defaults, + ...overrides, + settings: { ...defaults.settings, ...overrides.settings }, + ui: { ...defaults.ui, ...overrides.ui }, + workspaceSession: { ...defaults.workspaceSession, ...overrides.workspaceSession } + } +} + +describe('profile project transfer', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-transfer-')) + writeIndex() + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('copies a project into another profile with a new repo id and re-keyed metadata', async () => { + const sourceWorktreeId = 'repo-1::/workspace/orca-feature' + writeProfileState( + 'personal', + makeState({ + repos: [makeRepo()], + sparsePresetsByRepo: { + 'repo-1': [ + { + id: 'preset-1', + repoId: 'repo-1', + name: 'UI', + directories: ['src/renderer'], + createdAt: 1, + updatedAt: 1 + } + ] + }, + worktreeMeta: { + [sourceWorktreeId]: makeWorktreeMeta({ projectHostSetupId: 'repo-1' }) + }, + workspaceSession: { + ...getDefaultPersistedState('/Users/tester').workspaceSession, + tabsByWorktree: { + [sourceWorktreeId]: [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: sourceWorktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + }) + ) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(result.status === 'transferred' ? result.targetRepoId : '').not.toBe('repo-1') + const targetRepoId = result.status === 'transferred' ? result.targetRepoId : '' + const target = readProfileState('work') + const targetWorktreeId = `${targetRepoId}::/workspace/orca-feature` + expect(target.repos).toEqual([ + expect.objectContaining({ id: targetRepoId, path: '/workspace/orca' }) + ]) + expect(target.worktreeMeta[targetWorktreeId]).toMatchObject({ + displayName: 'Feature', + projectHostSetupId: targetRepoId, + hostId: 'local' + }) + expect(target.sparsePresetsByRepo[targetRepoId]).toEqual([ + expect.objectContaining({ id: 'preset-1', repoId: targetRepoId }) + ]) + expect(target.workspaceSession.tabsByWorktree).toEqual({}) + expect(readProfileState('personal').repos.map((repo) => repo.id)).toEqual(['repo-1']) + }) + + it('moves a project, preserving SSH identity and restorable workspace session state', async () => { + const sourceWorktreeId = 'repo-ssh::/srv/orca-feature' + const sshTarget: SshTarget = { + id: 'ssh-1', + label: 'Builder', + host: 'builder.example.com', + port: 22, + username: 'dev' + } + writeProfileState( + 'personal', + makeState({ + repos: [ + makeRepo({ + id: 'repo-ssh', + path: '/srv/orca', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + ], + sshTargets: [sshTarget], + worktreeMeta: { + [sourceWorktreeId]: makeWorktreeMeta({ projectHostSetupId: 'repo-ssh' }) + }, + workspaceSession: { + ...getDefaultPersistedState('/Users/tester').workspaceSession, + browserTabsByWorktree: { + [sourceWorktreeId]: [ + { + 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, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } + } + }) + ) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-ssh', + mode: 'move' + }, + testState.dir + ) + + expect(result).toMatchObject({ + status: 'transferred', + sourceRepoId: 'repo-ssh', + targetRepoId: 'repo-ssh' + }) + const source = readProfileState('personal') + const target = readProfileState('work') + expect(source.repos).toEqual([]) + expect(source.worktreeMeta).toEqual({}) + expect(target.repos[0]).toMatchObject({ + id: 'repo-ssh', + path: '/srv/orca', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + expect(target.sshTargets).toEqual([sshTarget]) + expect(target.workspaceSession.browserTabsByWorktree?.[sourceWorktreeId]?.[0]).toMatchObject({ + worktreeId: sourceWorktreeId, + sessionProfileId: null, + sessionPartition: null + }) + }) + + it('rejects a duplicate physical project inside the target profile', async () => { + writeProfileState( + 'personal', + makeState({ + repos: [makeRepo({ path: 'C:\\Work\\Orca\\' })] + }) + ) + writeProfileState( + 'work', + makeState({ + repos: [makeRepo({ id: 'repo-existing', path: 'c:/work/orca' })] + }) + ) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result).toEqual({ + status: 'duplicate-target', + sourceProfileId: 'personal', + targetProfileId: 'work', + sourceRepoId: 'repo-1', + duplicateRepoId: 'repo-existing' + }) + expect(readProfileState('work').repos.map((repo) => repo.id)).toEqual(['repo-existing']) + }) +}) diff --git a/src/main/orca-profiles/profile-project-transfer.ts b/src/main/orca-profiles/profile-project-transfer.ts new file mode 100644 index 00000000000..75022d3ad65 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer.ts @@ -0,0 +1,77 @@ +import type { + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult +} from '../../shared/orca-profiles' +import { getOrcaProfileListState } from './profile-index-store' +import { readProfileState, writeProfileState } from './profile-project-state-file' +import { removeSourceRepo } from './profile-project-source-removal' +import { + applyPayloadToTarget, + createTargetRepo, + createTransferPayload +} from './profile-project-transfer-payload' +import { repoPhysicalKey } from './profile-project-worktree-identity' + +function assertKnownProfiles(args: TransferOrcaProfileProjectArgs, userDataPath: string): void { + const profiles = getOrcaProfileListState(userDataPath).profiles + const ids = new Set(profiles.map((profile) => profile.id)) + if (!ids.has(args.sourceProfileId)) { + throw new Error('unknown_source_orca_profile') + } + if (!ids.has(args.targetProfileId)) { + throw new Error('unknown_target_orca_profile') + } + if (args.sourceProfileId === args.targetProfileId) { + throw new Error('matching_orca_profile_transfer') + } +} + +export function transferOrcaProfileProject( + args: TransferOrcaProfileProjectArgs, + userDataPath: string +): TransferOrcaProfileProjectResult { + assertKnownProfiles(args, userDataPath) + const sourceState = readProfileState(args.sourceProfileId, userDataPath) + const targetState = readProfileState(args.targetProfileId, userDataPath) + const sourceRepo = sourceState.repos.find((repo) => repo.id === args.repoId) + if (!sourceRepo) { + throw new Error('unknown_source_repo') + } + const duplicate = targetState.repos.find( + (repo) => repoPhysicalKey(repo) === repoPhysicalKey(sourceRepo) + ) + if (duplicate) { + return { + status: 'duplicate-target', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + sourceRepoId: sourceRepo.id, + duplicateRepoId: duplicate.id + } + } + + const targetRepo = createTargetRepo(sourceRepo, targetState, args.mode === 'copy') + const payload = createTransferPayload({ + sourceState, + sourceRepo, + targetRepo, + includeSessions: args.mode === 'move' + }) + writeProfileState(args.targetProfileId, userDataPath, applyPayloadToTarget(targetState, payload)) + if (args.mode === 'move') { + writeProfileState( + args.sourceProfileId, + userDataPath, + removeSourceRepo(sourceState, sourceRepo.id) + ) + } + return { + status: 'transferred', + mode: args.mode, + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + sourceRepoId: sourceRepo.id, + targetRepoId: targetRepo.id, + targetProjectId: payload.targetProjectId + } +} diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts new file mode 100644 index 00000000000..9bb4fa9fc27 --- /dev/null +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -0,0 +1,77 @@ +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { Repo, WorkspaceKey } from '../../shared/types' +import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id' + +export function repoPhysicalKey( + repo: Pick +): string { + return [ + getRepoExecutionHostId(repo), + repo.connectionId?.trim() ?? '', + normalizeRuntimePathForComparison(repo.path) + ].join('\0') +} + +export function isRepoWorktreeId(repoId: string, worktreeId: string): boolean { + return worktreeId === repoId || worktreeId.startsWith(`${repoId}${WORKTREE_ID_SEPARATOR}`) +} + +export function rekeyWorktreeId(oldRepoId: string, newRepoId: string, worktreeId: string): string { + if (worktreeId === oldRepoId) { + return newRepoId + } + const prefix = `${oldRepoId}${WORKTREE_ID_SEPARATOR}` + return worktreeId.startsWith(prefix) + ? `${newRepoId}${WORKTREE_ID_SEPARATOR}${worktreeId.slice(prefix.length)}` + : worktreeId +} + +export function rekeyWorkspaceKey( + oldRepoId: string, + newRepoId: string, + workspaceKey: WorkspaceKey +): WorkspaceKey { + const parsed = parseWorkspaceKey(workspaceKey) + if (parsed?.type !== 'worktree' || !isRepoWorktreeId(oldRepoId, parsed.worktreeId)) { + return workspaceKey + } + return worktreeWorkspaceKey(rekeyWorktreeId(oldRepoId, newRepoId, parsed.worktreeId)) +} + +export function rekeyOwnerKey( + oldRepoId: string, + newRepoId: string, + ownerKey: string +): string | null { + if (isRepoWorktreeId(oldRepoId, ownerKey)) { + return rekeyWorktreeId(oldRepoId, newRepoId, ownerKey) + } + const parsed = parseWorkspaceKey(ownerKey) + if (parsed?.type === 'worktree' && isRepoWorktreeId(oldRepoId, parsed.worktreeId)) { + return worktreeWorkspaceKey(rekeyWorktreeId(oldRepoId, newRepoId, parsed.worktreeId)) + } + return null +} + +export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { + if (isRepoWorktreeId(repoId, ownerKey)) { + return true + } + const parsed = parseWorkspaceKey(ownerKey) + return parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId) +} + +export function removeRepoWorktreeRecord( + record: Record | undefined, + repoId: string +): Record { + const next = { ...record } + for (const key of Object.keys(next)) { + if (ownerKeyBelongsToRepo(key, repoId)) { + delete next[key] + } + } + return next +} diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts new file mode 100644 index 00000000000..5f8dc0df241 --- /dev/null +++ b/src/main/orca-profiles/profile-storage-paths.ts @@ -0,0 +1,72 @@ +import { app } from 'electron' +import { join } from 'node:path' + +const LEGACY_DATA_FILE_NAME = 'orca-data.json' +const LEGACY_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' +const PROFILE_INDEX_FILE_NAME = 'orca-profile-index.json' +const PROFILE_DATA_FILE_NAME = 'orca-data.json' +const PROFILE_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' +const PROFILE_DIRECTORY_NAME = 'profiles' + +export const LEGACY_BACKUP_COUNT = 5 + +let profileUserDataPath: string | null = null + +export function initOrcaProfilePaths(): void { + profileUserDataPath = app.getPath('userData') +} + +export function getProfileUserDataPath(): string { + if (!profileUserDataPath) { + profileUserDataPath = app.getPath('userData') + } + return profileUserDataPath +} + +export function getOrcaProfileIndexPath(userDataPath = getProfileUserDataPath()): string { + return join(userDataPath, PROFILE_INDEX_FILE_NAME) +} + +export function getOrcaProfilesDirectory(userDataPath = getProfileUserDataPath()): string { + return join(userDataPath, PROFILE_DIRECTORY_NAME) +} + +export function getOrcaProfileDirectory( + profileId: string, + userDataPath = getProfileUserDataPath() +): string { + return join(getOrcaProfilesDirectory(userDataPath), profileId) +} + +export function getOrcaProfileDataFile( + profileId: string, + userDataPath = getProfileUserDataPath() +): string { + return join(getOrcaProfileDirectory(profileId, userDataPath), PROFILE_DATA_FILE_NAME) +} + +export function getOrcaProfileBrowserSessionMetaFile( + profileId: string, + userDataPath = getProfileUserDataPath() +): string { + return join( + getOrcaProfileDirectory(profileId, userDataPath), + PROFILE_BROWSER_SESSION_META_FILE_NAME + ) +} + +export function legacyDataFilePath(userDataPath: string): string { + return join(userDataPath, LEGACY_DATA_FILE_NAME) +} + +export function legacyBrowserSessionMetaPath(userDataPath: string): string { + return join(userDataPath, LEGACY_BROWSER_SESSION_META_FILE_NAME) +} + +export function legacyBackupPath(userDataPath: string, index: number): string { + return `${legacyDataFilePath(userDataPath)}.bak.${index}` +} + +export function profileBackupPath(profileDataFile: string, index: number): string { + return `${profileDataFile}.bak.${index}` +} diff --git a/src/main/orca-profiles/profile-ui-scope.test.ts b/src/main/orca-profiles/profile-ui-scope.test.ts new file mode 100644 index 00000000000..0ed1f31de0d --- /dev/null +++ b/src/main/orca-profiles/profile-ui-scope.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { isMultiProfileUiEnabled } from './profile-ui-scope' + +describe('isMultiProfileUiEnabled', () => { + it('enables multi-profile UI only when the flag is exactly "1"', () => { + expect(isMultiProfileUiEnabled({ ORCA_MULTI_PROFILE_UI: '1' })).toBe(true) + }) + + it('defaults to the single-profile account menu', () => { + expect(isMultiProfileUiEnabled({})).toBe(false) + expect(isMultiProfileUiEnabled({ ORCA_MULTI_PROFILE_UI: '0' })).toBe(false) + expect(isMultiProfileUiEnabled({ ORCA_MULTI_PROFILE_UI: 'true' })).toBe(false) + }) +}) diff --git a/src/main/orca-profiles/profile-ui-scope.ts b/src/main/orca-profiles/profile-ui-scope.ts new file mode 100644 index 00000000000..937f4af2abd --- /dev/null +++ b/src/main/orca-profiles/profile-ui-scope.ts @@ -0,0 +1,6 @@ +// Why: multi-profile management is downscoped to an accounts-first UX, not +// removed — the full switcher stays reachable behind this product-scope toggle +// (safe in packaged builds; it is not a security escape hatch). +export function isMultiProfileUiEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.ORCA_MULTI_PROFILE_UI === '1' +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 52f826935d4..22f20157d8f 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -337,6 +337,31 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) + it('loads state from an explicit profile data file path', async () => { + const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') + const profileDataFile = join(profileDataDirectory, 'orca-data.json') + mkdirSync(profileDataDirectory, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: 'legacy-root-repo', path: '/legacy' })] + }) + writeFileSync( + profileDataFile, + JSON.stringify({ + schemaVersion: 1, + repos: [makeRepo({ id: 'profile-repo', path: '/profile' })] + }), + 'utf-8' + ) + + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const store = new Store({ dataFile: profileDataFile }) + + expect(store.getRepos().map((repo) => repo.id)).toEqual(['profile-repo']) + }, 15_000) + it('backfills project host setup compatibility records from legacy repos on load', async () => { writeDataFile({ schemaVersion: 1, @@ -5009,6 +5034,33 @@ describe('Store', () => { expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 }) }) + it('keeps GitHub cache sidecars scoped to explicit profile data files', async () => { + const profileADir = join(testState.dir, 'profiles', 'a') + const profileBDir = join(testState.dir, 'profiles', 'b') + const profileADataFile = join(profileADir, 'orca-data.json') + const profileBDataFile = join(profileBDir, 'orca-data.json') + mkdirSync(profileADir, { recursive: true }) + mkdirSync(profileBDir, { recursive: true }) + + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const profileAStore = new Store({ dataFile: profileADataFile }) + profileAStore.setGitHubCache({ pr: { 'o/r#a': { fetchedAt: 10 } as never }, issue: {} }) + profileAStore.flush() + + const profileBStore = new Store({ dataFile: profileBDataFile }) + expect(profileBStore.getGitHubCache().pr['o/r#a']).toBeUndefined() + profileBStore.setGitHubCache({ pr: { 'o/r#b': { fetchedAt: 20 } as never }, issue: {} }) + profileBStore.flush() + + const restartedProfileA = new Store({ dataFile: profileADataFile }) + const restartedProfileB = new Store({ dataFile: profileBDataFile }) + expect(restartedProfileA.getGitHubCache().pr['o/r#a']).toEqual({ fetchedAt: 10 }) + expect(restartedProfileA.getGitHubCache().pr['o/r#b']).toBeUndefined() + expect(restartedProfileB.getGitHubCache().pr['o/r#b']).toEqual({ fetchedAt: 20 }) + }) + it('keeps a legacy in-file cache as the seed and strips it from disk', async () => { writeDataFile({ githubCache: { pr: { legacy: { fetchedAt: 1 } }, issue: {} } }) @@ -6380,6 +6432,49 @@ describe('Store', () => { expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback') }) + it('stores terminal scrollback snapshots beside explicit profile data files', async () => { + const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') + const profileDataFile = join(profileDataDirectory, 'orca-data.json') + mkdirSync(profileDataDirectory, { recursive: true }) + + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const store = new Store({ dataFile: profileDataFile }) + store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) + const session = makeSessionWithTerminalBuffers() + store.setWorkspaceSession({ + ...session, + tabsByWorktree: { 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] }, + terminalLayoutsByTabId: { 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] } + }) + + const ref = + store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ + TEST_LEAF_2 + ] + expect(ref).toEqual(expect.stringMatching(/^v1-[0-9a-f]{32}$/)) + expect(existsSync(join(profileDataDirectory, 'terminal-scrollback', `${ref}.bin`))).toBe(true) + expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) + }) + + it('reads legacy terminal scrollback snapshots for explicit profile data files', async () => { + const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') + const profileDataFile = join(profileDataDirectory, 'orca-data.json') + const ref = 'v1-11111111111111111111111111111111' + const legacySnapshotDir = join(testState.dir, 'terminal-scrollback') + mkdirSync(profileDataDirectory, { recursive: true }) + mkdirSync(legacySnapshotDir, { recursive: true }) + writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') + + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const store = new Store({ dataFile: profileDataFile }) + + expect(store.readTerminalScrollbackSnapshot(ref)).toBe('legacy-scrollback') + }) + it('caps oversized browser history when setting workspace session', async () => { const store = await createStore() const oversizedSession = makeSessionWithBrowserHistory(500) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b10fa4fe910..67fdd0c8215 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -219,8 +219,10 @@ import { import { collectTerminalScrollbackSnapshotRefs, deleteTerminalScrollbackSnapshotSync, + getProfileTerminalScrollbackSnapshotRoot, migrateWorkspaceSessionTerminalScrollbackSnapshots, - readTerminalScrollbackSnapshotSync + readTerminalScrollbackSnapshotSync, + type TerminalScrollbackSnapshotStorage } from './terminal-scrollback-snapshots' import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' @@ -342,8 +344,8 @@ function getDataFile(): string { // the content-hash guard by design). It lives in memory during the session // and is snapshotted here best-effort at quit so PR/issue badges still paint // instantly on the next launch. Loss of this file costs nothing. -function getGithubCacheFile(): string { - return join(dirname(getDataFile()), 'orca-github-cache.json') +function getGithubCacheFile(dataFile = getDataFile()): string { + return join(dirname(dataFile), 'orca-github-cache.json') } // Why: worktrees deleted outside Orca (git CLI worktree remove, rm -rf, @@ -422,9 +424,9 @@ function gcStaleWorktreeMeta(state: PersistedState): number { return removed } -function readGithubCacheSnapshot(): PersistedState['githubCache'] | null { +function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null { try { - const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown + const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown const isPlainRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) if ( @@ -2574,7 +2576,8 @@ function backfillFolderScopeConnectionIds(state: PersistedState): { function deleteRemovedTerminalScrollbackSnapshots( prior: WorkspaceSessionState | undefined, - next: WorkspaceSessionState + next: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage ): void { if (!prior) { return @@ -2582,16 +2585,26 @@ function deleteRemovedTerminalScrollbackSnapshots( const nextRefs = collectTerminalScrollbackSnapshotRefs(next) for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { if (!nextRefs.has(ref)) { - deleteTerminalScrollbackSnapshotSync(ref) + deleteTerminalScrollbackSnapshotSync(ref, storage) } } } +export type StoreOptions = { + dataFile?: string +} + export class Store { private state: PersistedState + private readonly dataFile: string + private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage private writeTimer: ReturnType | null = null private pendingWrite: Promise | null = null private writeGeneration = 0 + // Why: after a profile transfer rewrites this store's file on disk behind + // its back, the stale in-memory state must never be persisted again — a + // late sync flush before the relaunch would resurrect the moved project. + private writesFrozen = false // Why: hash of the plaintext state as of the last successful write. Saves // triggered by mutations that net out to identical state skip the full // 1.6MB pretty-print + tmp write + rename. Hashing plaintext (not the @@ -2611,7 +2624,16 @@ export class Store { >() private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() - constructor() { + constructor(options: StoreOptions = {}) { + // Why: profile switching creates more than one possible state path. Capture + // the path per Store instance so late async writes cannot follow a global path. + this.dataFile = options.dataFile ?? getDataFile() + const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) + const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) + this.terminalScrollbackSnapshotStorage = { + snapshotRoot: profileSnapshotRoot, + fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot + } const loaded = this.load() const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state @@ -2820,7 +2842,7 @@ export class Store { // would be absent on every pre-telemetry install and misclassify existing // users as fresh, flipping them to default-on in violation of the // social contract we installed them under. - const dataFile = getDataFile() + const dataFile = this.dataFile const fileExistedOnLoad = existsSync(dataFile) logPersistenceStartupMilestone('persistence-load-start', { fileExists: fileExistedOnLoad @@ -3412,7 +3434,10 @@ export class Store { const workspaceSession = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) ) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(workspaceSession) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + workspaceSession, + this.terminalScrollbackSnapshotStorage + ) if (migratedScrollback.changed) { this.loadNeedsSave = true } @@ -3468,7 +3493,7 @@ export class Store { // poll refresh happens this session — the seed survives the migration. this.githubCacheDirty = true } else { - migrated.githubCache = readGithubCacheSnapshot() ?? migrated.githubCache + migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache } logPersistenceStartupMilestone('persistence-load-done', { @@ -3616,6 +3641,9 @@ export class Store { // Why: async writes avoid blocking the main Electron thread on every // debounced save during active use. private async writeToDiskAsync(): Promise { + if (this.writesFrozen) { + return + } const gen = this.writeGeneration const stateHash = this.computeStateHash() // Why: a mutation burst that nets out to already-persisted state (or a @@ -3624,7 +3652,7 @@ export class Store { return } const payload = this.buildStateToSave() - const dataFile = getDataFile() + const dataFile = this.dataFile const dir = dirname(dataFile) await mkdir(dir, { recursive: true }).catch(() => {}) const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` @@ -3669,6 +3697,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. @@ -3678,7 +3709,7 @@ export class Store { if (!opts.force && stateHash === this.lastWrittenStateHash) { return } - const dataFile = getDataFile() + const dataFile = this.dataFile const dir = dirname(dataFile) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) @@ -5533,7 +5564,7 @@ export class Store { } readTerminalScrollbackSnapshot(ref: string): string | null { - return readTerminalScrollbackSnapshotSync(ref) + return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage) } /** Resolve the worktree a terminal tab belongs to, from the session's @@ -5707,9 +5738,12 @@ export class Store { } } session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(session) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + session, + this.terminalScrollbackSnapshotStorage + ) session = migratedScrollback.session - deleteRemovedTerminalScrollbackSnapshots(prior, session) + deleteRemovedTerminalScrollbackSnapshots(prior, session, this.terminalScrollbackSnapshotStorage) this.state.workspaceSession = session this.scheduleSave() } @@ -6414,13 +6448,24 @@ 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 { if (!this.githubCacheDirty) { return } - const cacheFile = getGithubCacheFile() + const cacheFile = getGithubCacheFile(this.dataFile) const tmpFile = `${cacheFile}.${process.pid}.tmp` try { writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index 2dd41fad7d1..3929288d588 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -9,7 +9,7 @@ import { statSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { app } from 'electron' import type { WorkspaceSessionState } from '../shared/types' import { @@ -20,20 +20,47 @@ import { const SNAPSHOT_DIR_NAME = 'terminal-scrollback' const REF_PREFIX = 'v1' -function getSnapshotRoot(): string { +export type TerminalScrollbackSnapshotStorage = { + snapshotRoot?: string + fallbackSnapshotRoot?: string | null +} + +function getLegacySnapshotRoot(): string { return join(app.getPath('userData'), SNAPSHOT_DIR_NAME) } +export function getProfileTerminalScrollbackSnapshotRoot(dataFile: string): string { + return join(dirname(dataFile), SNAPSHOT_DIR_NAME) +} + +function getSnapshotRoot(storage?: TerminalScrollbackSnapshotStorage): string { + return storage?.snapshotRoot ?? getLegacySnapshotRoot() +} + export function makeTerminalScrollbackSnapshotRef(tabId: string, leafId: string): string { const hash = createHash('sha256').update(`${tabId}\0${leafId}`).digest('hex').slice(0, 32) return `${REF_PREFIX}-${hash}` } -function snapshotPath(ref: string): string | null { +function snapshotPath(ref: string, snapshotRoot: string): string | null { if (!/^v1-[0-9a-f]{32}$/.test(ref)) { return null } - return join(getSnapshotRoot(), `${ref}.bin`) + return join(snapshotRoot, `${ref}.bin`) +} + +function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStorage): string[] { + const primaryRoot = getSnapshotRoot(storage) + const primaryPath = snapshotPath(ref, primaryRoot) + if (!primaryPath) { + return [] + } + const fallbackRoot = storage?.fallbackSnapshotRoot ?? null + if (!fallbackRoot || fallbackRoot === primaryRoot) { + return [primaryPath] + } + const fallbackPath = snapshotPath(ref, fallbackRoot) + return fallbackPath ? [primaryPath, fallbackPath] : [primaryPath] } function trailingUtf8Bytes(value: string, maxBytes: number): Buffer { @@ -72,17 +99,19 @@ export function writeTerminalScrollbackSnapshotSync(args: { tabId: string leafId: string buffer: string + storage?: TerminalScrollbackSnapshotStorage }): string | null { if (!args.buffer) { return null } const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) - const path = snapshotPath(ref) + const snapshotRoot = getSnapshotRoot(args.storage) + const path = snapshotPath(ref, snapshotRoot) if (!path) { return null } try { - mkdirSync(getSnapshotRoot(), { recursive: true, mode: 0o700 }) + mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 }) const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` const bytes = trailingUtf8Bytes(args.buffer, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) let renamed = false @@ -104,27 +133,30 @@ export function writeTerminalScrollbackSnapshotSync(args: { } } -export function readTerminalScrollbackSnapshotSync(ref: string): string | null { - const path = snapshotPath(ref) - if (!path) { - return null - } - try { - return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) - } catch { - return null +export function readTerminalScrollbackSnapshotSync( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): string | null { + for (const path of snapshotReadPaths(ref, storage)) { + try { + return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) + } catch { + // Try the legacy/global fallback when a profile-local snapshot is absent. + } } + return null } -export function deleteTerminalScrollbackSnapshotSync(ref: string): void { - const path = snapshotPath(ref) - if (!path) { - return - } - try { - rmSync(path, { force: true }) - } catch { - // Best-effort cleanup; stale refs are harmless and bounded by per-file caps. +export function deleteTerminalScrollbackSnapshotSync( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): void { + for (const path of snapshotReadPaths(ref, storage)) { + try { + rmSync(path, { force: true }) + } catch { + // Best-effort cleanup; stale refs are harmless and bounded by per-file caps. + } } } @@ -139,7 +171,8 @@ export function collectTerminalScrollbackSnapshotRefs(session: WorkspaceSessionS } export function migrateWorkspaceSessionTerminalScrollbackSnapshots( - session: WorkspaceSessionState + session: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage ): { session: WorkspaceSessionState; changed: boolean } { let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { @@ -151,7 +184,7 @@ export function migrateWorkspaceSessionTerminalScrollbackSnapshots( const remainingBuffers: Record = {} let layoutChanged = false for (const [leafId, buffer] of Object.entries(buffers)) { - const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer }) + const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer, storage }) if (ref) { refs[leafId] = ref layoutChanged = true diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3b9c581b215..5c3bea5e55d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -11,6 +11,32 @@ import type { import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' +import type { + CreateLocalOrcaProfileArgs, + CreateLocalOrcaProfileResult, + CreateCloudLinkedOrcaProfileArgs, + CreateCloudLinkedOrcaProfileResult, + ConnectCurrentOrcaProfileResult, + FindOrcaProfileProjectsByPathArgs, + FindOrcaProfileProjectsByPathResult, + OrcaProfileListResult, + OrcaProfileAuthStatus, + RefreshCurrentOrcaProfileAuthResult, + SelectOrcaProfileOrgArgs, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult, + SwitchOrcaProfileArgs, + SwitchOrcaProfileResult, + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult, + OrcaProfileOrgInviteRevokeArgs, + OrcaProfileOrgMemberChangeRoleArgs, + OrcaProfileOrgMemberInviteArgs, + OrcaProfileOrgMemberMutationResult, + OrcaProfileOrgMemberRemoveArgs, + OrcaProfileOrgMembersListArgs, + OrcaProfileOrgMembersListResult +} from '../shared/orca-profiles' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { TaskSourceContext } from '../shared/task-source-context' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' @@ -842,6 +868,40 @@ export type AppApi = { export type PreloadApi = { app: AppApi + orcaProfiles: { + list: () => Promise + authStatus: () => Promise + createLocal: (args?: CreateLocalOrcaProfileArgs) => Promise + createCloudLinked: ( + args?: CreateCloudLinkedOrcaProfileArgs + ) => Promise + switchProfile: (args: SwitchOrcaProfileArgs) => Promise + transferProject: ( + args: TransferOrcaProfileProjectArgs + ) => Promise + findProjectProfiles: ( + args: FindOrcaProfileProjectsByPathArgs + ) => Promise + connectCurrent: () => Promise + refreshAuth: () => Promise + signOutCurrent: () => Promise + selectOrg: (args: SelectOrcaProfileOrgArgs) => Promise + orgMembersList: ( + args: OrcaProfileOrgMembersListArgs + ) => Promise + orgMemberInvite: ( + args: OrcaProfileOrgMemberInviteArgs + ) => Promise + orgInviteRevoke: ( + args: OrcaProfileOrgInviteRevokeArgs + ) => Promise + orgMemberChangeRole: ( + args: OrcaProfileOrgMemberChangeRoleArgs + ) => Promise + orgMemberRemove: ( + args: OrcaProfileOrgMemberRemoveArgs + ) => Promise + } platform: { get: () => { platform: NodeJS.Platform diff --git a/src/preload/index.ts b/src/preload/index.ts index c02a1fa5b9e..39756074c74 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -482,6 +482,25 @@ const api = { ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory') }, + orcaProfiles: { + list: () => ipcRenderer.invoke('orcaProfiles:list'), + authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), + createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), + createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), + switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), + transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), + findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), + connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), + refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), + signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'), + selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args), + orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args), + orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), + orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), + orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), + orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) + } satisfies PreloadApi['orcaProfiles'], + platform: { get: () => ({ platform: process.platform, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cb881ea97d9..eb34e42b62e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -64,6 +64,7 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' +import { OrcaProfileSwitcher } from './components/orca-profiles/OrcaProfileSwitcher' import { TOGGLE_FLOATING_TERMINAL_EVENT, requestFloatingTerminalOpenMaximized @@ -418,6 +419,7 @@ function App(): React.JSX.Element { fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, fetchAllWorktrees: s.fetchAllWorktrees, fetchWorktreeLineage: s.fetchWorktreeLineage, + fetchOrcaProfiles: s.fetchOrcaProfiles, fetchSettings: s.fetchSettings, fetchKeybindings: s.fetchKeybindings, initGitHubCache: s.initGitHubCache, @@ -864,6 +866,10 @@ function App(): React.JSX.Element { const startupStartedAt = performance.now() logRendererStartupDiagnostic('startup-chain-start') try { + // 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. @@ -1494,6 +1500,8 @@ function App(): React.JSX.Element { // Why: suppress right sidebar controls on full-page navigation surfaces // since those surfaces intentionally own the full content area. const showRightSidebarControls = !creationLayoutActive && canShowRightSidebarForView(activeView) + const showProfileSwitcherInSidebarFooter = showSidebar && sidebarOpen + const showProfileSwitcherInTopRight = !showProfileSwitcherInSidebarFooter const handleToggleExpand = (): void => { if (!effectiveActiveTabId) { @@ -2135,6 +2143,7 @@ function App(): React.JSX.Element { )} + {showProfileSwitcherInTopRight ? : null} {/* Why: when the right sidebar is open, its own header renders an identical close button — hide this copy so only one is visible at a time. */} @@ -2144,6 +2153,25 @@ function App(): React.JSX.Element { {hasCustomTitleBar &&
} ) + const workspaceProfileSwitcher = + showProfileSwitcherInTopRight && + workspaceChromeActive && + leftTitlebarChromeLayout.shouldMount && + !stackedSidebarOpen ? ( +
+ +
+ ) : null return (
)} + {workspaceProfileSwitcher}
{shouldMountTerminalWorkbench ? (
s.createBrowserTab) const consumeAddressBarFocusRequest = useAppStore((s) => s.consumeAddressBarFocusRequest) const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) + const activeOrcaProfileId = useAppStore((s) => s.activeOrcaProfileId) + const fallbackBrowserPartition = activeOrcaProfileId + ? getOrcaProfileBrowserDefaultPartition(activeOrcaProfileId) + : null + const defaultSessionProfile = browserSessionProfiles.find((p) => p.id === 'default') ?? null const sessionProfile = sessionProfileId ? (browserSessionProfiles.find((p) => p.id === sessionProfileId) ?? null) - : null - const webviewPartition = sessionPartition ?? sessionProfile?.partition ?? ORCA_BROWSER_PARTITION + : defaultSessionProfile + const webviewPartition = + sessionPartition ?? + sessionProfile?.partition ?? + defaultSessionProfile?.partition ?? + fallbackBrowserPartition ?? + ORCA_BROWSER_PARTITION const browserSessionImportState = useAppStore((s) => s.browserSessionImportState) const clearBrowserSessionImportState = useAppStore((s) => s.clearBrowserSessionImportState) const showBrowserZoomFeedback = useCallback((level: number): void => { diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx new file mode 100644 index 00000000000..07980102743 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx @@ -0,0 +1,22 @@ +import { cn } from '@/lib/utils' +import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' + +export function OrcaProfileAvatar({ + profile, + className +}: { + profile: OrcaProfileSummary + className?: string +}): React.JSX.Element { + return ( + + {profile.avatar.initials.slice(0, 2).toUpperCase()} + + ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx new file mode 100644 index 00000000000..e7f2270dc5d --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx @@ -0,0 +1,126 @@ +import { Check, Cloud, Loader2, LogIn, LogOut, Plus } from 'lucide-react' +import { + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' +import type { + OrcaCloudOrgSummary, + OrcaProfileAuthStatus, + OrcaProfileSummary +} from '../../../../shared/orca-profiles' + +function getConnectLabel(authStatus: OrcaProfileAuthStatus | null): string { + if (authStatus?.configured !== true) { + return translate( + 'auto.components.orca.profiles.switcher.cloud.unavailable', + 'Cloud sign-in unavailable' + ) + } + if (authStatus.state === 'connected' || authStatus.state === 'reconnect-required') { + return translate('auto.components.orca.profiles.switcher.reconnect', 'Reconnect profile') + } + return translate('auto.components.orca.profiles.switcher.connect', 'Connect profile') +} + +export function OrcaProfileCloudMenuItems({ + activeProfile, + authStatus, + connecting, + profileActionDisabled, + allowProfileCreation, + onConnect, + onCreateProfileForOrg, + onSelectOrg, + onRequestSignOut +}: { + activeProfile: OrcaProfileSummary + authStatus: OrcaProfileAuthStatus | null + connecting: boolean + profileActionDisabled: boolean + allowProfileCreation: boolean + onConnect: () => void + onCreateProfileForOrg: (organization: OrcaCloudOrgSummary) => void + onSelectOrg: (orgId: string) => void + onRequestSignOut: () => void +}): React.JSX.Element { + const cloudConfigured = authStatus?.configured === true + const organizations = authStatus?.organizations ?? [] + const showOrganizationChoices = activeProfile.kind === 'cloud-linked' && organizations.length > 1 + // Why: profile creation is hidden in the downscoped account menu, so the + // "Create profile for org" submenu only appears when multi-profile UI is on. + const showCloudProfileCreation = + allowProfileCreation && activeProfile.kind === 'cloud-linked' && organizations.length > 0 + const orgActionDisabled = profileActionDisabled || authStatus?.state !== 'connected' + const activeOrgId = activeProfile.cloud?.activeOrgId + + return ( + <> + {showOrganizationChoices || showCloudProfileCreation ? ( + <> + + + {translate('auto.components.orca.profiles.switcher.organization', 'Organization')} + + {showOrganizationChoices + ? organizations.map((organization) => ( + { + if (organization.orgId !== activeOrgId) { + onSelectOrg(organization.orgId) + } + }} + className="min-w-0" + > + + {organization.name} + {organization.orgId === activeOrgId ? : null} + + )) + : null} + {showCloudProfileCreation ? ( + + + + {translate( + 'auto.components.orca.profiles.switcher.create.profile.for.org', + 'Create profile for org' + )} + + + {organizations.map((organization) => ( + onCreateProfileForOrg(organization)} + className="min-w-0" + > + + {organization.name} + + ))} + + + ) : null} + + ) : null} + + + + {connecting ? : } + {getConnectLabel(authStatus)} + + {activeProfile.kind === 'cloud-linked' ? ( + + + {translate('auto.components.orca.profiles.switcher.signout', 'Sign out')} + + ) : null} + + ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx new file mode 100644 index 00000000000..40ac5022e23 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx @@ -0,0 +1,66 @@ +import { Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { translate } from '@/i18n/i18n' + +export function OrcaProfileCreateDialog({ + open, + onOpenChange, + name, + onNameChange, + creating, + switching, + onSubmit +}: { + open: boolean + onOpenChange: (open: boolean) => void + name: string + onNameChange: (name: string) => void + creating: boolean + switching: boolean + onSubmit: (event: React.FormEvent) => void +}): React.JSX.Element { + return ( + + +
+ + + {translate('auto.components.orca.profiles.switcher.16e3681072', 'New local profile')} + + + {translate( + 'auto.components.orca.profiles.switcher.e3b91a3d90', + 'Create an empty profile for separate projects and worktrees.' + )} + + + onNameChange(event.target.value)} + placeholder={translate( + 'auto.components.orca.profiles.switcher.f322e1f4d6', + 'Profile name' + )} + maxLength={80} + /> + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx new file mode 100644 index 00000000000..8f1577c7998 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx @@ -0,0 +1,272 @@ +import { useMemo, useState } from 'react' +import { ArrowRightLeft, Copy, FolderGit2, Loader2, MoveRight } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { ScrollArea } from '@/components/ui/scroll-area' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import type { + OrcaProfileSummary, + TransferOrcaProfileProjectMode +} from '../../../../shared/orca-profiles' +import type { Repo } from '../../../../shared/types' +import { OrcaProfileAvatar } from './OrcaProfileAvatar' +import { + OrcaProfileProjectTransferConfirmDialog, + type PendingProjectTransfer +} from './OrcaProfileProjectTransferConfirmDialog' +import { getOrcaProfileProjectLiveWorkSummary } from './orca-profile-switch-liveness' + +type PendingTransfer = { + repoId: string + targetProfileId: string + mode: TransferOrcaProfileProjectMode +} + +function pendingKey(value: PendingTransfer): string { + return `${value.mode}:${value.repoId}:${value.targetProfileId}` +} + +function getRepoPath(repo: Repo): string { + return repo.path || repo.displayName +} + +function ProjectTransferMenu({ + repo, + sourceProfileId, + targetProfiles, + pending, + onTransfer +}: { + repo: Repo + sourceProfileId: string + targetProfiles: OrcaProfileSummary[] + pending: PendingTransfer | null + onTransfer: ( + repo: Repo, + targetProfile: OrcaProfileSummary, + mode: TransferOrcaProfileProjectMode + ) => void +}): React.JSX.Element { + const disabled = targetProfiles.length === 0 || Boolean(pending) + const repoPending = pending?.repoId === repo.id + const renderTargetItems = (mode: TransferOrcaProfileProjectMode): React.JSX.Element[] => + targetProfiles.map((profile) => { + const targetPending = + pending && + pendingKey(pending) === pendingKey({ repoId: repo.id, targetProfileId: profile.id, mode }) + return ( + onTransfer(repo, profile, mode)} + > + {mode === 'move' ? : } + + {profile.name} + {targetPending ? : null} + + ) + }) + + return ( + + + + + + + {translate('auto.components.orca.profiles.management.128c7dfe64', 'Copy to')} + + {renderTargetItems('copy')} + + + {translate('auto.components.orca.profiles.management.df8b7d876b', 'Move to')} + + {renderTargetItems('move')} + + + ) +} + +export function OrcaProfileManagementDialog({ + open, + onOpenChange, + activeProfile, + profiles +}: { + open: boolean + onOpenChange: (open: boolean) => void + activeProfile: OrcaProfileSummary + profiles: OrcaProfileSummary[] +}): React.JSX.Element { + const repos = useAppStore((s) => s.repos) + const transferProject = useAppStore((s) => s.transferOrcaProfileProject) + const [pending, setPending] = useState(null) + const [pendingConfirmation, setPendingConfirmation] = useState( + null + ) + const targetProfiles = useMemo( + () => profiles.filter((profile) => profile.id !== activeProfile.id), + [activeProfile.id, profiles] + ) + + const runTransfer = async ( + repo: Repo, + targetProfile: OrcaProfileSummary, + mode: TransferOrcaProfileProjectMode + ): Promise => { + if (pending) { + return + } + const nextPending = { repoId: repo.id, targetProfileId: targetProfile.id, mode } + setPending(nextPending) + const result = await transferProject({ + sourceProfileId: activeProfile.id, + targetProfileId: targetProfile.id, + repoId: repo.id, + mode + }) + setPending(null) + if (result?.status === 'transferred') { + toast.success( + mode === 'move' + ? translate('auto.components.orca.profiles.management.9aa26347b3', 'Project moved') + : translate('auto.components.orca.profiles.management.816ce624b6', 'Project copied'), + { + description: targetProfile.name + } + ) + } + } + + const handleTransfer = ( + repo: Repo, + targetProfile: OrcaProfileSummary, + mode: TransferOrcaProfileProjectMode + ): void => { + if (pending) { + return + } + const liveWorkSummary = getOrcaProfileProjectLiveWorkSummary(useAppStore.getState(), repo.id) + if (mode === 'move' || liveWorkSummary.hasLiveWork) { + setPendingConfirmation({ repo, targetProfile, mode, liveWorkSummary }) + return + } + void runTransfer(repo, targetProfile, mode) + } + + const confirmTransfer = async (): Promise => { + if (!pendingConfirmation) { + return + } + const next = pendingConfirmation + await runTransfer(next.repo, next.targetProfile, next.mode) + setPendingConfirmation(null) + } + + return ( + + + + + {translate('auto.components.orca.profiles.management.2c45bda8d3', 'Manage profiles')} + + + {translate( + 'auto.components.orca.profiles.management.2db945e4a0', + 'Copy or move projects from the active profile to another local profile.' + )} + + + +
+
+ +
+
{activeProfile.name}
+
+ {translate( + 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 } + )} +
+
+
+ + {repos.length === 0 ? ( +
+ {translate( + 'auto.components.orca.profiles.management.8668cb2946', + 'No projects in this profile.' + )} +
+ ) : ( +
+ {repos.map((repo) => ( +
+ +
+
{repo.displayName}
+
+ {getRepoPath(repo)} +
+
+ { + handleTransfer(selectedRepo, targetProfile, mode) + }} + /> +
+ ))} +
+ )} +
+
+ + {targetProfiles.length === 0 ? ( +
+ {translate( + 'auto.components.orca.profiles.management.93034915ab', + 'Create another profile before copying projects.' + )} +
+ ) : null} + setPendingConfirmation(null)} + onConfirm={() => { + void confirmTransfer() + }} + /> +
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx new file mode 100644 index 00000000000..894e21c8481 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react' +import { Loader2, UserMinus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { OrcaOrgMember, OrcaOrgRole } from '../../../../shared/orca-profiles' +import { + ORG_ROLE_OPTIONS, + orgMemberInitials, + orgRoleLabel +} from './orca-profile-org-members-labels' + +export function OrcaProfileOrgMemberRow({ + member, + canManage, + isSelf, + busy, + onChangeRole, + onRemove +}: { + member: OrcaOrgMember + canManage: boolean + isSelf: boolean + busy: boolean + onChangeRole: (role: OrcaOrgRole) => void + onRemove: () => void +}): React.JSX.Element { + const [confirmingRemove, setConfirmingRemove] = useState(false) + // Why: teammates provisioned server-side who never signed into Orca have no + // userId, so the API cannot target them for role/remove mutations. + const neverSignedIn = member.userId === null + const actionsDisabled = !canManage || isSelf || neverSignedIn || busy + const displayName = member.displayName?.trim() || member.email + + const roleControl = canManage ? ( + + ) : ( + {orgRoleLabel(member.role)} + ) + + return ( +
+ + {orgMemberInitials(member.displayName, member.email)} + +
+
{displayName}
+
{member.email}
+
+ + {neverSignedIn && canManage ? ( + + + {roleControl} + + + {translate( + 'auto.components.orca.profiles.org.members.not.signed.in', + "They haven't signed in to Orca yet." + )} + + + ) : ( + roleControl + )} + + {canManage ? ( + confirmingRemove && !actionsDisabled ? ( +
+ + +
+ ) : ( + + ) + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx new file mode 100644 index 00000000000..35e3a07ae90 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +import type { ReactNode } from 'react' +import { render, screen, cleanup } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcaOrgMembersRoster } from '../../../../shared/orca-profiles' +import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog' + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children} +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + disabled, + 'aria-label': ariaLabel, + type + }: { + children?: ReactNode + disabled?: boolean + 'aria-label'?: string + type?: 'button' | 'submit' + }) => ( + + ) +})) + +vi.mock('@/components/ui/input', () => ({ + Input: ({ placeholder, disabled }: { placeholder?: string; disabled?: boolean }) => ( + + ) +})) + +vi.mock('@/components/ui/select', () => ({ + Select: ({ children }: { children: ReactNode }) =>
{children}
, + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectValue: () => null, + SelectTrigger: ({ + disabled, + 'aria-label': ariaLabel, + children + }: { + disabled?: boolean + 'aria-label'?: string + children?: ReactNode + }) => ( + + ) +})) + +const managerRoster: OrcaOrgMembersRoster = { + members: [ + { userId: 'user-viewer', email: 'me@example.com', displayName: 'Me', role: 'admin' }, + { userId: 'user-2', email: 'other@example.com', displayName: 'Other', role: 'member' }, + { userId: null, email: 'never@example.com', role: 'member' } + ], + pendingInvites: [{ email: 'pending@example.com', role: 'member', createdAt: 1 }], + viewerRole: 'owner', + canManageMembers: true +} + +const memberRoster: OrcaOrgMembersRoster = { + members: [ + { userId: 'user-1', email: 'boss@example.com', displayName: 'Boss', role: 'owner' }, + { userId: 'user-2', email: 'peer@example.com', displayName: 'Peer', role: 'member' } + ], + pendingInvites: [], + viewerRole: 'member', + canManageMembers: false +} + +function stubOrgMembersApi(roster: OrcaOrgMembersRoster): void { + ;(window as unknown as { api: unknown }).api = { + orcaProfiles: { + orgMembersList: vi.fn().mockResolvedValue({ status: 'ok', roster }) + } + } +} + +describe('OrcaProfileOrgMembersDialog', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('shows the invite form and a role select per member for managers', async () => { + stubOrgMembersApi(managerRoster) + render( + {}} + orgId="org-1" + orgName="Acme" + viewerUserId="user-viewer" + /> + ) + + expect(await screen.findByText('other@example.com')).toBeTruthy() + // Invite form present. + expect(screen.getByPlaceholderText('teammate@example.com')).toBeTruthy() + // Pending invite listed with a revoke action. + expect(screen.getByText('pending@example.com')).toBeTruthy() + // One role select per member. + expect(screen.getAllByLabelText('Role')).toHaveLength(3) + // Self and the never-signed-in row disable their remove action; the peer's stays enabled. + const removeButtons = screen.getAllByLabelText('Remove teammate') as HTMLButtonElement[] + expect(removeButtons).toHaveLength(3) + expect(removeButtons.filter((button) => button.disabled)).toHaveLength(2) + // Never-signed-in explanation is present. + expect(screen.getByText("They haven't signed in to Orca yet.")).toBeTruthy() + }) + + it('renders a read-only roster for non-managers', async () => { + stubOrgMembersApi(memberRoster) + render( + {}} + orgId="org-1" + viewerUserId="user-2" + /> + ) + + expect(await screen.findByText('boss@example.com')).toBeTruthy() + expect(screen.getByText('peer@example.com')).toBeTruthy() + // No management affordances. + expect(screen.queryByPlaceholderText('teammate@example.com')).toBeNull() + expect(screen.queryAllByLabelText('Role')).toHaveLength(0) + expect(screen.queryAllByLabelText('Remove teammate')).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx new file mode 100644 index 00000000000..95c5344ebcb --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx @@ -0,0 +1,300 @@ +import { useCallback, useEffect, useState } from 'react' +import { Loader2, Mail, RefreshCw, Users } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' +import type { + OrcaOrgMember, + OrcaOrgMembersRoster, + OrcaOrgRole, + OrcaProfileOrgMemberMutationResult +} from '../../../../shared/orca-profiles' +import { OrcaProfileOrgMemberRow } from './OrcaProfileOrgMemberRow' +import { + describeOrgMutationError, + ORG_ROLE_OPTIONS, + orgRoleLabel +} from './orca-profile-org-members-labels' + +type DialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + orgId: string + orgName?: string + viewerUserId?: string +} + +function isOk(result: OrcaProfileOrgMemberMutationResult): result is { status: 'ok' } { + return result.status === 'ok' +} + +export function OrcaProfileOrgMembersDialog({ + open, + onOpenChange, + orgId, + orgName, + viewerUserId +}: DialogProps): React.JSX.Element { + const [roster, setRoster] = useState(null) + const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState(false) + const [actionError, setActionError] = useState(null) + const [pendingMemberId, setPendingMemberId] = useState(null) + const [pendingInviteEmail, setPendingInviteEmail] = useState(null) + const [inviteEmail, setInviteEmail] = useState('') + const [inviteRole, setInviteRole] = useState('member') + const [inviting, setInviting] = useState(false) + + const refresh = useCallback(async (): Promise => { + setLoading(true) + setLoadError(false) + setActionError(null) + const result = await window.api.orcaProfiles.orgMembersList({ orgId }) + if (result.status === 'ok') { + setRoster(result.roster) + } else { + setRoster(null) + setLoadError(true) + } + setLoading(false) + }, [orgId]) + + // Why: reload every time the dialog opens so a manager always acts on the + // current roster, and clear transient dialog state when it closes. + useEffect(() => { + if (open) { + void refresh() + } else { + setActionError(null) + setInviteEmail('') + setInviteRole('member') + } + }, [open, refresh]) + + const canManage = roster?.canManageMembers === true + + const runMutation = async ( + mutation: Promise + ): Promise => { + setActionError(null) + const result = await mutation + if (isOk(result)) { + await refresh() + return true + } + setActionError(describeOrgMutationError(result)) + return false + } + + const handleChangeRole = async (member: OrcaOrgMember, role: OrcaOrgRole): Promise => { + if (member.userId === null || role === member.role) { + return + } + setPendingMemberId(member.userId) + await runMutation( + window.api.orcaProfiles.orgMemberChangeRole({ orgId, userId: member.userId, role }) + ) + setPendingMemberId(null) + } + + const handleRemove = async (member: OrcaOrgMember): Promise => { + if (member.userId === null) { + return + } + setPendingMemberId(member.userId) + await runMutation(window.api.orcaProfiles.orgMemberRemove({ orgId, userId: member.userId })) + setPendingMemberId(null) + } + + const handleRevoke = async (email: string): Promise => { + setPendingInviteEmail(email) + await runMutation(window.api.orcaProfiles.orgInviteRevoke({ orgId, email })) + setPendingInviteEmail(null) + } + + const handleInvite = async (event: React.FormEvent): Promise => { + event.preventDefault() + const email = inviteEmail.trim() + if (!email || inviting) { + return + } + setInviting(true) + const ok = await runMutation( + window.api.orcaProfiles.orgMemberInvite({ orgId, email, role: inviteRole }) + ) + setInviting(false) + if (ok) { + setInviteEmail('') + setInviteRole('member') + } + } + + return ( + + + + + + {translate('auto.components.orca.profiles.org.members.title', 'Organization members')} + + + {orgName + ? translate( + 'auto.components.orca.profiles.org.members.subtitle.named', + 'People in {{orgName}} who can collaborate on Orca.', + { orgName } + ) + : translate( + 'auto.components.orca.profiles.org.members.subtitle.default', + 'People in your organization who can collaborate on Orca.' + )} + + + + {loading ? ( +
+ +
+ ) : loadError ? ( +
+

+ {translate( + 'auto.components.orca.profiles.org.members.load.error', + "Couldn't load organization members." + )} +

+ +
+ ) : roster ? ( +
+ {actionError ? ( +

+ {actionError} +

+ ) : null} + + +
+ {roster.members.map((member) => ( + void handleChangeRole(member, role)} + onRemove={() => void handleRemove(member)} + /> + ))} +
+
+ + {canManage && roster.pendingInvites.length > 0 ? ( +
+

+ {translate( + 'auto.components.orca.profiles.org.members.pending.title', + 'Pending invites' + )} +

+ {roster.pendingInvites.map((invite) => ( +
+ + + {invite.email} + + + {orgRoleLabel(invite.role)} + + +
+ ))} +
+ ) : null} + + {canManage ? ( +
void handleInvite(event)} className="flex items-end gap-2"> +
+ + setInviteEmail(event.target.value)} + disabled={inviting} + placeholder={translate( + 'auto.components.orca.profiles.org.members.invite.placeholder', + 'teammate@example.com' + )} + /> +
+ + +
+ ) : null} +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx new file mode 100644 index 00000000000..4b5678e52b1 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx @@ -0,0 +1,162 @@ +import { ArrowRightLeft, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import type { + OrcaProfileSummary, + TransferOrcaProfileProjectMode +} from '../../../../shared/orca-profiles' +import type { Repo } from '../../../../shared/types' +import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' + +type PendingProjectTransfer = { + repo: Repo + targetProfile: OrcaProfileSummary + mode: TransferOrcaProfileProjectMode + liveWorkSummary: OrcaProfileSwitchLiveWorkSummary +} + +export type { PendingProjectTransfer } + +function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { + const lines: string[] = [] + if (summary.liveTerminalTabCount > 0) { + lines.push( + translate( + 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 } + ) + ) + } + if (summary.liveAgentCount > 0) { + lines.push( + translate( + 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 } + ) + ) + } + if (summary.browserWorkspaceCount > 0) { + lines.push( + translate( + 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 } + ) + ) + } + return lines +} + +export function OrcaProfileProjectTransferConfirmDialog({ + activeProfileName, + pendingTransfer, + pending, + onCancel, + onConfirm +}: { + activeProfileName: string + pendingTransfer: PendingProjectTransfer | null + pending: boolean + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + const mode = pendingTransfer?.mode ?? 'copy' + const repoName = pendingTransfer?.repo.displayName ?? '' + const targetName = pendingTransfer?.targetProfile.name ?? '' + const lines = pendingTransfer ? liveWorkLines(pendingTransfer.liveWorkSummary) : [] + + return ( + { + if (!open && !pending) { + onCancel() + } + }} + > + + + + + {mode === 'move' + ? translate( + 'auto.components.orca.profiles.project.transfer.confirm.move.title', + 'Move project?' + ) + : translate( + 'auto.components.orca.profiles.project.transfer.confirm.copy.title', + 'Copy project?' + )} + + + {mode === 'move' + ? translate( + 'auto.components.orca.profiles.project.transfer.confirm.move.description', + 'Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.', + { activeProfileName, repoName, targetName } + ) + : translate( + 'auto.components.orca.profiles.project.transfer.confirm.copy.description', + 'Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.', + { repoName, targetName } + )} + + + + {lines.length > 0 ? ( +
+
+ {translate( + 'auto.components.orca.profiles.project.transfer.confirm.live.work', + 'Live work in this project' + )} +
+
    + {lines.map((line) => ( +
  • {line}
  • + ))} +
+
+ ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx new file mode 100644 index 00000000000..afa2b4707de --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx @@ -0,0 +1,60 @@ +import { AlertTriangle, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' + +export function OrcaProfileSignOutConfirmDialog({ + open, + onOpenChange, + onConfirm, + profileName, + signingOut +}: { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + profileName: string + signingOut: boolean +}): React.JSX.Element { + return ( + + + + + + {translate('auto.components.orca.profiles.signout.confirm.title', 'Sign out?')} + + + {translate( + 'auto.components.orca.profiles.signout.confirm.description', + 'Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.', + { profileName } + )} + + + + + + + + + + ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx new file mode 100644 index 00000000000..89c80e99aa6 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx @@ -0,0 +1,129 @@ +import { AlertTriangle, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' +import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' + +function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { + const lines: string[] = [] + if (summary.liveTerminalTabCount > 0) { + lines.push( + translate( + 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( + 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( + 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 } + ) + ) + } + return lines +} + +export function OrcaProfileSwitchConfirmDialog({ + open, + onOpenChange, + onConfirm, + activeProfileName, + targetProfile, + liveWorkSummary, + switching +}: { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + activeProfileName: string + targetProfile: OrcaProfileSummary | null + liveWorkSummary: OrcaProfileSwitchLiveWorkSummary + switching: boolean +}): React.JSX.Element { + const targetName = + targetProfile?.name ?? + translate('auto.components.orca.profiles.switch.confirm.target', 'the selected profile') + const lines = liveWorkLines(liveWorkSummary) + + return ( + + + + + + {translate('auto.components.orca.profiles.switch.confirm.title', 'Switch profiles?')} + + + {translate( + 'auto.components.orca.profiles.switch.confirm.description', + 'Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.', + { activeProfileName, targetName } + )} + + + + {lines.length > 0 ? ( +
+
+ {translate( + 'auto.components.orca.profiles.switch.confirm.live.work', + 'Live work in this profile' + )} +
+
    + {lines.map((line) => ( +
  • {line}
  • + ))} +
+
+ ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx new file mode 100644 index 00000000000..cc52a10c975 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom + +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import type { OrcaProfileAuthStatus, OrcaProfileSummary } from '../../../../shared/orca-profiles' +import { OrcaProfileSwitcher } from './OrcaProfileSwitcher' + +const mocks = vi.hoisted(() => ({ + state: {} as Partial +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Partial) => unknown) => selector(mocks.state) +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuItem: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + 'aria-label': ariaLabel + }: { + children: ReactNode + 'aria-label'?: string + }) => +})) + +vi.mock('./OrcaProfileAvatar', () => ({ + OrcaProfileAvatar: () => +})) + +vi.mock('./OrcaProfileCreateDialog', () => ({ + OrcaProfileCreateDialog: () =>
+})) + +vi.mock('./OrcaProfileManagementDialog', () => ({ + OrcaProfileManagementDialog: () =>
+})) + +vi.mock('./OrcaProfileSwitchConfirmDialog', () => ({ + OrcaProfileSwitchConfirmDialog: () =>
+})) + +vi.mock('./OrcaProfileSignOutConfirmDialog', () => ({ + OrcaProfileSignOutConfirmDialog: () =>
+})) + +vi.mock('./orca-profile-switch-liveness', () => ({ + getOrcaProfileSwitchLiveWorkSummary: () => ({ hasLiveWork: false }) +})) + +const cloudProfile: OrcaProfileSummary = { + id: 'local-default', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'cloud-linked', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1, + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'nina@example.com', + activeOrgId: 'org-1', + activeOrgName: 'Acme', + linkedAt: 2 + } +} + +const connectedAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: cloudProfile.cloud, + organizations: [ + { orgId: 'org-1', name: 'Acme' }, + { orgId: 'org-2', name: 'Globex' } + ] +} + +const unconfiguredAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' +} + +function baseState(overrides: Partial): Partial { + return { + orcaProfiles: [cloudProfile], + activeOrcaProfileId: 'local-default', + orcaProfilesLoading: false, + orcaProfileSwitching: false, + orcaProfileConnecting: false, + orcaProfileAuthStatus: connectedAuthStatus, + orcaProfilesMultiProfileUi: false, + fetchOrcaProfiles: vi.fn(), + createLocalOrcaProfile: vi.fn(), + createCloudLinkedOrcaProfile: vi.fn(), + connectCurrentOrcaProfile: vi.fn(), + signOutCurrentOrcaProfile: vi.fn(), + selectOrcaProfileOrg: vi.fn(), + switchOrcaProfile: vi.fn(), + ...overrides + } +} + +describe('OrcaProfileSwitcher', () => { + beforeEach(() => { + mocks.state = baseState({}) + }) + + it('renders an account menu without profile management when the flag is off and cloud is configured', () => { + mocks.state = baseState({ orcaProfilesMultiProfileUi: false }) + const html = renderToStaticMarkup() + + expect(html).toContain('aria-label="Account"') + // Cloud actions stay reachable in the downscoped account menu. + expect(html).toContain('Sign out') + // Profile management surfaces are gone. + expect(html).not.toContain('Manage profiles') + expect(html).not.toContain('New local profile') + expect(html).not.toContain('Create profile for org') + expect(html).not.toContain('data-testid="create-dialog"') + expect(html).not.toContain('data-testid="management-dialog"') + expect(html).not.toContain('data-testid="switch-confirm-dialog"') + // Sign-out remains mounted. + expect(html).toContain('data-testid="signout-confirm-dialog"') + }) + + it('renders nothing when the flag is off and cloud is unconfigured', () => { + mocks.state = baseState({ + orcaProfilesMultiProfileUi: false, + orcaProfileAuthStatus: unconfiguredAuthStatus + }) + const html = renderToStaticMarkup() + + expect(html).toBe('') + }) + + it('renders the full multi-profile menu when the flag is on', () => { + mocks.state = baseState({ orcaProfilesMultiProfileUi: true }) + const html = renderToStaticMarkup() + + expect(html).toContain('aria-label="Switch profile"') + expect(html).toContain('Manage profiles') + expect(html).toContain('New local profile') + expect(html).toContain('Create profile for org') + expect(html).toContain('data-testid="create-dialog"') + expect(html).toContain('data-testid="management-dialog"') + expect(html).toContain('data-testid="switch-confirm-dialog"') + }) +}) diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx new file mode 100644 index 00000000000..f8a98da9d0a --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx @@ -0,0 +1,403 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronDown, Cloud, Laptop, Loader2, Plus, Settings2, Users } from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import type { OrcaCloudOrgSummary, OrcaProfileSummary } from '../../../../shared/orca-profiles' +import { OrcaProfileAvatar } from './OrcaProfileAvatar' +import { OrcaProfileCloudMenuItems } from './OrcaProfileCloudMenuItems' +import { OrcaProfileCreateDialog } from './OrcaProfileCreateDialog' +import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog' +import { OrcaProfileManagementDialog } from './OrcaProfileManagementDialog' +import { OrcaProfileSignOutConfirmDialog } from './OrcaProfileSignOutConfirmDialog' +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 + } + if (profile.cloud?.email) { + return profile.cloud.email + } + return translate('auto.components.orca.profiles.switcher.b4f9d1125d', 'Local') +} + +export function OrcaProfileSwitcher({ + placement = 'titlebar' +}: { + placement?: 'titlebar' | 'sidebar' +}): React.JSX.Element | null { + const profiles = useAppStore((s) => s.orcaProfiles) + const activeProfileId = useAppStore((s) => s.activeOrcaProfileId) + const loading = useAppStore((s) => s.orcaProfilesLoading) + const switching = useAppStore((s) => s.orcaProfileSwitching) + const connecting = useAppStore((s) => s.orcaProfileConnecting) + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const multiProfileUi = useAppStore((s) => s.orcaProfilesMultiProfileUi) + const fetchProfiles = useAppStore((s) => s.fetchOrcaProfiles) + const createLocalProfile = useAppStore((s) => s.createLocalOrcaProfile) + const createCloudLinkedProfile = useAppStore((s) => s.createCloudLinkedOrcaProfile) + const connectCurrentProfile = useAppStore((s) => s.connectCurrentOrcaProfile) + const signOutCurrentProfile = useAppStore((s) => s.signOutCurrentOrcaProfile) + const selectOrg = useAppStore((s) => s.selectOrcaProfileOrg) + const switchProfile = useAppStore((s) => s.switchOrcaProfile) + const liveWorkSummary = useAppStore(useShallow((s) => getOrcaProfileSwitchLiveWorkSummary(s))) + const [dialogOpen, setDialogOpen] = useState(false) + const [managementOpen, setManagementOpen] = useState(false) + const [newProfileName, setNewProfileName] = useState('') + const [creating, setCreating] = useState(false) + const [creatingCloudProfile, setCreatingCloudProfile] = useState(false) + const [signOutConfirmOpen, setSignOutConfirmOpen] = useState(false) + const [signingOut, setSigningOut] = useState(false) + const [orgMembersOpen, setOrgMembersOpen] = useState(false) + const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState(null) + const activeProfile = useMemo( + () => profiles.find((profile) => profile.id === activeProfileId) ?? profiles[0] ?? null, + [activeProfileId, profiles] + ) + const pendingSwitchProfile = useMemo( + () => profiles.find((profile) => profile.id === pendingSwitchProfileId) ?? null, + [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 && !fetchAttemptedRef.current) { + fetchAttemptedRef.current = true + void fetchProfiles() + } + }, [fetchProfiles, loading, profiles.length]) + + // Why: the Orca Cloud account UX isn't ready for production users yet, so the + // trigger stays hidden in packaged builds. Dev builds still show it when + // cloud auth is configured, and a dev-only Settings > Dev Tools > Orca Cloud + // section mirrors it. import.meta.env.PROD is true only in packaged builds + // (false in dev and under vitest), so tests still exercise the render path. + if (import.meta.env.PROD) { + return null + } + + // 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 + } + + // Why: with multi-profile UI downscoped, local-only builds (no cloud + // configured) have nothing to offer in an account menu — show no trigger. + if (!multiProfileUi && authStatus?.configured !== true) { + return null + } + + const handleCreateProfile = async (event: React.FormEvent): Promise => { + event.preventDefault() + if (creating || switching) { + return + } + setCreating(true) + const profile = await createLocalProfile(newProfileName) + setCreating(false) + if (!profile) { + return + } + setNewProfileName('') + setDialogOpen(false) + if (liveWorkSummary.hasLiveWork) { + setPendingSwitchProfileId(profile.id) + return + } + await switchProfile(profile.id) + } + + const handleSwitchProfile = (profileId: string): void => { + if (profileId === activeProfileId || switching) { + return + } + if (liveWorkSummary.hasLiveWork) { + setPendingSwitchProfileId(profileId) + return + } + void switchProfile(profileId) + } + + const handleConfirmSwitchProfile = (): void => { + if (!pendingSwitchProfileId || switching) { + return + } + void switchProfile(pendingSwitchProfileId) + } + + const handleCreateCloudProfileForOrg = async ( + organization: OrcaCloudOrgSummary + ): Promise => { + if (creatingCloudProfile || switching) { + return + } + setCreatingCloudProfile(true) + const result = await createCloudLinkedProfile({ + orgId: organization.orgId, + name: organization.name + }) + setCreatingCloudProfile(false) + if (result?.status !== 'created') { + return + } + if (liveWorkSummary.hasLiveWork) { + setPendingSwitchProfileId(result.profile.id) + return + } + await switchProfile(result.profile.id) + } + + const handleConfirmSignOut = async (): Promise => { + if (signingOut) { + return + } + setSigningOut(true) + const result = await signOutCurrentProfile() + setSigningOut(false) + if (result) { + setSignOutConfirmOpen(false) + } + } + + const profileActionDisabled = + switching || creating || creatingCloudProfile || connecting || signingOut + // Why: teammate management needs a connected cloud profile scoped to an org; + // the server enforces role permissions, and the dialog adapts via + // canManageMembers, so cloud-linked + org + connected is enough to reveal it. + const activeOrgId = activeProfile.cloud?.activeOrgId + const showOrgMembers = + activeProfile.kind === 'cloud-linked' && + Boolean(activeOrgId) && + authStatus?.state === 'connected' + const sidebarPlacement = placement === 'sidebar' + const triggerLabel = multiProfileUi + ? translate('auto.components.orca.profiles.switcher.4815f7d163', 'Switch profile') + : translate('auto.components.orca.profiles.switcher.account', 'Account') + + return ( + <> + + + + + + + + + {triggerLabel} + + + + +
+ +
+
+ {activeProfile.name} +
+
+ {getProfileSubtitle(activeProfile)} +
+
+
+
+ + {multiProfileUi + ? profiles.map((profile) => { + const active = profile.id === activeProfileId + return ( + handleSwitchProfile(profile.id)} + className="min-w-0" + > + + + {profile.name} + + {getProfileSubtitle(profile)} + + + {profile.kind === 'cloud-linked' ? : } + {active && } + + ) + }) + : null} + {showOrgMembers ? ( + <> + + setOrgMembersOpen(true)} + > + + {translate( + 'auto.components.orca.profiles.switcher.org.members', + 'Organization members' + )} + + + ) : null} + { + void connectCurrentProfile() + }} + onCreateProfileForOrg={(organization) => { + void handleCreateCloudProfileForOrg(organization) + }} + onSelectOrg={(orgId) => { + void selectOrg(orgId) + }} + onRequestSignOut={() => setSignOutConfirmOpen(true)} + /> + {multiProfileUi ? ( + <> + + { + setManagementOpen(true) + }} + > + + {translate('auto.components.orca.profiles.switcher.d00d853e2a', 'Manage profiles')} + + { + setDialogOpen(true) + }} + > + + {translate( + 'auto.components.orca.profiles.switcher.c106c674fe', + 'New local profile' + )} + + + ) : null} +
+
+ + {multiProfileUi ? ( + <> + + + + ) : null} + {showOrgMembers && activeOrgId ? ( + + ) : null} + { + if (!signingOut) { + setSignOutConfirmOpen(open) + } + }} + onConfirm={() => { + void handleConfirmSignOut() + }} + profileName={activeProfile.name} + signingOut={signingOut} + /> + {multiProfileUi ? ( + { + if (!open && !switching) { + setPendingSwitchProfileId(null) + } + }} + onConfirm={handleConfirmSwitchProfile} + activeProfileName={activeProfile.name} + targetProfile={pendingSwitchProfile} + liveWorkSummary={liveWorkSummary} + switching={switching} + /> + ) : null} + + ) +} diff --git a/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts b/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts new file mode 100644 index 00000000000..9378d77cec1 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts @@ -0,0 +1,79 @@ +import { translate } from '@/i18n/i18n' +import type { + OrcaOrgRole, + OrcaProfileOrgMemberMutationResult +} from '../../../../shared/orca-profiles' + +export const ORG_ROLE_OPTIONS: readonly OrcaOrgRole[] = ['owner', 'admin', 'member'] + +export function orgRoleLabel(role: OrcaOrgRole): string { + switch (role) { + case 'owner': + return translate('auto.components.orca.profiles.org.members.role.owner', 'Owner') + case 'admin': + return translate('auto.components.orca.profiles.org.members.role.admin', 'Admin') + case 'member': + return translate('auto.components.orca.profiles.org.members.role.member', 'Member') + } +} + +export function orgMemberInitials(displayName: string | undefined, email: string): string { + const source = displayName?.trim() || email.trim() + if (!source) { + return '?' + } + const words = source.split(/\s+/).filter(Boolean) + if (words.length >= 2) { + return `${words[0][0]}${words[1][0]}`.toUpperCase() + } + return source.slice(0, 2).toUpperCase() +} + +// Why: the dialog maps every non-ok mutation status to a precise, translated +// message so managers see why an action was rejected instead of a raw code. +export function describeOrgMutationError( + result: Exclude +): string { + switch (result.status) { + case 'forbidden': + return translate( + 'auto.components.orca.profiles.org.members.error.forbidden', + "Your role can't do that." + ) + case 'conflict': + return translate( + 'auto.components.orca.profiles.org.members.error.conflict', + 'Already a member or invited.' + ) + case 'invalid': + return result.reason === 'cannot_remove_self' + ? translate( + 'auto.components.orca.profiles.org.members.error.remove.self', + "You can't remove yourself." + ) + : translate( + 'auto.components.orca.profiles.org.members.error.own.role', + "You can't change your own role." + ) + case 'not-found': + return translate( + 'auto.components.orca.profiles.org.members.error.not.found', + 'That teammate is no longer in this organization.' + ) + case 'reconnect-required': + return translate( + 'auto.components.orca.profiles.org.members.error.reconnect', + 'Reconnect your profile to manage members.' + ) + case 'unconfigured': + return translate( + 'auto.components.orca.profiles.org.members.error.unconfigured', + 'Organization management is unavailable in this build.' + ) + case 'failed': + return translate( + 'auto.components.orca.profiles.org.members.error.failed', + 'Something went wrong. Please try again.' + ) + } +} diff --git a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts new file mode 100644 index 00000000000..1dfc0873727 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' +import { + getOrcaProfileProjectLiveWorkSummary, + getOrcaProfileSwitchLiveWorkSummary, + type OrcaProfileSwitchLiveWorkState +} from './orca-profile-switch-liveness' + +const NOW = 1_000_000 + +function makeState( + overrides: Partial = {} +): OrcaProfileSwitchLiveWorkState { + return { + agentStatusByPaneKey: {}, + browserTabsByWorktree: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + tabsByWorktree: {}, + ...overrides + } +} + +function makeTab( + overrides: Partial> & { + id: string + worktreeId?: string + } +): TerminalTab { + const { id, worktreeId = 'worktree-1', ...rest } = overrides + return { + id, + color: null, + createdAt: NOW, + customTitle: null, + ptyId: null, + sortOrder: 0, + title: 'zsh', + worktreeId, + ...rest + } +} + +function makeAgentEntry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: 'tab-1:0', + prompt: '', + state: 'working', + stateHistory: [], + stateStartedAt: NOW, + updatedAt: NOW, + ...overrides + } +} + +function makeBrowserWorkspace(id: string): BrowserWorkspace { + return { + id, + activePageId: null, + canGoBack: false, + canGoForward: false, + createdAt: NOW, + faviconUrl: null, + label: 'Browser', + loadError: null, + loading: false, + pageIds: [], + title: 'Browser', + url: 'about:blank', + worktreeId: 'worktree-1' + } +} + +describe('getOrcaProfileSwitchLiveWorkSummary', () => { + it('reports quiet profiles as safe to switch without confirmation', () => { + expect(getOrcaProfileSwitchLiveWorkSummary(makeState(), NOW)).toEqual({ + browserWorkspaceCount: 0, + hasLiveWork: false, + liveAgentCount: 0, + livePtyCount: 0, + liveTerminalTabCount: 0 + }) + }) + + it('counts live PTYs as live terminal work', () => { + const summary = getOrcaProfileSwitchLiveWorkSummary( + makeState({ + ptyIdsByTabId: { + 'tab-1': ['pty-1', 'pty-2'], + 'tab-2': [] + } + }), + NOW + ) + + expect(summary).toMatchObject({ + hasLiveWork: true, + livePtyCount: 2, + liveTerminalTabCount: 1 + }) + }) + + it('counts fresh working explicit agent status', () => { + const summary = getOrcaProfileSwitchLiveWorkSummary( + makeState({ + agentStatusByPaneKey: { + 'tab-1:0': makeAgentEntry({ paneKey: 'tab-1:0', state: 'working' }), + 'tab-2:0': makeAgentEntry({ paneKey: 'tab-2:0', state: 'done' }), + 'tab-3:0': makeAgentEntry({ + paneKey: 'tab-3:0', + state: 'waiting', + updatedAt: -999_999_999 + }) + } + }), + NOW + ) + + expect(summary.liveAgentCount).toBe(1) + expect(summary.hasLiveWork).toBe(true) + }) + + it('counts title-detected agents only when the tab has a live PTY', () => { + const summary = getOrcaProfileSwitchLiveWorkSummary( + makeState({ + ptyIdsByTabId: { + 'tab-live': ['pty-1'] + }, + tabsByWorktree: { + 'worktree-1': [ + makeTab({ id: 'tab-live', title: 'Codex working' }), + makeTab({ id: 'tab-slept', title: 'Codex working' }) + ] + } + }), + NOW + ) + + expect(summary.liveAgentCount).toBe(1) + expect(summary.hasLiveWork).toBe(true) + }) + + it('counts browser workspaces as live browser work', () => { + const summary = getOrcaProfileSwitchLiveWorkSummary( + makeState({ + browserTabsByWorktree: { + 'worktree-1': [makeBrowserWorkspace('browser-1'), makeBrowserWorkspace('browser-2')] + } + }), + NOW + ) + + expect(summary).toMatchObject({ + browserWorkspaceCount: 2, + hasLiveWork: true + }) + }) + + it('filters live work to the selected project', () => { + const summary = getOrcaProfileProjectLiveWorkSummary( + makeState({ + agentStatusByPaneKey: { + 'other-tab:0': makeAgentEntry({ + paneKey: 'other-tab:0', + worktreeId: 'repo-other::/workspace/other' + }) + }, + browserTabsByWorktree: { + 'repo-1::/workspace/orca': [makeBrowserWorkspace('browser-1')], + 'repo-other::/workspace/other': [makeBrowserWorkspace('browser-2')] + }, + ptyIdsByTabId: { + 'tab-live': ['pty-1'], + 'other-tab': ['pty-2'] + }, + tabsByWorktree: { + 'repo-1::/workspace/orca': [makeTab({ id: 'tab-live', title: 'Codex working' })], + 'repo-other::/workspace/other': [makeTab({ id: 'other-tab', title: 'Codex working' })] + } + }), + 'repo-1', + NOW + ) + + expect(summary).toMatchObject({ + browserWorkspaceCount: 1, + hasLiveWork: true, + liveAgentCount: 1, + livePtyCount: 1, + liveTerminalTabCount: 1 + }) + }) +}) diff --git a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts new file mode 100644 index 00000000000..c96a1812157 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts @@ -0,0 +1,180 @@ +import type { AppState } from '@/store/types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' + +export type OrcaProfileSwitchLiveWorkState = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'browserTabsByWorktree' + | 'ptyIdsByTabId' + | 'runtimePaneTitlesByTabId' + | 'tabsByWorktree' +> + +export type OrcaProfileSwitchLiveWorkSummary = { + hasLiveWork: boolean + liveAgentCount: number + livePtyCount: number + liveTerminalTabCount: number + browserWorkspaceCount: number +} + +const LIVE_AGENT_STATES = new Set(['working', 'blocked', 'waiting']) + +export function getOrcaProfileSwitchLiveWorkSummary( + state: OrcaProfileSwitchLiveWorkState, + now = Date.now() +): OrcaProfileSwitchLiveWorkSummary { + const terminalSummary = getLiveTerminalSummary(state) + const liveAgentCount = getLiveAgentKeys(state, now).size + const browserWorkspaceCount = Object.values(state.browserTabsByWorktree).reduce( + (count, workspaces) => count + workspaces.length, + 0 + ) + + return { + hasLiveWork: + terminalSummary.livePtyCount > 0 || liveAgentCount > 0 || browserWorkspaceCount > 0, + liveAgentCount, + livePtyCount: terminalSummary.livePtyCount, + liveTerminalTabCount: terminalSummary.liveTerminalTabCount, + browserWorkspaceCount + } +} + +export function getOrcaProfileProjectLiveWorkSummary( + state: OrcaProfileSwitchLiveWorkState, + repoId: string, + now = Date.now() +): OrcaProfileSwitchLiveWorkSummary { + const tabsByWorktree = Object.fromEntries( + Object.entries(state.tabsByWorktree).filter(([worktreeId]) => + worktreeBelongsToRepo(worktreeId, repoId) + ) + ) + const tabIds = new Set(Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id))) + const browserTabsByWorktree = Object.fromEntries( + Object.entries(state.browserTabsByWorktree).filter(([worktreeId]) => + worktreeBelongsToRepo(worktreeId, repoId) + ) + ) + const ptyIdsByTabId = Object.fromEntries( + Object.entries(state.ptyIdsByTabId).filter(([tabId]) => tabIds.has(tabId)) + ) + const runtimePaneTitlesByTabId = Object.fromEntries( + Object.entries(state.runtimePaneTitlesByTabId).filter(([tabId]) => tabIds.has(tabId)) + ) + const agentStatusByPaneKey = Object.fromEntries( + Object.entries(state.agentStatusByPaneKey).filter(([paneKey, entry]) => + agentEntryBelongsToProject(paneKey, entry, repoId, tabIds) + ) + ) + + return getOrcaProfileSwitchLiveWorkSummary( + { + agentStatusByPaneKey, + browserTabsByWorktree, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + tabsByWorktree + }, + now + ) +} + +function getLiveTerminalSummary(state: OrcaProfileSwitchLiveWorkState): { + livePtyCount: number + liveTerminalTabCount: number +} { + const liveTabIds = new Set() + let livePtyCount = 0 + + for (const [tabId, ptyIds] of Object.entries(state.ptyIdsByTabId)) { + if (ptyIds.length === 0) { + continue + } + livePtyCount += ptyIds.length + liveTabIds.add(tabId) + } + + return { + livePtyCount, + liveTerminalTabCount: liveTabIds.size + } +} + +function getLiveAgentKeys(state: OrcaProfileSwitchLiveWorkState, now: number): Set { + const keys = new Set() + for (const entry of Object.values(state.agentStatusByPaneKey)) { + if ( + LIVE_AGENT_STATES.has(entry.state) && + isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + ) { + keys.add(entry.paneKey) + } + } + + for (const tabs of Object.values(state.tabsByWorktree)) { + for (const tab of tabs) { + addTitleDetectedLiveAgentKeys(keys, state, tab) + } + } + + return keys +} + +function addTitleDetectedLiveAgentKeys( + keys: Set, + state: OrcaProfileSwitchLiveWorkState, + tab: TerminalTab +): void { + if (!tabHasLivePty(state.ptyIdsByTabId, tab.id)) { + return + } + + const paneTitles = state.runtimePaneTitlesByTabId[tab.id] + if (paneTitles && Object.keys(paneTitles).length > 0) { + for (const [paneId, title] of Object.entries(paneTitles)) { + if (isLiveAgentTitle(title)) { + keys.add(`${tab.id}:${paneId}`) + } + } + return + } + + if (isLiveAgentTitle(tab.title)) { + keys.add(`${tab.id}:title`) + } +} + +function isLiveAgentTitle(title: string): boolean { + const status = detectAgentStatusFromTitle(title) + return status === 'working' || status === 'permission' +} + +function agentEntryBelongsToProject( + paneKey: string, + entry: AgentStatusEntry, + repoId: string, + tabIds: Set +): boolean { + if (entry.worktreeId && worktreeBelongsToRepo(entry.worktreeId, repoId)) { + return true + } + return tabIds.has(getPaneKeyTabId(paneKey)) +} + +function getPaneKeyTabId(paneKey: string): string { + const separatorIndex = paneKey.lastIndexOf(':') + return separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) +} + +function worktreeBelongsToRepo(worktreeId: string, repoId: string): boolean { + return getRepoIdFromWorktreeId(worktreeId) === repoId +} diff --git a/src/renderer/src/components/settings/DevToolsPane.tsx b/src/renderer/src/components/settings/DevToolsPane.tsx index f22b5823e00..e1779e9aead 100644 --- a/src/renderer/src/components/settings/DevToolsPane.tsx +++ b/src/renderer/src/components/settings/DevToolsPane.tsx @@ -6,6 +6,7 @@ import { SettingsSubsectionHeader } from './SettingsFormControls' import { showDeleteWorktreeFailureToast } from '../sidebar/delete-worktree-failure-toast' import { showLocalBaseRefUpdateSuggestionToast } from '../sidebar/local-base-ref-suggestion-toast' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' import type { AppState } from '@/store/types' const LONG_WORKSPACE_NAME = 'feature/dev-toast-layout-with-a-long-workspace-name' @@ -124,6 +125,85 @@ function showDeleteFailureToast(): void { }) } +// Dev-only preview of the first-party Orca Cloud sign-in. The sidebar/titlebar +// account switcher is hidden in packaged builds while the feature is in +// progress; this surfaces it (and its status) in dev when the env vars are set. +function OrcaCloudDevSubsection(): React.JSX.Element { + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const connecting = useAppStore((s) => s.orcaProfileConnecting) + const connect = useAppStore((s) => s.connectCurrentOrcaProfile) + const signOut = useAppStore((s) => s.signOutCurrentOrcaProfile) + const refresh = useAppStore((s) => s.fetchOrcaProfileAuthStatus) + const configured = authStatus?.configured === true + const connected = authStatus?.state === 'connected' + + return ( +
+
+
+ + {configured ? ( +
+

+ {translate('auto.components.settings.DevToolsPane.orcaCloudStatus', 'Status')}:{' '} + {authStatus?.state} +

+
+ {connected ? ( + + ) : ( + + )} + +
+
+ ) : ( +

+ {authStatus?.setupMessage ?? + translate( + 'auto.components.settings.DevToolsPane.orcaCloudNotConfigured', + 'Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build.' + )} +

+ )} +
+ ) +} + export function DevToolsPane(): React.JSX.Element { const actions: DevToastAction[] = [ { @@ -221,28 +301,32 @@ export function DevToolsPane(): React.JSX.Element { ] return ( -
-
- - - {translate('auto.components.settings.DevToolsPane.devOnly', 'Dev only')} - -
+
+
+
+ + + {translate('auto.components.settings.DevToolsPane.devOnly', 'Dev only')} + +
-
- {actions.map((action) => ( - - ))} -
-
+
+ {actions.map((action) => ( + + ))} +
+
+ + +
) } diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx index b3ca7aa3e20..28fd8cbcdcf 100644 --- a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx @@ -33,6 +33,9 @@ function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideFie 'Command template' ) } + // Fail at compile time if the override-field union grows a new variant. + const _exhaustive: never = field + return _exhaustive } function getRecipeOverrideFieldSummary(fields: SourceControlActionRecipeOverrideField[]): string { diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx index a9d141e9237..f3dde9621ea 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx @@ -36,6 +36,14 @@ vi.mock('./SidebarSettingsHelpMenu', () => ({ SidebarSettingsHelpMenu: () => })) +vi.mock('../orca-profiles/OrcaProfileSwitcher', () => ({ + OrcaProfileSwitcher: ({ placement }: { placement?: string }) => ( + + ) +})) + const roots: Root[] = [] async function renderToolbar(onWorkspaceBoardToggle = vi.fn()): Promise<{ @@ -122,4 +130,12 @@ describe('SidebarToolbar moved workspace board hint', () => { expect(container.textContent).toContain('Workspace board moved to the bottom bar') expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBe('true') }) + + it('renders the profile switcher before settings in the footer controls', async () => { + const { container } = await renderToolbar() + const html = container.innerHTML + + expect(html).toContain('data-placement="sidebar"') + expect(html.indexOf('Profile')).toBeLessThan(html.indexOf('Settings')) + }) }) diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index 9ea74af8d77..ff2e030c7a5 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton' import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu' +import { OrcaProfileSwitcher } from '../orca-profiles/OrcaProfileSwitcher' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { hasFeatureInteraction } from '../../../../shared/feature-interactions' @@ -65,7 +66,10 @@ const SidebarToolbar = React.memo(function SidebarToolbar({ return (
- +
+ + +
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 62628f70dca..570c20c6c54 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "Project already added", "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." + "15cf5319ec": "{{path}} was checked on {{hostName}}, but that host did not report a usable folder.", + "2dcd706774": "Project also exists in another profile", + "presenceProfileOverflow": "{{names}} +{{count}} more" }, "settings": { "e12dab333b": "Failed to switch servers", @@ -262,6 +264,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" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "Persistent prompt with an inline Settings link and footer action.", "notificationPlayground": "Notification playground", "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "devOnly": "Dev only", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "Use in workspace" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "Copy" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "Cloud sign-in unavailable" + }, + "account": "Account", + "reconnect": "Reconnect profile", + "connect": "Connect profile", + "organization": "Organization", + "create": { + "profile": { + "for": { + "org": "Create profile for 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", + "org": { + "members": "Organization members" + } + }, + "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.", + "8668cb2946": "No projects in this profile.", + "93034915ab": "Create another profile before copying projects.", + "projectCountSingular": "{{count}} project", + "projectCountPlural": "{{count}} projects" + }, + "project": { + "transfer": { + "confirm": { + "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" + }, + "copy": { + "title": "Copy project?", + "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", + "action": "Copy project" + }, + "live": { + "work": "Live work in this project" + }, + "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" + } + } + }, + "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" + } + }, + "switch": { + "confirm": { + "target": "the selected profile", + "title": "Switch profiles?", + "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "live": { + "work": "Live work in this profile" + }, + "cancel": "Cancel", + "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" + } + }, + "org": { + "members": { + "role": { + "label": "Role", + "owner": "Owner", + "admin": "Admin", + "member": "Member" + }, + "not": { + "signed": { + "in": "They haven't signed in to Orca yet." + } + }, + "remove": { + "confirm": "Remove", + "cancel": "Cancel", + "label": "Remove teammate" + }, + "title": "Organization members", + "load": { + "error": "Couldn't load organization members." + }, + "retry": "Try again", + "pending": { + "title": "Pending invites", + "revoke": "Revoke" + }, + "invite": { + "title": "Invite", + "placeholder": "teammate@example.com", + "role": "Invite role", + "submit": "Send" + }, + "error": { + "forbidden": "Your role can't do that.", + "conflict": "Already a member or invited.", + "remove": { + "self": "You can't remove yourself." + }, + "own": { + "role": "You can't change your own role." + }, + "not": { + "found": "That teammate is no longer in this organization." + }, + "reconnect": "Reconnect your profile to manage members.", + "unconfigured": "Organization management is unavailable in this build.", + "failed": "Something went wrong. Please try again." + }, + "subtitle": { + "named": "People in {{orgName}} who can collaborate on Orca.", + "default": "People in your organization who can collaborate on Orca." + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cb1f6f58463..2543c3ce6d3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "Proyecto ya agregado", "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." + "15cf5319ec": "{{path}} se verificó en {{hostName}}, pero ese host no reportó ninguna carpeta utilizable.", + "2dcd706774": "Project also exists in another profile", + "presenceProfileOverflow": "{{names}} +{{count}} more" }, "settings": { "e12dab333b": "No se pudo cambiar de servidor", @@ -262,6 +264,24 @@ }, "linear": { "37d36984d0": "La conexión de Linear fue reemplazada por una solicitud más reciente." + }, + "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" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "Aviso persistente con un enlace a Ajustes y una acción en el pie.", "notificationPlayground": "Panel de prueba de notificaciones", "notificationPlaygroundDescription": "Disparadores solo para desarrollo para comprobar el diseño de toasts, acciones de recuperación y ajuste de texto largo.", - "devOnly": "Solo para desarrollo" + "devOnly": "Solo para desarrollo", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "Usar en el espacio de trabajo" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "Copiar" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "Cloud sign-in unavailable" + }, + "account": "Account", + "reconnect": "Reconnect profile", + "connect": "Connect profile", + "organization": "Organization", + "create": { + "profile": { + "for": { + "org": "Create profile for 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", + "org": { + "members": "Organization members" + } + }, + "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.", + "8668cb2946": "No projects in this profile.", + "93034915ab": "Create another profile before copying projects.", + "projectCountSingular": "{{count}} project", + "projectCountPlural": "{{count}} projects" + }, + "project": { + "transfer": { + "confirm": { + "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" + }, + "copy": { + "title": "Copy project?", + "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", + "action": "Copy project" + }, + "live": { + "work": "Live work in this project" + }, + "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" + } + } + }, + "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" + } + }, + "switch": { + "confirm": { + "target": "the selected profile", + "title": "Switch profiles?", + "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "live": { + "work": "Live work in this profile" + }, + "cancel": "Cancel", + "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" + } + }, + "org": { + "members": { + "role": { + "label": "Role", + "owner": "Owner", + "admin": "Admin", + "member": "Member" + }, + "not": { + "signed": { + "in": "They haven't signed in to Orca yet." + } + }, + "remove": { + "confirm": "Remove", + "cancel": "Cancel", + "label": "Remove teammate" + }, + "title": "Organization members", + "load": { + "error": "Couldn't load organization members." + }, + "retry": "Try again", + "pending": { + "title": "Pending invites", + "revoke": "Revoke" + }, + "invite": { + "title": "Invite", + "placeholder": "teammate@example.com", + "role": "Invite role", + "submit": "Send" + }, + "error": { + "forbidden": "Your role can't do that.", + "conflict": "Already a member or invited.", + "remove": { + "self": "You can't remove yourself." + }, + "own": { + "role": "You can't change your own role." + }, + "not": { + "found": "That teammate is no longer in this organization." + }, + "reconnect": "Reconnect your profile to manage members.", + "unconfigured": "Organization management is unavailable in this build.", + "failed": "Something went wrong. Please try again." + }, + "subtitle": { + "named": "People in {{orgName}} who can collaborate on Orca.", + "default": "People in your organization who can collaborate on Orca." + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b17bf2dcc03..9727ae4adc4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "プロジェクトはすでに追加されています", "6d3318e813": "repos のインポートに失敗しました", "3be0f7df04": "選択したランタイムでフォルダーを開けません", - "15cf5319ec": "{{path}} は {{hostName}} でチェックされましたが、そのホストは使用可能なフォルダーを報告しませんでした。" + "15cf5319ec": "{{path}} は {{hostName}} でチェックされましたが、そのホストは使用可能なフォルダーを報告しませんでした。", + "2dcd706774": "プロジェクトは別のプロファイルにも存在します", + "presenceProfileOverflow": "{{names}} 他 {{count}} 件" }, "settings": { "e12dab333b": "サーバーの切り替えに失敗しました", @@ -262,6 +264,24 @@ }, "linear": { "37d36984d0": "Linear 接続は新しいリクエストに置き換えられました。" + }, + "orca": { + "profiles": { + "612f7f6861": "プロファイルの作成に失敗しました", + "319d7cf39b": "クラウド プロファイルが作成されました", + "d6e764e7db": "このプロファイルを再接続してください", + "f0c9e11a6d": "クラウド プロファイルの作成に失敗しました", + "8b8fa73174": "Orca Cloud のサインインが構成されていません", + "33290e88ed": "プロファイルの接続に失敗しました", + "9fcb07a796": "プロファイルが接続されました", + "2f6c78a039": "プロファイル認証の更新に失敗しました", + "a37b5e6d37": "プロファイルからサインアウトしました", + "83600521e7": "サインアウトに失敗しました", + "76deec8f58": "組織の切り替えに失敗しました", + "7d4bc516ee": "プロファイルの切り替えに失敗しました", + "f518e89aa5": "プロジェクトはそのプロファイルに既に存在します", + "f03ae7f27b": "プロジェクトの転送に失敗しました" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "インラインの設定リンクとフッターアクション付きの永続プロンプト。", "notificationPlayground": "Notification playground", "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "devOnly": "Dev only", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "ワークスペースでの使用" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "コピー" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "クラウド サインインは利用できません" + }, + "reconnect": "プロファイルを再接続", + "connect": "プロファイルを接続", + "organization": "組織", + "create": { + "profile": { + "for": { + "org": "組織用のプロファイルを作成" + } + } + }, + "signout": "サインアウト", + "16e3681072": "新しいローカル プロファイル", + "e3b91a3d90": "プロジェクトとワークツリーを分けるための空のプロファイルを作成します。", + "f322e1f4d6": "プロファイル名", + "cfa59f8ad1": "作成して切り替え", + "b4f9d1125d": "ローカル", + "4815f7d163": "プロファイルを切り替え", + "d00d853e2a": "プロファイルを管理", + "c106c674fe": "新しいローカル プロファイル", + "account": "アカウント", + "org": { + "members": "組織のメンバー" + } + }, + "management": { + "04e7bd2a23": "転送", + "128c7dfe64": "コピー先", + "df8b7d876b": "移動先", + "9aa26347b3": "プロジェクトを移動しました", + "816ce624b6": "プロジェクトをコピーしました", + "2c45bda8d3": "プロファイルを管理", + "2db945e4a0": "アクティブなプロファイルから別のローカル プロファイルへプロジェクトをコピーまたは移動します。", + "8668cb2946": "このプロファイルにはプロジェクトがありません。", + "93034915ab": "プロジェクトをコピーする前に、別のプロファイルを作成してください。", + "projectCountSingular": "{{count}} 件のプロジェクト", + "projectCountPlural": "{{count}} 件のプロジェクト" + }, + "project": { + "transfer": { + "confirm": { + "move": { + "title": "プロジェクトを移動しますか?", + "description": "{{repoName}} を {{targetName}} に移動します。Orca は {{activeProfileName}} から削除し、ファイルはそのまま残して {{targetName}} で再起動します。", + "action": "プロジェクトを移動" + }, + "copy": { + "title": "プロジェクトをコピーしますか?", + "description": "{{repoName}} を {{targetName}} にコピーします。両方のプロファイルは同じファイルを参照し、Orca のメタデータは別々に保持されます。", + "action": "プロジェクトをコピー" + }, + "live": { + "work": "このプロジェクトで実行中の作業" + }, + "cancel": "キャンセル", + "terminalSingular": "{{count}} 個の実行中ターミナル タブ", + "terminalPlural": "{{count}} 個の実行中ターミナル タブ", + "agentSingular": "{{count}} 個のアクティブなエージェント", + "agentPlural": "{{count}} 個のアクティブなエージェント", + "browserSingular": "{{count}} 個のブラウザ ワークスペース", + "browserPlural": "{{count}} 個のブラウザ ワークスペース" + } + } + }, + "signout": { + "confirm": { + "title": "サインアウトしますか?", + "description": "{{profileName}} からサインアウトし、そのプロジェクト、ワークツリー、ローカル メタデータはこのデバイスに保持します。", + "cancel": "キャンセル", + "action": "サインアウト" + } + }, + "switch": { + "confirm": { + "target": "選択したプロファイル", + "title": "プロファイルを切り替えますか?", + "description": "{{targetName}} に切り替えると、Orca が再起動し、{{activeProfileName}} のワークスペースが再読み込みされます。", + "live": { + "work": "このプロファイルで実行中の作業" + }, + "cancel": "キャンセル", + "switch": "プロファイルを切り替え", + "terminalSingular": "{{count}} 個の実行中ターミナル タブ", + "terminalPlural": "{{count}} 個の実行中ターミナル タブ", + "agentSingular": "{{count}} 個のアクティブなエージェント", + "agentPlural": "{{count}} 個のアクティブなエージェント", + "browserSingular": "{{count}} 個のブラウザ ワークスペース", + "browserPlural": "{{count}} 個のブラウザ ワークスペース" + } + }, + "org": { + "members": { + "role": { + "label": "ロール", + "owner": "オーナー", + "admin": "管理者", + "member": "メンバー" + }, + "not": { + "signed": { + "in": "まだ Orca にサインインしていません。" + } + }, + "remove": { + "confirm": "削除", + "cancel": "キャンセル", + "label": "メンバーを削除" + }, + "title": "組織のメンバー", + "load": { + "error": "組織のメンバーを読み込めませんでした。" + }, + "retry": "再試行", + "pending": { + "title": "保留中の招待", + "revoke": "取り消す" + }, + "invite": { + "title": "招待", + "placeholder": "teammate@example.com", + "role": "招待するロール", + "submit": "送信" + }, + "error": { + "forbidden": "あなたのロールではこの操作を実行できません。", + "conflict": "すでにメンバーまたは招待済みです。", + "remove": { + "self": "自分自身を削除することはできません。" + }, + "own": { + "role": "自分のロールは変更できません。" + }, + "not": { + "found": "そのメンバーはこの組織に存在しません。" + }, + "reconnect": "メンバーを管理するにはプロファイルを再接続してください。", + "unconfigured": "このビルドでは組織の管理を利用できません。", + "failed": "問題が発生しました。もう一度お試しください。" + }, + "subtitle": { + "named": "{{orgName}} で Orca を共同利用できるメンバーです。", + "default": "組織内で Orca を共同利用できるメンバーです。" + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d12f3cdd776..bff86bf9af6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "프로젝트가 이미 추가되었습니다.", "6d3318e813": "repos를 가져오지 못했습니다.", "3be0f7df04": "선택한 런타임에서 폴더를 열 수 없습니다.", - "15cf5319ec": "{{path}}이(가) {{hostName}}에서 확인되었지만 해당 호스트가 사용 가능한 폴더를 보고하지 않았습니다." + "15cf5319ec": "{{path}}이(가) {{hostName}}에서 확인되었지만 해당 호스트가 사용 가능한 폴더를 보고하지 않았습니다.", + "2dcd706774": "프로젝트가 다른 프로필에도 있습니다", + "presenceProfileOverflow": "{{names}} 외 {{count}}개" }, "settings": { "e12dab333b": "서버를 전환하지 못했습니다.", @@ -262,6 +264,24 @@ }, "linear": { "37d36984d0": "더 새로운 요청이 있어 Linear 연결 요청을 건너뛰었습니다." + }, + "orca": { + "profiles": { + "612f7f6861": "프로필을 생성하지 못했습니다", + "319d7cf39b": "클라우드 프로필이 생성되었습니다", + "d6e764e7db": "이 프로필을 다시 연결하세요", + "f0c9e11a6d": "클라우드 프로필을 생성하지 못했습니다", + "8b8fa73174": "Orca Cloud 로그인이 구성되어 있지 않습니다", + "33290e88ed": "프로필을 연결하지 못했습니다", + "9fcb07a796": "프로필이 연결되었습니다", + "2f6c78a039": "프로필 인증을 새로 고치지 못했습니다", + "a37b5e6d37": "프로필에서 로그아웃했습니다", + "83600521e7": "로그아웃하지 못했습니다", + "76deec8f58": "조직을 전환하지 못했습니다", + "7d4bc516ee": "프로필을 전환하지 못했습니다", + "f518e89aa5": "프로젝트가 해당 프로필에 이미 있습니다", + "f03ae7f27b": "프로젝트를 전송하지 못했습니다" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "인라인 설정 링크와 바닥글 작업이 있는 영구 프롬프트입니다.", "notificationPlayground": "Notification playground", "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "devOnly": "Dev only", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "워크스페이스에서 사용" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "복사" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "클라우드 로그인을 사용할 수 없습니다" + }, + "reconnect": "프로필 다시 연결", + "connect": "프로필 연결", + "organization": "조직", + "create": { + "profile": { + "for": { + "org": "조직용 프로필 만들기" + } + } + }, + "signout": "로그아웃", + "16e3681072": "새 로컬 프로필", + "e3b91a3d90": "프로젝트와 워크트리를 분리할 빈 프로필을 만듭니다.", + "f322e1f4d6": "프로필 이름", + "cfa59f8ad1": "만들고 전환", + "b4f9d1125d": "로컬", + "4815f7d163": "프로필 전환", + "d00d853e2a": "프로필 관리", + "c106c674fe": "새 로컬 프로필", + "account": "계정", + "org": { + "members": "조직 구성원" + } + }, + "management": { + "04e7bd2a23": "전송", + "128c7dfe64": "복사 대상", + "df8b7d876b": "이동 대상", + "9aa26347b3": "프로젝트를 이동했습니다", + "816ce624b6": "프로젝트를 복사했습니다", + "2c45bda8d3": "프로필 관리", + "2db945e4a0": "활성 프로필의 프로젝트를 다른 로컬 프로필로 복사하거나 이동합니다.", + "8668cb2946": "이 프로필에는 프로젝트가 없습니다.", + "93034915ab": "프로젝트를 복사하려면 먼저 다른 프로필을 만드세요.", + "projectCountSingular": "{{count}}개의 프로젝트", + "projectCountPlural": "{{count}}개의 프로젝트" + }, + "project": { + "transfer": { + "confirm": { + "move": { + "title": "프로젝트를 이동하시겠습니까?", + "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{activeProfileName}}에서 제거하고 파일은 그대로 유지한 채 {{targetName}}(으)로 다시 시작합니다.", + "action": "프로젝트 이동" + }, + "copy": { + "title": "프로젝트를 복사하시겠습니까?", + "description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필은 같은 파일을 가리키며 Orca 메타데이터는 별도로 유지됩니다.", + "action": "프로젝트 복사" + }, + "live": { + "work": "이 프로젝트에서 진행 중인 작업" + }, + "cancel": "취소", + "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", + "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", + "agentSingular": "{{count}}개의 활성 에이전트", + "agentPlural": "{{count}}개의 활성 에이전트", + "browserSingular": "{{count}}개의 브라우저 워크스페이스", + "browserPlural": "{{count}}개의 브라우저 워크스페이스" + } + } + }, + "signout": { + "confirm": { + "title": "로그아웃하시겠습니까?", + "description": "{{profileName}}에서 로그아웃하고 해당 프로젝트, 워크트리 및 로컬 메타데이터는 이 기기에 유지합니다.", + "cancel": "취소", + "action": "로그아웃" + } + }, + "switch": { + "confirm": { + "target": "선택한 프로필", + "title": "프로필을 전환하시겠습니까?", + "description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 워크스페이스가 다시 로드됩니다.", + "live": { + "work": "이 프로필에서 진행 중인 작업" + }, + "cancel": "취소", + "switch": "프로필 전환", + "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", + "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", + "agentSingular": "{{count}}개의 활성 에이전트", + "agentPlural": "{{count}}개의 활성 에이전트", + "browserSingular": "{{count}}개의 브라우저 워크스페이스", + "browserPlural": "{{count}}개의 브라우저 워크스페이스" + } + }, + "org": { + "members": { + "role": { + "label": "역할", + "owner": "소유자", + "admin": "관리자", + "member": "구성원" + }, + "not": { + "signed": { + "in": "아직 Orca에 로그인하지 않았습니다." + } + }, + "remove": { + "confirm": "삭제", + "cancel": "취소", + "label": "구성원 삭제" + }, + "title": "조직 구성원", + "load": { + "error": "조직 구성원을 불러오지 못했습니다." + }, + "retry": "다시 시도", + "pending": { + "title": "대기 중인 초대", + "revoke": "철회" + }, + "invite": { + "title": "초대", + "placeholder": "teammate@example.com", + "role": "초대 역할", + "submit": "보내기" + }, + "error": { + "forbidden": "현재 역할로는 이 작업을 수행할 수 없습니다.", + "conflict": "이미 구성원이거나 초대되었습니다.", + "remove": { + "self": "자신은 삭제할 수 없습니다." + }, + "own": { + "role": "자신의 역할은 변경할 수 없습니다." + }, + "not": { + "found": "해당 구성원이 더 이상 이 조직에 없습니다." + }, + "reconnect": "구성원을 관리하려면 프로필을 다시 연결하세요.", + "unconfigured": "이 빌드에서는 조직 관리를 사용할 수 없습니다.", + "failed": "문제가 발생했습니다. 다시 시도하세요." + }, + "subtitle": { + "named": "{{orgName}}에서 Orca로 협업할 수 있는 사용자입니다.", + "default": "조직에서 Orca로 협업할 수 있는 사용자입니다." + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b86a41a0354..a0b21163a78 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -247,7 +247,9 @@ "a8e4b3af5b": "项目已添加过了", "6d3318e813": "导入存储库失败", "3be0f7df04": "无法在所选运行时打开文件夹", - "15cf5319ec": "已在 {{hostName}} 上检查 {{path}},但该主机未报告可用文件夹。" + "15cf5319ec": "已在 {{hostName}} 上检查 {{path}},但该主机未报告可用文件夹。", + "2dcd706774": "该项目也存在于另一个配置文件中", + "presenceProfileOverflow": "{{names}} 及另外 {{count}} 个" }, "settings": { "e12dab333b": "切换服务器失败", @@ -262,6 +264,24 @@ }, "linear": { "37d36984d0": "Linear 连接已被新的请求取代。" + }, + "orca": { + "profiles": { + "612f7f6861": "创建配置文件失败", + "319d7cf39b": "已创建云配置文件", + "d6e764e7db": "请重新连接此配置文件", + "f0c9e11a6d": "创建云配置文件失败", + "8b8fa73174": "未配置 Orca Cloud 登录", + "33290e88ed": "连接配置文件失败", + "9fcb07a796": "配置文件已连接", + "2f6c78a039": "刷新配置文件身份验证失败", + "a37b5e6d37": "已退出配置文件", + "83600521e7": "退出登录失败", + "76deec8f58": "切换组织失败", + "7d4bc516ee": "切换配置文件失败", + "f518e89aa5": "该项目已存在于那个配置文件中", + "f03ae7f27b": "转移项目失败" + } } } }, @@ -8747,7 +8767,14 @@ "behindBaseRefToastDescription": "带有内联设置链接和底部操作按钮的持久提示。", "notificationPlayground": "通知测试面板", "notificationPlaygroundDescription": "用于检查提示布局、恢复操作和长文案换行的开发触发器。", - "devOnly": "仅开发" + "devOnly": "仅开发", + "orcaCloud": "Orca Cloud", + "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", + "orcaCloudStatus": "Status", + "orcaCloudSignOut": "Sign out", + "orcaCloudConnect": "Connect profile", + "orcaCloudRefresh": "Refresh status", + "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." }, "EphemeralVmRecipeRow": { "useInWorkspace": "在工作区中使用" @@ -12492,6 +12519,158 @@ "contextMenu": { "copy": "复制" } + }, + "orca": { + "profiles": { + "switcher": { + "cloud": { + "unavailable": "云登录不可用" + }, + "reconnect": "重新连接配置文件", + "connect": "连接配置文件", + "organization": "组织", + "create": { + "profile": { + "for": { + "org": "为组织创建配置文件" + } + } + }, + "signout": "退出登录", + "16e3681072": "新建本地配置文件", + "e3b91a3d90": "创建一个空配置文件,用于分开管理项目和工作树。", + "f322e1f4d6": "配置文件名称", + "cfa59f8ad1": "创建并切换", + "b4f9d1125d": "本地", + "4815f7d163": "切换配置文件", + "d00d853e2a": "管理配置文件", + "c106c674fe": "新建本地配置文件", + "account": "帐户", + "org": { + "members": "组织成员" + } + }, + "management": { + "04e7bd2a23": "转移", + "128c7dfe64": "复制到", + "df8b7d876b": "移动到", + "9aa26347b3": "项目已移动", + "816ce624b6": "项目已复制", + "2c45bda8d3": "管理配置文件", + "2db945e4a0": "将项目从当前配置文件复制或移动到另一个本地配置文件。", + "8668cb2946": "此配置文件中没有项目。", + "93034915ab": "复制项目前,请先创建另一个配置文件。", + "projectCountSingular": "{{count}} 个项目", + "projectCountPlural": "{{count}} 个项目" + }, + "project": { + "transfer": { + "confirm": { + "move": { + "title": "移动项目?", + "description": "将 {{repoName}} 移动到 {{targetName}}。Orca 会将其从 {{activeProfileName}} 中移除,保留文件原位,并重新启动进入 {{targetName}}。", + "action": "移动项目" + }, + "copy": { + "title": "复制项目?", + "description": "将 {{repoName}} 复制到 {{targetName}}。两个配置文件将指向相同的文件,但使用各自独立的 Orca 元数据。", + "action": "复制项目" + }, + "live": { + "work": "此项目中正在进行的工作" + }, + "cancel": "取消", + "terminalSingular": "{{count}} 个正在运行的终端选项卡", + "terminalPlural": "{{count}} 个正在运行的终端选项卡", + "agentSingular": "{{count}} 个活动的智能体", + "agentPlural": "{{count}} 个活动的智能体", + "browserSingular": "{{count}} 个浏览器工作区", + "browserPlural": "{{count}} 个浏览器工作区" + } + } + }, + "signout": { + "confirm": { + "title": "退出登录?", + "description": "退出 {{profileName}},并在此设备上保留其项目、工作树和本地元数据。", + "cancel": "取消", + "action": "退出登录" + } + }, + "switch": { + "confirm": { + "target": "所选配置文件", + "title": "切换配置文件?", + "description": "切换到 {{targetName}} 将重新启动 Orca 并重新加载 {{activeProfileName}} 的工作区。", + "live": { + "work": "此配置文件中正在进行的工作" + }, + "cancel": "取消", + "switch": "切换配置文件", + "terminalSingular": "{{count}} 个正在运行的终端选项卡", + "terminalPlural": "{{count}} 个正在运行的终端选项卡", + "agentSingular": "{{count}} 个活动的智能体", + "agentPlural": "{{count}} 个活动的智能体", + "browserSingular": "{{count}} 个浏览器工作区", + "browserPlural": "{{count}} 个浏览器工作区" + } + }, + "org": { + "members": { + "role": { + "label": "角色", + "owner": "所有者", + "admin": "管理员", + "member": "成员" + }, + "not": { + "signed": { + "in": "他们尚未登录 Orca。" + } + }, + "remove": { + "confirm": "移除", + "cancel": "取消", + "label": "移除成员" + }, + "title": "组织成员", + "load": { + "error": "无法加载组织成员。" + }, + "retry": "重试", + "pending": { + "title": "待处理的邀请", + "revoke": "撤销" + }, + "invite": { + "title": "邀请", + "placeholder": "teammate@example.com", + "role": "邀请角色", + "submit": "发送" + }, + "error": { + "forbidden": "你的角色无法执行此操作。", + "conflict": "已是成员或已被邀请。", + "remove": { + "self": "无法移除你自己。" + }, + "own": { + "role": "无法更改你自己的角色。" + }, + "not": { + "found": "该成员已不在此组织中。" + }, + "reconnect": "请重新连接你的配置文件以管理成员。", + "unconfigured": "此版本不支持组织管理。", + "failed": "出现问题。请重试。" + }, + "subtitle": { + "named": "{{orgName}} 中可以协作使用 Orca 的成员。", + "default": "你的组织中可以协作使用 Orca 的成员。" + } + } + } + } } }, "i18n": { diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index b27c9791718..4fb8bdc8581 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -35,6 +35,7 @@ import { createRuntimeStatusSlice } from './slices/runtime-status' import { createPullRequestGenerationSlice } from './slices/pull-request-generation' import { createCommitMessageGenerationSlice } from './slices/commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm' +import { createOrcaProfilesSlice } from './slices/orca-profiles' import { createNewIssueDraftSlice } from './slices/new-issue-draft' import { e2eConfig } from '@/lib/e2e-config' import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' @@ -75,6 +76,7 @@ export const useAppStore = create()((...a) => ({ ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), ...createPinnedTabCloseConfirmSlice(...a), + ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a) })) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index bb41d9e7fba..15b6f3444ab 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -140,6 +140,7 @@ import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm' +import { createOrcaProfilesSlice } from './orca-profiles' import { createNewIssueDraftSlice } from './new-issue-draft' function createTestStore() { @@ -179,6 +180,7 @@ function createTestStore() { ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), ...createPinnedTabCloseConfirmSlice(...a), + ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a) })) } diff --git a/src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts b/src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts new file mode 100644 index 00000000000..7f4f16abf5c --- /dev/null +++ b/src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { + ConnectCurrentOrcaProfileResult, + CreateCloudLinkedOrcaProfileResult, + OrcaProfileAuthStatus, + OrcaProfileListState, + RefreshCurrentOrcaProfileAuthResult, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult +} from '../../../../shared/orca-profiles' + +const listState: OrcaProfileListState = { + activeProfileId: 'local-default', + profiles: [ + { + id: 'local-default', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] +} + +const localAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' +} + +const connectedCloud = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + linkedAt: 3 +} + +const connectedOrganizations = [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } +] + +const connectedAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: connectedCloud, + organizations: connectedOrganizations, + capabilities: { + flags: { share: true }, + refreshedAt: 4 + } +} + +const orcaProfilesApi = { + list: vi.fn(), + authStatus: vi.fn(), + createLocal: vi.fn(), + createCloudLinked: vi.fn(), + connectCurrent: vi.fn(), + refreshAuth: vi.fn(), + signOutCurrent: vi.fn(), + selectOrg: vi.fn(), + switchProfile: vi.fn(), + transferProject: vi.fn() +} + +describe('orca profile auth actions slice', () => { + beforeEach(() => { + vi.resetAllMocks() + orcaProfilesApi.authStatus.mockResolvedValue(localAuthStatus) + vi.stubGlobal('window', { + api: { + orcaProfiles: orcaProfilesApi + } + }) + }) + + it('connects the current profile and stores returned cloud metadata', async () => { + const connectedProfiles = [ + { + ...listState.profiles[0], + kind: 'cloud-linked' as const, + cloud: connectedAuthStatus.cloud + } + ] + const result: ConnectCurrentOrcaProfileResult = { + status: 'connected', + auth: connectedAuthStatus, + activeProfileId: 'local-default', + profiles: connectedProfiles + } + orcaProfilesApi.connectCurrent.mockResolvedValue(result) + const store = createTestStore() + + const pending = store.getState().connectCurrentOrcaProfile() + + expect(store.getState().orcaProfileConnecting).toBe(true) + await expect(pending).resolves.toEqual(result) + expect(store.getState().orcaProfileConnecting).toBe(false) + expect(store.getState().orcaProfileAuthStatus).toEqual(connectedAuthStatus) + expect(store.getState().orcaProfiles).toEqual(connectedProfiles) + }) + + it('refreshes current profile auth and stores fresh capability flags', async () => { + const refreshedAuthStatus: OrcaProfileAuthStatus = { + ...connectedAuthStatus, + capabilities: { + flags: { share: false, team: true }, + refreshedAt: 8 + } + } + const result: RefreshCurrentOrcaProfileAuthResult = { + status: 'refreshed', + auth: refreshedAuthStatus, + activeProfileId: 'local-default', + profiles: [ + { + ...listState.profiles[0], + kind: 'cloud-linked', + cloud: refreshedAuthStatus.cloud + } + ] + } + orcaProfilesApi.refreshAuth.mockResolvedValue(result) + const store = createTestStore() + + await expect(store.getState().refreshCurrentOrcaProfileAuth()).resolves.toEqual(result) + expect(orcaProfilesApi.refreshAuth).toHaveBeenCalledOnce() + expect(store.getState().orcaProfileAuthStatus).toEqual(refreshedAuthStatus) + expect(store.getState().orcaProfiles).toEqual(result.profiles) + }) + + it('creates a cloud-linked profile and stores the returned profile list', async () => { + const cloudProfile = { + id: 'cloud-acme', + name: 'Acme', + avatar: { kind: 'initials' as const, initials: 'A', color: 'neutral' as const }, + kind: 'cloud-linked' as const, + createdAt: 5, + updatedAt: 5, + lastOpenedAt: 5, + cloud: { + ...connectedCloud, + cloudProfileId: 'cloud-profile-2', + activeOrgId: 'org-1', + activeOrgName: 'Acme' + } + } + const result: CreateCloudLinkedOrcaProfileResult = { + status: 'created', + auth: connectedAuthStatus, + activeProfileId: 'local-default', + profiles: [...listState.profiles, cloudProfile], + profile: cloudProfile + } + orcaProfilesApi.createCloudLinked.mockResolvedValue(result) + const store = createTestStore() + + await expect( + store.getState().createCloudLinkedOrcaProfile({ orgId: 'org-1', name: 'Acme' }) + ).resolves.toEqual(result) + expect(orcaProfilesApi.createCloudLinked).toHaveBeenCalledWith({ + orgId: 'org-1', + name: 'Acme' + }) + expect(store.getState().orcaProfiles).toEqual(result.profiles) + }) + + it('signs out the current profile without dropping local profile data', async () => { + const result: SignOutCurrentOrcaProfileResult = { + status: 'signed-out', + auth: localAuthStatus, + activeProfileId: 'local-default', + profiles: listState.profiles + } + orcaProfilesApi.signOutCurrent.mockResolvedValue(result) + const store = createTestStore() + + await expect(store.getState().signOutCurrentOrcaProfile()).resolves.toEqual(result) + expect(store.getState().orcaProfileAuthStatus).toEqual(localAuthStatus) + expect(store.getState().orcaProfiles).toEqual(listState.profiles) + }) + + it('selects a cloud organization and refreshes auth state', async () => { + const selectedAuthStatus: OrcaProfileAuthStatus = { + ...connectedAuthStatus, + cloud: { + ...connectedCloud, + activeOrgId: 'org-1', + activeOrgName: 'Acme' + } + } + const result: SelectOrcaProfileOrgResult = { + status: 'selected', + auth: selectedAuthStatus, + activeProfileId: 'local-default', + profiles: [ + { + ...listState.profiles[0], + kind: 'cloud-linked', + cloud: selectedAuthStatus.cloud + } + ] + } + orcaProfilesApi.selectOrg.mockResolvedValue(result) + const store = createTestStore() + + await expect(store.getState().selectOrcaProfileOrg('org-1')).resolves.toEqual(result) + expect(orcaProfilesApi.selectOrg).toHaveBeenCalledWith({ orgId: 'org-1' }) + expect(store.getState().orcaProfileAuthStatus).toEqual(selectedAuthStatus) + expect(store.getState().orcaProfileAuthStatus?.organizations).toEqual(connectedOrganizations) + }) +}) diff --git a/src/renderer/src/store/slices/orca-profiles-auth-actions.ts b/src/renderer/src/store/slices/orca-profiles-auth-actions.ts new file mode 100644 index 00000000000..c8212962821 --- /dev/null +++ b/src/renderer/src/store/slices/orca-profiles-auth-actions.ts @@ -0,0 +1,211 @@ +import type { StateCreator } from 'zustand' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { + ConnectCurrentOrcaProfileResult, + CreateCloudLinkedOrcaProfileResult, + RefreshCurrentOrcaProfileAuthResult, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult +} from '../../../../shared/orca-profiles' +import type { AppState } from '../types' + +export type OrcaProfilesAuthActions = { + createCloudLinkedOrcaProfile: (args: { + orgId?: string + name?: string + }) => Promise + connectCurrentOrcaProfile: () => Promise + refreshCurrentOrcaProfileAuth: () => Promise + signOutCurrentOrcaProfile: () => Promise + selectOrcaProfileOrg: (orgId: string) => Promise +} + +// Why a separate module: the cloud-auth actions share the profiles slice's +// state keys but form their own cohesive surface (connect/refresh/sign-out/ +// org selection), and the combined slice file exceeded the repo line budget. +export const createOrcaProfilesAuthActions: StateCreator< + AppState, + [], + [], + OrcaProfilesAuthActions +> = (set, get) => ({ + 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 + } + } +}) diff --git a/src/renderer/src/store/slices/orca-profiles.test.ts b/src/renderer/src/store/slices/orca-profiles.test.ts new file mode 100644 index 00000000000..142037c02fe --- /dev/null +++ b/src/renderer/src/store/slices/orca-profiles.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { + CreateLocalOrcaProfileResult, + OrcaProfileAuthStatus, + OrcaProfileListResult, + TransferOrcaProfileProjectResult +} from '../../../../shared/orca-profiles' + +const { toastErrorMock } = vi.hoisted(() => ({ + toastErrorMock: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { + error: toastErrorMock, + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } +})) + +const listState: OrcaProfileListResult = { + activeProfileId: 'local-default', + multiProfileUi: false, + profiles: [ + { + id: 'local-default', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] +} + +const createdState: CreateLocalOrcaProfileResult = { + activeProfileId: 'local-default', + profiles: [ + ...listState.profiles, + { + id: 'local-work', + name: 'Work', + avatar: { kind: 'initials', initials: 'W', color: 'neutral' }, + kind: 'local', + createdAt: 2, + updatedAt: 2, + lastOpenedAt: 2 + } + ], + profile: { + id: 'local-work', + name: 'Work', + avatar: { kind: 'initials', initials: 'W', color: 'neutral' }, + kind: 'local', + createdAt: 2, + updatedAt: 2, + lastOpenedAt: 2 + } +} + +const localAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' +} + +const connectedAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + linkedAt: 3 + }, + capabilities: { + flags: { share: true }, + refreshedAt: 4 + } +} + +const orcaProfilesApi = { + list: vi.fn(), + authStatus: vi.fn(), + createLocal: vi.fn(), + createCloudLinked: vi.fn(), + connectCurrent: vi.fn(), + refreshAuth: vi.fn(), + signOutCurrent: vi.fn(), + selectOrg: vi.fn(), + switchProfile: vi.fn(), + transferProject: vi.fn() +} + +describe('orca profile slice', () => { + beforeEach(() => { + vi.resetAllMocks() + toastErrorMock.mockReset() + orcaProfilesApi.authStatus.mockResolvedValue(localAuthStatus) + vi.stubGlobal('window', { + api: { + orcaProfiles: orcaProfilesApi + } + }) + }) + + it('fetches profiles into store state', async () => { + orcaProfilesApi.list.mockResolvedValue(listState) + const store = createTestStore() + + await store.getState().fetchOrcaProfiles() + + expect(store.getState().activeOrcaProfileId).toBe('local-default') + expect(store.getState().orcaProfiles).toEqual(listState.profiles) + expect(store.getState().orcaProfileAuthStatus).toEqual(localAuthStatus) + expect(store.getState().orcaProfilesMultiProfileUi).toBe(false) + expect(store.getState().orcaProfilesLoading).toBe(false) + }) + + it('stores the multi-profile UI flag from the list result', async () => { + orcaProfilesApi.list.mockResolvedValue({ ...listState, multiProfileUi: true }) + const store = createTestStore() + + await store.getState().fetchOrcaProfiles() + + expect(store.getState().orcaProfilesMultiProfileUi).toBe(true) + }) + + it('creates a local profile and returns the created summary', async () => { + orcaProfilesApi.createLocal.mockResolvedValue(createdState) + const store = createTestStore() + + const profile = await store.getState().createLocalOrcaProfile('Work') + + expect(profile).toEqual(createdState.profile) + expect(orcaProfilesApi.createLocal).toHaveBeenCalledWith({ name: 'Work' }) + expect(store.getState().orcaProfiles).toEqual(createdState.profiles) + }) + + it('fetches auth status independently', async () => { + orcaProfilesApi.authStatus.mockResolvedValue(connectedAuthStatus) + const store = createTestStore() + + await expect(store.getState().fetchOrcaProfileAuthStatus()).resolves.toEqual( + connectedAuthStatus + ) + expect(store.getState().orcaProfileAuthStatus).toEqual(connectedAuthStatus) + }) + + it('sets switching state while requesting a profile switch', async () => { + orcaProfilesApi.switchProfile.mockResolvedValue({ status: 'relaunching' }) + const store = createTestStore() + store.setState({ activeOrcaProfileId: 'local-default' }) + + const result = await store.getState().switchOrcaProfile('local-work') + + expect(result).toEqual({ status: 'relaunching' }) + expect(orcaProfilesApi.switchProfile).toHaveBeenCalledWith({ profileId: 'local-work' }) + 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' }) + + const result = await store.getState().switchOrcaProfile('local-default') + + expect(result).toEqual({ status: 'already-active' }) + expect(orcaProfilesApi.switchProfile).not.toHaveBeenCalled() + }) + + it('transfers projects through the profile API', async () => { + const transferResult: TransferOrcaProfileProjectResult = { + status: 'transferred', + mode: 'copy', + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + sourceRepoId: 'repo-1', + targetRepoId: 'repo-2', + targetProjectId: 'repo:repo-2' + } + orcaProfilesApi.transferProject.mockResolvedValue(transferResult) + const store = createTestStore() + + const result = await store.getState().transferOrcaProfileProject({ + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + repoId: 'repo-1', + mode: 'copy' + }) + + expect(result).toEqual(transferResult) + expect(orcaProfilesApi.transferProject).toHaveBeenCalledWith({ + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + repoId: 'repo-1', + mode: 'copy' + }) + }) + + it('marks profile switching when a project transfer relaunches the app', async () => { + const transferResult: TransferOrcaProfileProjectResult = { + status: 'transferred', + mode: 'move', + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + sourceRepoId: 'repo-1', + targetRepoId: 'repo-1', + targetProjectId: 'repo:repo-1', + willRelaunch: true + } + orcaProfilesApi.transferProject.mockResolvedValue(transferResult) + const store = createTestStore() + + await store.getState().transferOrcaProfileProject({ + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + repoId: 'repo-1', + mode: 'move' + }) + + expect(store.getState().orcaProfileSwitching).toBe(true) + }) + + it('warns when a project already exists in the target profile', async () => { + const transferResult: TransferOrcaProfileProjectResult = { + status: 'duplicate-target', + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + sourceRepoId: 'repo-1', + duplicateRepoId: 'repo-existing' + } + orcaProfilesApi.transferProject.mockResolvedValue(transferResult) + const store = createTestStore() + + await store.getState().transferOrcaProfileProject({ + sourceProfileId: 'local-default', + targetProfileId: 'local-work', + repoId: 'repo-1', + mode: 'copy' + }) + + expect(toastErrorMock).toHaveBeenCalledWith('Project already exists in that profile') + expect(store.getState().orcaProfileSwitching).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/orca-profiles.ts b/src/renderer/src/store/slices/orca-profiles.ts new file mode 100644 index 00000000000..d885e96b7e3 --- /dev/null +++ b/src/renderer/src/store/slices/orca-profiles.ts @@ -0,0 +1,153 @@ +import type { StateCreator } from 'zustand' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { + OrcaProfileAuthStatus, + OrcaProfileSummary, + SwitchOrcaProfileResult, + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult +} from '../../../../shared/orca-profiles' +import type { AppState } from '../types' +import { + createOrcaProfilesAuthActions, + type OrcaProfilesAuthActions +} from './orca-profiles-auth-actions' + +export type OrcaProfilesSlice = OrcaProfilesAuthActions & { + orcaProfiles: OrcaProfileSummary[] + activeOrcaProfileId: string | null + orcaProfileAuthStatus: OrcaProfileAuthStatus | null + orcaProfilesMultiProfileUi: boolean + orcaProfilesLoading: boolean + orcaProfileSwitching: boolean + orcaProfileConnecting: boolean + fetchOrcaProfiles: () => Promise + fetchOrcaProfileAuthStatus: () => Promise + createLocalOrcaProfile: (name?: string) => Promise + switchOrcaProfile: (profileId: string) => Promise + transferOrcaProfileProject: ( + args: TransferOrcaProfileProjectArgs + ) => Promise +} + +export const createOrcaProfilesSlice: StateCreator = ( + set, + get, + api +) => ({ + orcaProfiles: [], + activeOrcaProfileId: null, + orcaProfileAuthStatus: null, + orcaProfilesMultiProfileUi: false, + orcaProfilesLoading: false, + orcaProfileSwitching: false, + orcaProfileConnecting: false, + + fetchOrcaProfiles: async () => { + set({ orcaProfilesLoading: true }) + try { + const [state, authStatus] = await Promise.all([ + window.api.orcaProfiles.list(), + window.api.orcaProfiles.authStatus() + ]) + set({ + activeOrcaProfileId: state.activeProfileId, + orcaProfiles: state.profiles, + orcaProfilesMultiProfileUi: state.multiProfileUi, + orcaProfileAuthStatus: authStatus, + orcaProfilesLoading: false + }) + } catch (err) { + console.error('Failed to fetch Orca profiles:', err) + set({ orcaProfilesLoading: false }) + } + }, + + fetchOrcaProfileAuthStatus: async () => { + try { + const authStatus = await window.api.orcaProfiles.authStatus() + set({ orcaProfileAuthStatus: authStatus }) + return authStatus + } catch (err) { + console.error('Failed to fetch Orca profile auth status:', err) + return null + } + }, + + createLocalOrcaProfile: async (name) => { + try { + const state = await window.api.orcaProfiles.createLocal({ name }) + set({ + activeOrcaProfileId: state.activeProfileId, + orcaProfiles: state.profiles + }) + void get().fetchOrcaProfileAuthStatus() + return state.profile + } catch (err) { + console.error('Failed to create Orca profile:', err) + toast.error( + translate('auto.store.slices.orca.profiles.612f7f6861', 'Failed to create profile'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + }, + + ...createOrcaProfilesAuthActions(set, get, api), + + switchOrcaProfile: async (profileId) => { + if (!profileId || profileId === get().activeOrcaProfileId) { + return { status: 'already-active' } + } + set({ orcaProfileSwitching: true }) + try { + 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 }) + toast.error( + translate('auto.store.slices.orca.profiles.7d4bc516ee', 'Failed to switch profile'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + }, + + transferOrcaProfileProject: async (args) => { + try { + const result = await window.api.orcaProfiles.transferProject(args) + if (result.status === 'duplicate-target') { + toast.error( + translate( + 'auto.store.slices.orca.profiles.f518e89aa5', + 'Project already exists in that profile' + ) + ) + } + if (result.status === 'transferred' && result.willRelaunch) { + set({ orcaProfileSwitching: true }) + } + return result + } catch (err) { + console.error('Failed to transfer Orca profile project:', err) + toast.error( + translate('auto.store.slices.orca.profiles.f03ae7f27b', 'Failed to transfer project'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + } +}) diff --git a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts new file mode 100644 index 00000000000..738a805bba5 --- /dev/null +++ b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts @@ -0,0 +1,117 @@ +import { beforeEach, vi, type Mock } from 'vitest' +import { toast } from 'sonner' +import type { Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +// Shared harness for the repo-slice runtime-routing suite: sample repos, IPC/runtime +// mocks, and the window stub reset between tests. Extracted to keep the test file itself +// under the max-lines limit. + +export const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +export const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 +} + +export const sshRepo: Repo = { + id: 'ssh-repo', + path: '/home/orca/project', + displayName: 'SSH', + badgeColor: '#222', + addedAt: 3, + connectionId: 'ssh-1' +} + +export const reposList: Mock = vi.fn() +export const reposAdd: Mock = vi.fn() +export const reposPickFolder: Mock = vi.fn() +export const reposClone: Mock = vi.fn() +export const reposCloneRemote: Mock = vi.fn() +export const reposRemove: Mock = vi.fn() +export const reposUpdate: Mock = vi.fn() +export const reposReorder: Mock = vi.fn() +export const projectsCreateHostSetup: Mock = vi.fn() +export const projectsSetupExistingFolder: Mock = vi.fn() +export const projectsUpdateHostSetup: Mock = vi.fn() +export const projectsDeleteHostSetup: Mock = vi.fn() +export const projectsUpdate: Mock = vi.fn() +export const projectGroupsMoveProject: Mock = vi.fn() +export const ptyKill: Mock = vi.fn() +export const runtimeEnvironmentCall: Mock = vi.fn() +export const runtimeEnvironmentTransportCall: Mock = vi.fn() +export const orcaProfileFindProjectProfiles: Mock = vi.fn() + +// Registers the per-test reset + window stub. Call once inside the suite's module scope. +export function installReposRuntimeRoutingHarness(): void { + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.mocked(toast.error).mockReset() + vi.mocked(toast.info).mockReset() + vi.mocked(toast.success).mockReset() + vi.mocked(toast.warning).mockReset() + reposList.mockReset() + reposAdd.mockReset() + reposPickFolder.mockReset() + reposClone.mockReset() + reposCloneRemote.mockReset() + reposRemove.mockReset() + reposUpdate.mockReset() + reposReorder.mockReset() + projectsCreateHostSetup.mockReset() + projectsSetupExistingFolder.mockReset() + projectsUpdateHostSetup.mockReset() + projectsDeleteHostSetup.mockReset() + projectsUpdate.mockReset() + projectGroupsMoveProject.mockReset() + ptyKill.mockReset() + orcaProfileFindProjectProfiles.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + add: reposAdd, + clone: reposClone, + cloneRemote: reposCloneRemote, + pickFolder: reposPickFolder, + remove: reposRemove, + update: reposUpdate, + reorder: reposReorder + }, + projects: { + update: projectsUpdate, + createHostSetup: projectsCreateHostSetup, + setupExistingFolder: projectsSetupExistingFolder, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + }, + projectGroups: { + moveProject: projectGroupsMoveProject + }, + orcaProfiles: { + findProjectProfiles: orcaProfileFindProjectProfiles + }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + }) +} diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 38087dc087e..73eb1927d0e 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,105 +1,38 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import { workItemsCacheKey } from './github' -import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import type { Project, ProjectHostSetup } from '../../../../shared/types' +import { toast } from 'sonner' import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + installReposRuntimeRoutingHarness, + localRepo, + orcaProfileFindProjectProfiles, + projectGroupsMoveProject, + projectsSetupExistingFolder, + ptyKill, + remoteRepo, + reposAdd, + reposClone, + reposCloneRemote, + reposList, + reposPickFolder, + reposRemove, + reposReorder, + reposUpdate, + runtimeEnvironmentCall, + sshRepo +} from './repos-runtime-routing-fixture' -const localRepo: Repo = { - id: 'local-repo', - path: '/local', - displayName: 'Local', - badgeColor: '#000', - addedAt: 1 -} +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } +})) -const remoteRepo: Repo = { - id: 'remote-repo', - path: '/remote', - displayName: 'Remote', - badgeColor: '#111', - addedAt: 2 -} - -const sshRepo: Repo = { - id: 'ssh-repo', - path: '/home/orca/project', - displayName: 'SSH', - badgeColor: '#222', - addedAt: 3, - connectionId: 'ssh-1' -} - -const reposList = vi.fn() -const reposAdd = vi.fn() -const reposPickFolder = vi.fn() -const reposClone = vi.fn() -const reposCloneRemote = vi.fn() -const reposRemove = vi.fn() -const reposUpdate = vi.fn() -const reposReorder = vi.fn() -const projectsCreateHostSetup = vi.fn() -const projectsSetupExistingFolder = vi.fn() -const projectsUpdateHostSetup = vi.fn() -const projectsDeleteHostSetup = vi.fn() -const projectsUpdate = vi.fn() -const projectGroupsMoveProject = vi.fn() -const ptyKill = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - reposList.mockReset() - reposAdd.mockReset() - reposPickFolder.mockReset() - reposClone.mockReset() - reposCloneRemote.mockReset() - reposRemove.mockReset() - reposUpdate.mockReset() - reposReorder.mockReset() - projectsCreateHostSetup.mockReset() - projectsSetupExistingFolder.mockReset() - projectsUpdateHostSetup.mockReset() - projectsDeleteHostSetup.mockReset() - projectsUpdate.mockReset() - projectGroupsMoveProject.mockReset() - ptyKill.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentTransportCall.mockReset() - runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - }) - vi.stubGlobal('window', { - api: { - repos: { - list: reposList, - add: reposAdd, - clone: reposClone, - cloneRemote: reposCloneRemote, - pickFolder: reposPickFolder, - remove: reposRemove, - update: reposUpdate, - reorder: reposReorder - }, - projects: { - update: projectsUpdate, - createHostSetup: projectsCreateHostSetup, - setupExistingFolder: projectsSetupExistingFolder, - updateHostSetup: projectsUpdateHostSetup, - deleteHostSetup: projectsDeleteHostSetup - }, - projectGroups: { - moveProject: projectGroupsMoveProject - }, - pty: { kill: ptyKill }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } - } - }) -}) +installReposRuntimeRoutingHarness() describe('repo slice runtime routing', () => { it('fetches repos from local IPC when no remote environment is active', async () => { @@ -240,6 +173,39 @@ describe('repo slice runtime routing', () => { }) expect(reposAdd).not.toHaveBeenCalled() expect(reposPickFolder).not.toHaveBeenCalled() + expect(orcaProfileFindProjectProfiles).not.toHaveBeenCalled() + }) + + it('warns when a local project is already present in another profile', async () => { + reposAdd.mockResolvedValue({ repo: localRepo }) + orcaProfileFindProjectProfiles.mockResolvedValue({ + projects: [ + { + profileId: 'work', + profileName: 'Work', + profileKind: 'local', + repoId: 'work-repo', + repoName: 'Local' + } + ] + }) + const store = createTestStore() + store.setState({ activeOrcaProfileId: 'local-default' }) + + await expect(store.getState().addRepoPath('/local')).resolves.toEqual({ + ...localRepo, + executionHostId: 'local' + }) + + expect(orcaProfileFindProjectProfiles).toHaveBeenCalledWith({ + path: '/local', + connectionId: null, + executionHostId: 'local', + excludeProfileId: 'local-default' + }) + expect(toast.warning).toHaveBeenCalledWith('Project also exists in another profile', { + description: 'Work' + }) }) it('sets up a project on a local host through the project setup API', async () => { diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 55429bb6e28..6fc31768e28 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -264,6 +264,51 @@ function getSafeAutoForkSyncKey(repo: Repo): string { return `${getRepoExecutionHostId(repo)}:${repo.id}:${repo.path}` } +function formatProjectPresenceProfileNames(profileNames: readonly string[]): string { + const names = [...new Set(profileNames.map((name) => name.trim()).filter(Boolean))] + if (names.length <= 3) { + return names.join(', ') + } + // 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( + repo: Repo, + activeOrcaProfileId: string | null +): Promise { + const findProjectProfiles = window.api.orcaProfiles?.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 { + const result = await findProjectProfiles({ + path: repo.path, + connectionId: repo.connectionId ?? null, + executionHostId: getRepoExecutionHostId(repo), + excludeProfileId: activeOrcaProfileId + }) + const description = formatProjectPresenceProfileNames( + result.projects.map((project) => project.profileName) + ) + if (!description) { + return + } + toast.warning( + translate('auto.store.slices.repos.2dcd706774', 'Project also exists in another profile'), + { description } + ) + } catch (err) { + // Why: adding a project should not fail because an advisory profile scan failed. + console.warn('Failed to check project presence in other profiles:', err) + } +} + function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Repo[]): void { for (const repo of repos) { if (repo.kind === 'folder' || repo.forkSyncMode !== 'safe-auto' || !repo.upstream) { @@ -2238,6 +2283,9 @@ export const createRepoSlice: StateCreator = (set, description: repo.displayName } ) + // 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) { diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 75f06ed18e0..ed3e16c75b1 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -43,6 +43,7 @@ import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm' +import { createOrcaProfilesSlice } from './orca-profiles' import { createNewIssueDraftSlice } from './new-issue-draft' import { translate } from '@/i18n/i18n' @@ -91,6 +92,7 @@ export function createTestStore() { ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), ...createPinnedTabCloseConfirmSlice(...a), + ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a) })) } diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index fedadf016e2..be1a2f191ca 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -33,6 +33,7 @@ import type { RuntimeStatusSlice } from './slices/runtime-status' import type { PullRequestGenerationSlice } from './slices/pull-request-generation' import type { CommitMessageGenerationSlice } from './slices/commit-message-generation' import type { PinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm' +import type { OrcaProfilesSlice } from './slices/orca-profiles' import type { NewIssueDraftSlice } from './slices/new-issue-draft' export type AppState = RepoSlice & @@ -70,4 +71,5 @@ export type AppState = RepoSlice & PullRequestGenerationSlice & CommitMessageGenerationSlice & PinnedTabCloseConfirmSlice & + OrcaProfilesSlice & NewIssueDraftSlice diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 49536f28818..7d42be8ba46 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -46,6 +46,10 @@ import { normalizeWorktreeCardProperties, ONBOARDING_FLOW_VERSION } from '../../../shared/constants' +import { + createDefaultLocalOrcaProfile, + DEFAULT_LOCAL_ORCA_PROFILE_ID +} from '../../../shared/orca-profiles' import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' @@ -455,6 +459,15 @@ export function installWebPreloadApi(): void { } function createWebPreloadApi(): Partial { + const webOrcaProfileAuthStatus = () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + configured: false, + state: 'unconfigured' as const, + persistence: 'none' as const, + setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.' + }) + return { app: { getIdentity: () => @@ -501,6 +514,58 @@ function createWebPreloadApi(): Partial { displayServer: null }) }, + orcaProfiles: { + list: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + multiProfileUi: false + }), + authStatus: webOrcaProfileAuthStatus, + createLocal: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + profile: createDefaultLocalOrcaProfile(0) + }), + createCloudLinked: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + switchProfile: () => Promise.resolve({ status: 'already-active' }), + transferProject: (args) => + Promise.resolve({ + status: 'duplicate-target', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + sourceRepoId: args.repoId, + duplicateRepoId: args.repoId + }), + findProjectProfiles: async () => ({ projects: [] }), + connectCurrent: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + refreshAuth: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + signOutCurrent: async () => ({ + status: 'signed-out', + auth: await webOrcaProfileAuthStatus(), + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)] + }), + selectOrg: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + orgMembersList: async () => ({ status: 'unconfigured' }), + orgMemberInvite: async () => ({ status: 'unconfigured' }), + orgInviteRevoke: async () => ({ status: 'unconfigured' }), + orgMemberChangeRole: async () => ({ status: 'unconfigured' }), + orgMemberRemove: async () => ({ status: 'unconfigured' }) + }, e2e: { getConfig: () => createE2EConfig({}) }, diff --git a/src/shared/orca-profiles.ts b/src/shared/orca-profiles.ts new file mode 100644 index 00000000000..851021a0779 --- /dev/null +++ b/src/shared/orca-profiles.ts @@ -0,0 +1,346 @@ +import { ORCA_BROWSER_PARTITION } from './constants' +import type { ExecutionHostId } from './execution-host' + +export const ORCA_PROFILE_INDEX_SCHEMA_VERSION = 1 +export const DEFAULT_LOCAL_ORCA_PROFILE_ID = 'local-default' +export const DEFAULT_LOCAL_ORCA_PROFILE_NAME = 'Personal' +const LEGACY_ORCA_BROWSER_SESSION_PARTITION_PREFIX = 'persist:orca-browser-session-' + +export type OrcaProfileAvatar = { + kind: 'initials' + initials: string + color: 'neutral' +} + +export type OrcaProfileKind = 'local' | 'cloud-linked' + +export type OrcaProfileCloudSummary = { + cloudProfileId: string + userId: string + email: string + displayName?: string + activeOrgId?: string + activeOrgName?: string + linkedAt: number +} + +export type OrcaCloudOrgSummary = { + orgId: string + name: string + role?: string +} + +export type OrcaCloudCapabilityFlags = Record + +export type OrcaCloudCapabilities = { + flags: OrcaCloudCapabilityFlags + refreshedAt: number +} + +export type OrcaCloudSessionPersistence = 'none' | 'encrypted' | 'memory-only' | 'dev-plaintext' + +export type OrcaProfileAuthState = 'local' | 'unconfigured' | 'connected' | 'reconnect-required' + +export type OrcaProfileAuthStatus = { + activeProfileId: string + configured: boolean + state: OrcaProfileAuthState + persistence: OrcaCloudSessionPersistence + cloud?: OrcaProfileCloudSummary + organizations?: OrcaCloudOrgSummary[] + capabilities?: OrcaCloudCapabilities + credentialError?: string + setupMessage?: string +} + +export type OrcaProfileSummary = { + id: string + name: string + avatar: OrcaProfileAvatar + kind: OrcaProfileKind + createdAt: number + updatedAt: number + lastOpenedAt: number + cloud?: OrcaProfileCloudSummary +} + +export type OrcaProfileIndex = { + schemaVersion: number + activeProfileId: string + profiles: OrcaProfileSummary[] +} + +export type OrcaProfileListState = { + activeProfileId: string + profiles: OrcaProfileSummary[] +} + +export type OrcaProfileListResult = OrcaProfileListState & { + // Why: gates the full multi-profile switcher UI; default builds show a + // single-profile account menu instead. + multiProfileUi: boolean +} + +export type CreateLocalOrcaProfileArgs = { + name?: string +} + +export type CreateLocalOrcaProfileResult = OrcaProfileListState & { + profile: OrcaProfileSummary +} + +export type CreateCloudLinkedOrcaProfileArgs = { + orgId?: string + name?: string +} + +export type SwitchOrcaProfileArgs = { + profileId: string +} + +export type SwitchOrcaProfileResult = { + status: 'already-active' | 'relaunching' +} + +export type TransferOrcaProfileProjectMode = 'move' | 'copy' + +export type TransferOrcaProfileProjectArgs = { + sourceProfileId: string + targetProfileId: string + repoId: string + mode: TransferOrcaProfileProjectMode +} + +export type FindOrcaProfileProjectsByPathArgs = { + path: string + connectionId?: string | null + executionHostId?: ExecutionHostId | null + excludeProfileId?: string | null +} + +export type OrcaProfileProjectPresence = { + profileId: string + profileName: string + profileKind: OrcaProfileKind + repoId: string + repoName: string +} + +export type FindOrcaProfileProjectsByPathResult = { + projects: OrcaProfileProjectPresence[] +} + +export type TransferOrcaProfileProjectResult = + | { + status: 'transferred' + mode: TransferOrcaProfileProjectMode + sourceProfileId: string + targetProfileId: string + sourceRepoId: string + targetRepoId: string + targetProjectId: string | null + willRelaunch?: boolean + } + | { + status: 'duplicate-target' + sourceProfileId: string + targetProfileId: string + sourceRepoId: string + duplicateRepoId: string + } + +export type ConnectCurrentOrcaProfileResult = + | { + status: 'connected' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] + } + | { + status: 'unconfigured' + auth: OrcaProfileAuthStatus + } + | { + status: 'cancelled' + auth: OrcaProfileAuthStatus + } + | { + status: 'failed' + auth: OrcaProfileAuthStatus + error: string + } + +export type CreateCloudLinkedOrcaProfileResult = + | { + status: 'created' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] + profile: OrcaProfileSummary + } + | { + status: 'unconfigured' | 'reconnect-required' + auth: OrcaProfileAuthStatus + } + | { + status: 'failed' + auth: OrcaProfileAuthStatus + error: string + } + +export type SignOutCurrentOrcaProfileResult = { + status: 'signed-out' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] +} + +export type SelectOrcaProfileOrgArgs = { + orgId: string +} + +export type SelectOrcaProfileOrgResult = + | { + status: 'selected' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] + } + | { + status: 'unconfigured' | 'reconnect-required' + auth: OrcaProfileAuthStatus + } + | { + status: 'failed' + auth: OrcaProfileAuthStatus + error: string + } + +export type RefreshCurrentOrcaProfileAuthResult = + | { + status: 'refreshed' + auth: OrcaProfileAuthStatus + activeProfileId: string + profiles: OrcaProfileSummary[] + } + | { + status: 'local' | 'unconfigured' | 'reconnect-required' + auth: OrcaProfileAuthStatus + } + | { + status: 'failed' + auth: OrcaProfileAuthStatus + error: string + } + +// Why: organization roles are a fixed server-side enum; the desktop UI mirrors +// exactly these three so role selects can't drift from what the API accepts. +export type OrcaOrgRole = 'owner' | 'admin' | 'member' + +export type OrcaOrgMember = { + // Why: null for teammates provisioned server-side who never signed into Orca; + // mutation actions are disabled for them since the API keys on a real userId. + userId: string | null + email: string + displayName?: string + role: OrcaOrgRole +} + +export type OrcaOrgPendingInvite = { + email: string + role: OrcaOrgRole + createdAt: number +} + +export type OrcaOrgMembersRoster = { + members: OrcaOrgMember[] + pendingInvites: OrcaOrgPendingInvite[] + viewerRole: OrcaOrgRole + canManageMembers: boolean +} + +export type OrcaProfileOrgMembersListArgs = { + orgId: string +} + +export type OrcaProfileOrgMemberInviteArgs = { + orgId: string + email: string + role: OrcaOrgRole +} + +export type OrcaProfileOrgInviteRevokeArgs = { + orgId: string + email: string +} + +export type OrcaProfileOrgMemberChangeRoleArgs = { + orgId: string + userId: string + role: OrcaOrgRole +} + +export type OrcaProfileOrgMemberRemoveArgs = { + orgId: string + userId: string +} + +export type OrcaProfileOrgMembersListResult = + | { status: 'ok'; roster: OrcaOrgMembersRoster } + | { status: 'unconfigured' | 'reconnect-required' } + | { status: 'failed'; error: string } + +export type OrcaOrgInviteConflictReason = 'already_member' | 'already_invited' +export type OrcaOrgMutationInvalidReason = 'cannot_change_own_role' | 'cannot_remove_self' + +export type OrcaProfileOrgMemberMutationResult = + | { status: 'ok' } + | { status: 'unconfigured' | 'reconnect-required' | 'forbidden' | 'not-found' } + | { status: 'conflict'; reason: OrcaOrgInviteConflictReason } + | { status: 'invalid'; reason: OrcaOrgMutationInvalidReason } + | { status: 'failed'; error: string } + +export function createDefaultLocalOrcaProfile(now: number): OrcaProfileSummary { + return { + id: DEFAULT_LOCAL_ORCA_PROFILE_ID, + name: DEFAULT_LOCAL_ORCA_PROFILE_NAME, + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'local', + createdAt: now, + updatedAt: now, + lastOpenedAt: now + } +} + +function profilePartitionHash(value: string): string { + let hash = 2166136261 + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +export function getOrcaProfileBrowserPartitionSegment(profileId: string): string { + const safe = profileId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 48) || 'profile' + return `${safe}-${profilePartitionHash(profileId)}` +} + +export function getOrcaProfileBrowserDefaultPartition(profileId: string): string { + if (profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID) { + return ORCA_BROWSER_PARTITION + } + return `persist:orca-profile-${getOrcaProfileBrowserPartitionSegment(profileId)}-browser-default` +} + +export function getOrcaProfileBrowserSessionPartition( + profileId: string, + browserSessionProfileId: string +): string { + if (profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID) { + return `${LEGACY_ORCA_BROWSER_SESSION_PARTITION_PREFIX}${browserSessionProfileId}` + } + return `persist:orca-profile-${getOrcaProfileBrowserPartitionSegment( + profileId + )}-browser-session-${browserSessionProfileId}` +} diff --git a/tests/e2e/orca-profiles.spec.ts b/tests/e2e/orca-profiles.spec.ts new file mode 100644 index 00000000000..4d8bef3aec6 --- /dev/null +++ b/tests/e2e/orca-profiles.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from './helpers/orca-app' + +// Why: the multi-profile switcher UI is downscoped behind ORCA_MULTI_PROFILE_UI; +// these specs exercise that full UI, so opt the whole file into the flag. +test.use({ launchEnv: { ORCA_MULTI_PROFILE_UI: '1' } }) + +test('opens the profile switcher and profile dialogs', async ({ orcaPage }) => { + const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ }) + await expect(switcher).toBeVisible() + + await switcher.click() + await expect(orcaPage.getByText('Personal', { exact: true }).first()).toBeVisible() + const manageProfiles = orcaPage.getByRole('menuitem', { name: /Manage profiles/i }) + await expect(manageProfiles).toBeVisible() + await expect(orcaPage.getByRole('menuitem', { name: /New local profile/i })).toBeVisible() + + await manageProfiles.click() + const managementDialog = orcaPage.getByRole('dialog', { name: /Manage profiles/i }) + await expect(managementDialog).toBeVisible() + await expect(managementDialog.getByText(/projects/i).first()).toBeVisible() + + await orcaPage.keyboard.press('Escape') + await expect(managementDialog).toBeHidden() + + await switcher.click() + await orcaPage.getByRole('menuitem', { name: /New local profile/i }).click() + const createDialog = orcaPage.getByRole('dialog', { name: /New local profile/i }) + await expect(createDialog).toBeVisible() + await expect(createDialog.getByPlaceholder(/Profile name/i)).toBeVisible() + await expect(createDialog.getByRole('button', { name: /Create and Switch/i })).toBeVisible() +}) + +test('places the profile switcher in sidebar footer and full-page titlebar', async ({ + orcaPage +}) => { + const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ }) + const settingsButton = orcaPage.getByRole('button', { name: /^Settings$/ }) + await expect(switcher).toBeVisible() + await expect(settingsButton).toBeVisible() + + const sidebarSwitchBox = await switcher.boundingBox() + const settingsBox = await settingsButton.boundingBox() + const viewport = await orcaPage.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight + })) + expect(sidebarSwitchBox).not.toBeNull() + expect(settingsBox).not.toBeNull() + + expect(sidebarSwitchBox!.x + sidebarSwitchBox!.width).toBeLessThanOrEqual(settingsBox!.x + 2) + expect(Math.abs(sidebarSwitchBox!.y - settingsBox!.y)).toBeLessThanOrEqual(2) + expect(sidebarSwitchBox!.y).toBeGreaterThan(viewport.height - 64) + + await settingsButton.click() + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeView)) + .toBe('settings') + + const titlebarSwitchBox = await switcher.boundingBox() + expect(titlebarSwitchBox).not.toBeNull() + expect(titlebarSwitchBox!.x).toBeGreaterThan(viewport.width - 260) + expect(titlebarSwitchBox!.y).toBeLessThan(48) +}) + +test.describe('default single-profile mode', () => { + // Why: no flag — the default build shows no account trigger on a local-only + // (cloud-unconfigured) install. + test.use({ launchEnv: {} }) + + test('hides the account trigger when cloud is unconfigured', async ({ orcaPage }) => { + await expect(orcaPage.getByRole('button', { name: /^Switch profile$/ })).toHaveCount(0) + await expect(orcaPage.getByRole('button', { name: /^Account$/ })).toHaveCount(0) + }) +})