diff --git a/src/main/browser/browser-cookie-import.comet.test.ts b/src/main/browser/browser-cookie-import.comet.test.ts new file mode 100644 index 00000000000..c528a6de356 --- /dev/null +++ b/src/main/browser/browser-cookie-import.comet.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as childProcessModule from 'node:child_process' +import type * as fsModule from 'node:fs' + +const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn() +})) + +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents: vi.fn() }, + dialog: { showOpenDialog: dialogShowOpenDialogMock }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { BROWSER_FAMILY_LABELS } from '../../shared/constants' +describe('detectInstalledBrowsers — Comet', () => { + const originalPlatform = process.platform + const originalHome = process.env.HOME + + beforeEach(() => { + // Why: browser-cookie-import.ts uses destructured named imports from + // 'node:fs' which are bound at module-load time. Without vi.resetModules(), + // only the first test's vi.doMock takes effect — subsequent tests see the + // cached module with the first mock still applied. resetModules must run + // BEFORE each doMock so the next import() picks up the fresh mock factory. + vi.resetModules() + Object.defineProperty(process, 'platform', { value: 'darwin' }) + process.env.HOME = '/Users/test' + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + process.env.HOME = originalHome + vi.restoreAllMocks() + }) + + it('detects Comet when its data directory and Cookies DB exist', async () => { + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: (p: string) => { + if (p.includes('Comet/Default/Network/Cookies')) { + return true + } + if (p.includes('Comet/Local State')) { + return true + } + return false + }, + readFileSync: (p: string, enc?: string) => { + if (typeof p === 'string' && p.includes('Comet/Local State')) { + return JSON.stringify({ profile: { info_cache: { Default: { name: 'Default' } } } }) + } + return actual.readFileSync(p as never, enc as never) + } + } + }) + + const { detectInstalledBrowsers } = await import('./browser-cookie-import') + const detected = detectInstalledBrowsers() + const comet = detected.find((b) => b.family === 'comet') + expect(comet).toBeDefined() + expect(comet?.label).toBe('Comet') + expect(comet?.cookiesPath).toContain('Comet/Default/Network/Cookies') + expect(comet?.keychainService).toBe('Comet Safe Storage') + }) + + it('does not list Comet when its data directory is absent', async () => { + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: () => false + } + }) + + const { detectInstalledBrowsers } = await import('./browser-cookie-import') + const detected = detectInstalledBrowsers() + expect(detected.find((b) => b.family === 'comet')).toBeUndefined() + }) + + it('enumerates all Comet profiles from Local State info_cache', async () => { + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: (p: string) => { + if (p.includes('Comet/Default/Network/Cookies')) { + return true + } + if (p.includes('Comet/Local State')) { + return true + } + return false + }, + readFileSync: (p: string, enc?: string) => { + if (typeof p === 'string' && p.includes('Comet/Local State')) { + return JSON.stringify({ + profile: { + info_cache: { + Default: { name: 'Personal' }, + 'Profile 1': { name: 'Work' }, + 'Profile 2': { name: 'Research' } + } + } + }) + } + return actual.readFileSync(p as never, enc as never) + } + } + }) + + const { detectInstalledBrowsers } = await import('./browser-cookie-import') + const detected = detectInstalledBrowsers() + const comet = detected.find((b) => b.family === 'comet') + expect(comet).toBeDefined() + const directories = comet!.profiles.map((p) => p.directory).sort() + expect(directories).toEqual(['Default', 'Profile 1', 'Profile 2']) + const names = comet!.profiles.map((p) => p.name).sort() + expect(names).toEqual(['Personal', 'Research', 'Work']) + }) + + it('skips Comet when the data directory exists but no Cookies DB is present', async () => { + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: (p: string) => { + if (p.includes('Comet/Local State')) { + return true + } + if (p.includes('Network/Cookies') || p.endsWith('/Cookies')) { + return false + } + return false + }, + readFileSync: (p: string, enc?: string) => { + if (typeof p === 'string' && p.includes('Comet/Local State')) { + return JSON.stringify({ profile: { info_cache: { Default: { name: 'Default' } } } }) + } + return actual.readFileSync(p as never, enc as never) + } + } + }) + + const { detectInstalledBrowsers } = await import('./browser-cookie-import') + const detected = detectInstalledBrowsers() + expect(detected.find((b) => b.family === 'comet')).toBeUndefined() + }) +}) + +describe('getUserAgentForBrowser — Comet', () => { + const originalPlatform = process.platform + + beforeEach(() => { + vi.resetModules() + Object.defineProperty(process, 'platform', { value: 'darwin' }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + vi.restoreAllMocks() + }) + + it('returns a Chrome-shaped UA string when Comet plist version reads successfully', async () => { + vi.doMock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process') + return { + ...actual, + execFileSync: (cmd: string, args: readonly string[]) => { + if (cmd === 'defaults' && args[1]?.includes('/Applications/Comet.app/Contents/Info')) { + return '120.0.6099.71\n' + } + return actual.execFileSync(cmd, args as never) + } + } + }) + + const { getUserAgentForBrowser } = await import('./browser-cookie-import') + const ua = getUserAgentForBrowser('comet') + + expect(ua).not.toBeNull() + expect(ua).toContain('Macintosh; Intel Mac OS X 10_15_7') + expect(ua).toContain('AppleWebKit/537.36') + expect(ua).toContain('Chrome/120.0.6099.71') + expect(ua).toContain('Safari/537.36') + }) + + it('returns null when reading the Comet plist version throws', async () => { + vi.doMock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process') + return { + ...actual, + execFileSync: () => { + throw new Error('defaults: domain not found') + } + } + }) + + const { getUserAgentForBrowser } = await import('./browser-cookie-import') + const ua = getUserAgentForBrowser('comet') + expect(ua).toBeNull() + }) + + it('returns null on non-darwin platforms regardless of family', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + const { getUserAgentForBrowser } = await import('./browser-cookie-import') + const ua = getUserAgentForBrowser('comet') + expect(ua).toBeNull() + }) +}) + +describe('BROWSER_FAMILY_LABELS — Comet', () => { + it('maps the comet family key to the user-facing label "Comet"', () => { + expect(BROWSER_FAMILY_LABELS.comet).toBe('Comet') + }) +}) diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 73698fbcdbe..4bc358ab5f6 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -227,7 +227,7 @@ describe('detectInstalledBrowsers', () => { it('each detected browser has a valid family', () => { const browsers = detectInstalledBrowsers() - const validFamilies = ['chrome', 'edge', 'arc', 'chromium', 'firefox', 'safari'] + const validFamilies = ['chrome', 'edge', 'arc', 'chromium', 'firefox', 'safari', 'comet'] for (const browser of browsers) { expect(validFamilies).toContain(browser.family) } diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index dfbdcd4f155..2da3a85b224 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -2,12 +2,13 @@ that must stay together so the encryption, schema, and staging steps remain in sync. */ import { app, type BrowserWindow, dialog, session } from 'electron' import { execFileSync } from 'node:child_process' -import { createDecipheriv, pbkdf2Sync } from 'node:crypto' +import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' import { appendFileSync, copyFileSync, existsSync, mkdtempSync, + mkdirSync, readFileSync, readdirSync, rmSync, @@ -113,6 +114,15 @@ const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ macRoot: 'BraveSoftware/Brave-Browser', winRoot: 'BraveSoftware/Brave-Browser/User Data', linuxRoot: 'BraveSoftware/Brave-Browser' + }, + { + family: 'comet', + label: 'Comet', + keychainService: 'Comet Safe Storage', + keychainAccount: 'Comet', + macRoot: 'Comet', + winRoot: 'Comet/User Data' + // linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15 } ] @@ -658,7 +668,7 @@ export async function importCookiesFromFile( // Why: Google and other services bind auth cookies to the User-Agent that // created them. We read the source browser's real version from its plist // and construct a matching UA string so imported sessions aren't invalidated. -function getUserAgentForBrowser( +export function getUserAgentForBrowser( family: BrowserSessionProfileSource['browserFamily'] ): string | null { // Why: UA spoofing uses macOS-specific plist reading. On other platforms, @@ -703,6 +713,12 @@ function getUserAgentForBrowser( const v = readBrowserVersion('/Applications/Brave Browser.app') return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null } + case 'comet': { + // Why: Comet is Chromium-based and ships a Chrome-shaped version in its plist. + // Use the same UA shape as Chrome itself so Google-bound auth cookies survive import. + const v = readBrowserVersion('/Applications/Comet.app') + return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null + } default: return null } @@ -1343,8 +1359,14 @@ export async function importCookiesFromBrowser( return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } } - const stagingCookiesPath = join(app.getPath('userData'), 'Cookies-staged') + const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') + const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') + const stagingCookiesPath = join( + stagingDir, + `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` + ) try { + mkdirSync(stagingDir, { recursive: true }) copyFileSync(liveCookiesPath, stagingCookiesPath) } catch { rmSync(tmpDir, { recursive: true, force: true }) @@ -1565,7 +1587,7 @@ export async function importCookiesFromBrowser( // Why: some cookies couldn't be loaded via cookies.set() (non-ASCII values // or other validation failures). Keep the staging DB so the next cold start // picks them up from SQLite where CookieMonster reads them without validation. - browserSessionRegistry.setPendingCookieImport(stagingCookiesPath) + browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) } else { try { @@ -1580,7 +1602,7 @@ export async function importCookiesFromBrowser( if (ua) { targetSession.setUserAgent(ua) setupClientHintsOverride(targetSession, ua) - browserSessionRegistry.persistUserAgent(ua) + browserSessionRegistry.persistUserAgent(targetPartition, ua) diag(` set UA for partition: ${ua.substring(0, 80)}...`) } diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts new file mode 100644 index 00000000000..18d9fc89931 --- /dev/null +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const USER_DATA = '/user-data' +const META_PATH = `${USER_DATA}/browser-session-meta.json` + +type FsState = { + files: Map + present: Set +} + +function createFsState(): FsState { + return { files: new Map(), present: new Set() } +} + +function seedMeta(fsState: FsState, meta: unknown): void { + const raw = JSON.stringify(meta) + fsState.files.set(META_PATH, raw) + fsState.present.add(META_PATH) +} + +function installModuleMocks( + fsState: FsState, + copyFailures: Set = new Set() +): { + sessionFromPartitionMock: ReturnType + setupClientHintsOverrideMock: ReturnType +} { + const sessionFromPartitionMock = vi.fn((partition: string) => ({ + partition, + setUserAgent: vi.fn(), + getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/31 Orca'), + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn(), + on: vi.fn(), + clearStorageData: vi.fn().mockResolvedValue(undefined), + clearCache: vi.fn().mockResolvedValue(undefined) + })) + const setupClientHintsOverrideMock = vi.fn() + + vi.doMock('electron', () => ({ + app: { getPath: vi.fn(() => USER_DATA) }, + session: { fromPartition: sessionFromPartitionMock }, + systemPreferences: { + askForMediaAccess: vi.fn().mockResolvedValue(true), + getMediaAccessStatus: vi.fn(() => 'granted') + } + })) + + vi.doMock('node:fs', () => ({ + copyFileSync: vi.fn((src: string, dst: string) => { + if (copyFailures.has(src)) { + throw new Error(`copy fail for ${src}`) + } + fsState.present.add(dst) + const value = fsState.files.get(src) + if (value !== undefined) { + fsState.files.set(dst, value) + } + }), + existsSync: vi.fn((p: string) => fsState.present.has(p)), + mkdirSync: vi.fn(), + readFileSync: vi.fn((p: string) => { + const v = fsState.files.get(p) + if (v === undefined) { + throw new Error('ENOENT') + } + return v + }), + renameSync: vi.fn((from: string, to: string) => { + const v = fsState.files.get(from) + if (v === undefined) { + throw new Error('ENOENT') + } + fsState.files.set(to, v) + fsState.present.add(to) + fsState.files.delete(from) + fsState.present.delete(from) + }), + unlinkSync: vi.fn((p: string) => { + fsState.present.delete(p) + fsState.files.delete(p) + }), + writeFileSync: vi.fn((p: string, data: string | Uint8Array) => { + const value = typeof data === 'string' ? data : Buffer.from(data).toString('utf-8') + fsState.files.set(p, value) + fsState.present.add(p) + }) + })) + + vi.doMock('./browser-manager', () => ({ + browserManager: { + notifyPermissionDenied: vi.fn(), + handleGuestWillDownload: vi.fn() + } + })) + vi.doMock('./browser-media-access', () => ({ + hasSystemMediaAccess: vi.fn(() => true), + requestSystemMediaAccess: vi.fn().mockResolvedValue(true) + })) + vi.doMock('./browser-session-ua', () => ({ + cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')), + setupClientHintsOverride: setupClientHintsOverrideMock + })) + + return { sessionFromPartitionMock, setupClientHintsOverrideMock } +} + +describe('BrowserSessionRegistry persistence', () => { + beforeEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + it('migrates and consumes legacy pendingCookieDbPath into default partition replay', async () => { + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + pendingCookieDbPath: '/staged/legacy', + profiles: [] + }) + fsState.present.add('/staged/legacy') + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.applyPendingCookieImport() + + const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') + expect(written.pendingCookieDbPath).toBeNull() + expect(written.pendingCookieImports).toEqual({}) + expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true) + }) + + it('merges partition-keyed pending entries without clobbering unrelated entries', async () => { + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: null, + pendingCookieImports: {}, + profiles: [] + }) + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.setPendingCookieImport('persist:orca-browser', '/staged/default') + browserSessionRegistry.setPendingCookieImport( + 'persist:orca-browser-session-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + '/staged/imported' + ) + + const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') + expect(written.pendingCookieDbPath).toBe('/staged/default') + expect(written.pendingCookieImports).toEqual({ + 'persist:orca-browser': '/staged/default', + 'persist:orca-browser-session-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa': '/staged/imported' + }) + }) + + it('restores persisted UA for non-default partitions', async () => { + const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' + const importedUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36' + const defaultUa = 'Mozilla/5.0 Chrome/119.0.0.0 Safari/537.36' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: defaultUa, + userAgentByPartition: { + 'persist:orca-browser': defaultUa, + [importedPartition]: importedUa + }, + pendingCookieDbPath: null, + pendingCookieImports: {}, + profiles: [ + { + id: '11111111-1111-4111-8111-111111111111', + scope: 'imported', + partition: importedPartition, + label: 'Imported', + source: { browserFamily: 'comet', importedAt: 1 } + } + ] + }) + + const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.restorePersistedUserAgent() + + const importedSessions = sessionFromPartitionMock.mock.results + .filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === importedPartition) + .map((r) => r.value) + expect(importedSessions.length).toBeGreaterThan(0) + expect( + importedSessions.some((s) => + s.setUserAgent.mock.calls.some((c: unknown[]) => c[0] === importedUa) + ) + ).toBe(true) + expect( + setupClientHintsOverrideMock.mock.calls.some( + (c: unknown[]) => + (c[0] as { partition?: string } | undefined)?.partition === importedPartition && + c[1] === importedUa + ) + ).toBe(true) + }) + + it('keeps failed partition replay pending and removes unrelated missing entries', async () => { + const importedPartition = 'persist:orca-browser-session-22222222-2222-4222-8222-222222222222' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: null, + pendingCookieImports: { + [importedPartition]: '/staged/imported', + 'persist:orca-browser': '/staged/missing' + }, + profiles: [ + { + id: '22222222-2222-4222-8222-222222222222', + scope: 'imported', + partition: importedPartition, + label: 'Imported', + source: { browserFamily: 'comet', importedAt: 1 } + } + ] + }) + fsState.present.add('/staged/imported') + + installModuleMocks(fsState, new Set(['/staged/imported'])) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.applyPendingCookieImport() + + const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') + expect(written.pendingCookieImports).toEqual({ [importedPartition]: '/staged/imported' }) + expect(written.pendingCookieDbPath).toBeNull() + }) +}) diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 6a51a95c1bc..e6915755175 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -122,6 +122,17 @@ describe('BrowserSessionRegistry', () => { expect(updated!.source?.browserFamily).toBe('edge') }) + it('updates profile source with comet family', () => { + const profile = browserSessionRegistry.createProfile('imported', 'Comet Source Test') + expect(profile).not.toBeNull() + const updated = browserSessionRegistry.updateProfileSource(profile!.id, { + browserFamily: 'comet', + importedAt: Date.now() + }) + expect(updated).not.toBeNull() + expect(updated!.source?.browserFamily).toBe('comet') + }) + it('deletes a non-default profile', async () => { const profile = browserSessionRegistry.createProfile('isolated', 'Delete Test') expect(profile).not.toBeNull() diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index cf6d3fffa11..18d98cf8faf 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto' import { copyFileSync, existsSync, + mkdirSync, readFileSync, renameSync, unlinkSync, @@ -22,7 +23,9 @@ import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-sess type BrowserSessionMeta = { defaultSource: BrowserSessionProfile['source'] userAgent: string | null + userAgentByPartition: Record pendingCookieDbPath: string | null + pendingCookieImports: Record profiles: BrowserSessionProfile[] } @@ -57,6 +60,11 @@ class BrowserSessionRegistry { return this.loadPersistedMeta().defaultSource } + private static partitionCookiesPath(partition: string): string { + const partitionName = partition.replace('persist:', '') + return join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies') + } + // Why: write-to-temp-then-rename is atomic on all supported platforms. // A crash mid-write would only lose the temp file, not corrupt the live one. private persistMeta(updates: Partial): void { @@ -88,14 +96,41 @@ class BrowserSessionRegistry { try { const raw = readFileSync(this.metadataPath, 'utf-8') const data = JSON.parse(raw) + const legacyUserAgent = typeof data?.userAgent === 'string' ? data.userAgent : null + const userAgentByPartition: Record = + data && typeof data.userAgentByPartition === 'object' && data.userAgentByPartition + ? { ...data.userAgentByPartition } + : {} + if (legacyUserAgent && !userAgentByPartition[ORCA_BROWSER_PARTITION]) { + userAgentByPartition[ORCA_BROWSER_PARTITION] = legacyUserAgent + } + + const legacyPendingCookieDbPath = + typeof data?.pendingCookieDbPath === 'string' ? data.pendingCookieDbPath : null + const pendingCookieImports: Record = + data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports + ? { ...data.pendingCookieImports } + : {} + if (legacyPendingCookieDbPath && !pendingCookieImports[ORCA_BROWSER_PARTITION]) { + pendingCookieImports[ORCA_BROWSER_PARTITION] = legacyPendingCookieDbPath + } return { defaultSource: data?.defaultSource ?? null, - userAgent: data?.userAgent ?? null, - pendingCookieDbPath: data?.pendingCookieDbPath ?? null, + userAgent: legacyUserAgent, + userAgentByPartition, + pendingCookieDbPath: legacyPendingCookieDbPath, + pendingCookieImports, profiles: Array.isArray(data?.profiles) ? data.profiles : [] } } catch { - return { defaultSource: null, userAgent: null, pendingCookieDbPath: null, profiles: [] } + return { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: null, + pendingCookieImports: {}, + profiles: [] + } } } @@ -110,22 +145,6 @@ class BrowserSessionRegistry { // after app is ready) ensures the default profile's source is populated. restorePersistedUserAgent(): void { const meta = this.loadPersistedMeta() - if (meta.userAgent) { - const sess = session.fromPartition(ORCA_BROWSER_PARTITION) - sess.setUserAgent(meta.userAgent) - setupClientHintsOverride(sess, meta.userAgent) - } else { - // Why: even without an imported session, the default Electron UA contains - // "Electron/X.X.X" and the app name which trip Cloudflare Turnstile. - try { - const sess = session.fromPartition(ORCA_BROWSER_PARTITION) - const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) - sess.setUserAgent(cleanUA) - setupClientHintsOverride(sess, cleanUA) - } catch { - /* session not available yet (e.g. unit tests or pre-ready) */ - } - } if (meta.defaultSource) { const current = this.profiles.get('default') if (current && current.source === null) { @@ -135,6 +154,30 @@ class BrowserSessionRegistry { if (meta.profiles.length > 0) { this.hydrateFromPersisted(meta.profiles) } + + const partitions = new Set([ + ORCA_BROWSER_PARTITION, + ...this.listProfiles().map((p) => p.partition) + ]) + for (const partition of partitions) { + try { + const sess = session.fromPartition(partition) + const persistedUa = meta.userAgentByPartition[partition] + if (persistedUa) { + sess.setUserAgent(persistedUa) + setupClientHintsOverride(sess, persistedUa) + continue + } + + // Why: even without an imported session, the default Electron UA contains + // "Electron/X.X.X" and the app name which trip Cloudflare Turnstile. + const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) + sess.setUserAgent(cleanUA) + setupClientHintsOverride(sess, cleanUA) + } catch { + /* session not available yet (e.g. unit tests or pre-ready) */ + } + } } // Why: the import writes cookies to a staging DB because CookieMonster holds @@ -144,56 +187,98 @@ class BrowserSessionRegistry { applyPendingCookieImport(): void { try { const meta = this.loadPersistedMeta() - if (!meta.pendingCookieDbPath) { - return - } - if (!existsSync(meta.pendingCookieDbPath)) { - this.persistMeta({ pendingCookieDbPath: null }) + const pendingEntries = Object.entries(meta.pendingCookieImports) + if (pendingEntries.length === 0) { return } + const knownPartitions = new Set([ + ORCA_BROWSER_PARTITION, + ...meta.profiles.map((p) => p.partition) + ]) + const remainingEntries = { ...meta.pendingCookieImports } - const partitionName = ORCA_BROWSER_PARTITION.replace('persist:', '') - const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies') - - copyFileSync(meta.pendingCookieDbPath, liveCookiesPath) - // Why: SQLite WAL mode stores uncommitted data in sidecar files. - // Stale WAL/SHM from a previous session could corrupt CookieMonster's - // read of the freshly swapped DB. - for (const suffix of ['-wal', '-shm']) { - try { - unlinkSync(liveCookiesPath + suffix) - } catch { - /* may not exist */ + for (const [partition, stagedPath] of pendingEntries) { + if (!knownPartitions.has(partition)) { + delete remainingEntries[partition] + continue } - const stagingSidecar = meta.pendingCookieDbPath + suffix - if (existsSync(stagingSidecar)) { - try { - copyFileSync(stagingSidecar, liveCookiesPath + suffix) - } catch { - /* best-effort */ + if (!existsSync(stagedPath)) { + delete remainingEntries[partition] + continue + } + + const liveCookiesPath = BrowserSessionRegistry.partitionCookiesPath(partition) + try { + mkdirSync(join(liveCookiesPath, '..'), { recursive: true }) + copyFileSync(stagedPath, liveCookiesPath) + // Why: SQLite WAL mode stores uncommitted data in sidecar files. + // Stale WAL/SHM from a previous session could corrupt CookieMonster's + // read of the freshly swapped DB. + let sidecarCopyFailed = false + for (const suffix of ['-wal', '-shm']) { + try { + unlinkSync(liveCookiesPath + suffix) + } catch { + /* may not exist */ + } + const stagingSidecar = stagedPath + suffix + if (!existsSync(stagingSidecar)) { + continue + } + try { + copyFileSync(stagingSidecar, liveCookiesPath + suffix) + } catch { + sidecarCopyFailed = true + } } - } - } - for (const ext of ['', '-wal', '-shm']) { - try { - unlinkSync(`${meta.pendingCookieDbPath}${ext}`) + if (sidecarCopyFailed) { + // Why: sidecar copy failures can leave an inconsistent replay state. + // Keep this entry for retry and preserve unrelated entries. + continue + } + for (const ext of ['', '-wal', '-shm']) { + try { + unlinkSync(`${stagedPath}${ext}`) + } catch { + /* best-effort */ + } + } + delete remainingEntries[partition] } catch { - /* best-effort */ + // Why: failed replay for one partition should not drop unrelated entries. + // Keep this entry for retry next launch. } } - this.persistMeta({ pendingCookieDbPath: null }) + this.persistMeta({ + pendingCookieImports: remainingEntries, + pendingCookieDbPath: remainingEntries[ORCA_BROWSER_PARTITION] ?? null + }) } catch { // best-effort — if this fails, CookieMonster loads the old DB } } - setPendingCookieImport(stagingDbPath: string): void { - this.persistMeta({ pendingCookieDbPath: stagingDbPath }) + setPendingCookieImport(partition: string, stagingDbPath: string): void { + const meta = this.loadPersistedMeta() + const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath } + this.persistMeta({ + pendingCookieImports, + pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null + }) } - persistUserAgent(userAgent: string | null): void { - const defaultProfile = this.profiles.get('default') - this.persistSource(defaultProfile?.source ?? null, userAgent) + persistUserAgent(partition: string, userAgent: string | null): void { + const meta = this.loadPersistedMeta() + const userAgentByPartition = { ...meta.userAgentByPartition } + if (userAgent) { + userAgentByPartition[partition] = userAgent + } else { + delete userAgentByPartition[partition] + } + this.persistMeta({ + userAgentByPartition, + userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null + }) } getDefaultProfile(): BrowserSessionProfile { @@ -273,6 +358,17 @@ class BrowserSessionRegistry { } this.profiles.delete(profileId) this.persistProfiles() + const meta = this.loadPersistedMeta() + const pendingCookieImports = { ...meta.pendingCookieImports } + delete pendingCookieImports[profile.partition] + const userAgentByPartition = { ...meta.userAgentByPartition } + delete userAgentByPartition[profile.partition] + this.persistMeta({ + pendingCookieImports, + pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null, + userAgentByPartition, + userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null + }) // Why: clearing the partition's storage prevents orphaned cookies/cache from // lingering after the user deletes an imported or isolated session profile. @@ -298,7 +394,18 @@ class BrowserSessionRegistry { if (defaultProfile) { this.profiles.set('default', { ...defaultProfile, source: null }) } - this.persistMeta({ defaultSource: null, userAgent: null, pendingCookieDbPath: null }) + const meta = this.loadPersistedMeta() + const pendingCookieImports = { ...meta.pendingCookieImports } + delete pendingCookieImports[ORCA_BROWSER_PARTITION] + const userAgentByPartition = { ...meta.userAgentByPartition } + delete userAgentByPartition[ORCA_BROWSER_PARTITION] + this.persistMeta({ + defaultSource: null, + userAgent: null, + userAgentByPartition, + pendingCookieDbPath: null, + pendingCookieImports + }) const sess = session.fromPartition(ORCA_BROWSER_PARTITION) await sess.clearStorageData({ storages: ['cookies'] }) diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 2d7d9ef7bd7..d28f5edffec 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -228,7 +228,7 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea