mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
fix(browser): harden Comet cookie import replay
This commit is contained in:
@@ -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<typeof fsModule>('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<typeof fsModule>('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<typeof fsModule>('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<typeof fsModule>('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<typeof childProcessModule>('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<typeof childProcessModule>('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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)}...`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>
|
||||
present: Set<string>
|
||||
}
|
||||
|
||||
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<string> = new Set()
|
||||
): {
|
||||
sessionFromPartitionMock: ReturnType<typeof vi.fn>
|
||||
setupClientHintsOverrideMock: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string>
|
||||
pendingCookieDbPath: string | null
|
||||
pendingCookieImports: Record<string, string>
|
||||
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<BrowserSessionMeta>): 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<string, string> =
|
||||
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<string, string> =
|
||||
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'] })
|
||||
|
||||
@@ -228,7 +228,7 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
|
||||
<SearchableSetting
|
||||
id="browser-session-cookies"
|
||||
title="Session & Cookies"
|
||||
description="Manage browser profiles and import cookies from Chrome, Edge, or other browsers."
|
||||
description="Manage browser profiles and import cookies from Chrome, Edge, Comet, or other browsers."
|
||||
keywords={[
|
||||
'cookies',
|
||||
'session',
|
||||
|
||||
@@ -36,6 +36,7 @@ export const SSH_TERMINATE_RECONNECT_REQUIRED = 'SSH_TERMINATE_RECONNECT_REQUIRE
|
||||
export const BROWSER_FAMILY_LABELS: Record<string, string> = {
|
||||
chrome: 'Google Chrome',
|
||||
chromium: 'Chromium',
|
||||
comet: 'Comet',
|
||||
arc: 'Arc',
|
||||
edge: 'Microsoft Edge',
|
||||
brave: 'Brave',
|
||||
|
||||
+1
-1
@@ -384,7 +384,7 @@ export type BrowserTab = BrowserWorkspace
|
||||
export type BrowserSessionProfileScope = 'default' | 'isolated' | 'imported'
|
||||
|
||||
export type BrowserSessionProfileSource = {
|
||||
browserFamily: 'chrome' | 'chromium' | 'arc' | 'edge' | 'firefox' | 'safari' | 'manual'
|
||||
browserFamily: 'chrome' | 'chromium' | 'arc' | 'edge' | 'firefox' | 'safari' | 'comet' | 'manual'
|
||||
profileName?: string
|
||||
importedAt: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user