From 2249330acfe4e50ba32177e7a0fe236f4fe22e33 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:33:50 -0700 Subject: [PATCH] fix(browser): bulk clear cookies during native import (#13966) * fix(browser): bulk clear cookies during native import * fix(browser): separate Google cookie import warning * fix(browser): report encrypted Google cookie exclusions * fix(browser): skip key lookup for excluded cookies * test(browser): cover excluded-cookie key bypass * perf(browser): keep plaintext key scan cheap --- ...ser-cookie-import-google-exclusion.test.ts | 85 +++++++++- .../browser-cookie-import-policy.test.ts | 153 ++++++++---------- .../browser/browser-cookie-import-policy.ts | 88 +++++----- .../browser-cookie-import-replacement.test.ts | 4 + .../browser/browser-cookie-import.test.ts | 5 +- src/main/browser/browser-cookie-import.ts | 46 ++++-- src/renderer/src/i18n/locales/en.json | 3 +- .../lib/browser-cookie-import-toast.test.ts | 48 ++++++ .../src/lib/browser-cookie-import-toast.ts | 17 +- src/shared/types.ts | 1 + 10 files changed, 295 insertions(+), 155 deletions(-) diff --git a/src/main/browser/browser-cookie-import-google-exclusion.test.ts b/src/main/browser/browser-cookie-import-google-exclusion.test.ts index 24472f8c828..5de16182bf9 100644 --- a/src/main/browser/browser-cookie-import-google-exclusion.test.ts +++ b/src/main/browser/browser-cookie-import-google-exclusion.test.ts @@ -89,6 +89,7 @@ describe('file import excludes the Google cookie family', () => { totalCookies: 4, importedCookies: 2, skippedCookies: 2, + googleCookiesSkipped: 2, domains: ['linear.app', 'youtube.com'] }) expect(cookiesRemoveMock.mock.calls).toEqual([['https://linear.app/', 'old-linear']]) @@ -110,6 +111,7 @@ describe('file import excludes the Google cookie family', () => { totalCookies: 1, importedCookies: 0, skippedCookies: 1, + googleCookiesSkipped: 1, domains: [] }) expect(cookiesRemoveMock).not.toHaveBeenCalled() @@ -164,7 +166,7 @@ describe('native Chromium import excludes the Google cookie family', () => { createChromiumCookieTestDatabase(targetCookiesPath, rows).close() } - it('never wipes the jar wholesale and keeps the live Google cookies', async () => { + it('bulk clears once and restores the live Google cookies before importing', async () => { const sourceCookiesPath = seedSource([ { domain: '.google.com', name: 'SID', value: 'transplanted-sid' }, { domain: '.example.com', name: 'session', value: 'new' } @@ -181,11 +183,67 @@ describe('native Chromium import excludes the Google cookie family', () => { totalCookies: 2, importedCookies: 1, skippedCookies: 1, + googleCookiesSkipped: 1, domains: ['example.com'] }) + expect(clearStorageDataMock).toHaveBeenCalledOnce() + expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] }) + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual([ + '.google.com', + '.example.com' + ]) + }) + + it('reports an excluded Google row even when its encrypted value is invalid', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { + domain: '.google.com', + name: 'SID', + value: '', + encryptedValue: Buffer.from('v10-invalid') + }, + { domain: '.example.com', name: 'session', value: 'new' } + ]).close() + seedTarget([]) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 2, + importedCookies: 1, + skippedCookies: 1, + googleCookiesSkipped: 1 + }) + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['session']) + }) + + it('does not request an encryption key for excluded Google rows', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { + domain: '.google.com', + name: 'SID', + value: '', + encryptedValue: Buffer.from('v10-invalid') + } + ]).close() + seedTarget([]) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok && result.summary).toEqual({ + totalCookies: 1, + importedCookies: 0, + skippedCookies: 1, + googleCookiesSkipped: 1, + domains: [] + }) + expect(execFileSyncMock).not.toHaveBeenCalled() expect(clearStorageDataMock).not.toHaveBeenCalled() - expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'stale']]) - expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual(['.example.com']) + expect(cookiesSetMock).not.toHaveBeenCalled() }) it('keeps the live Google rows in the staged restart-fallback database', async () => { @@ -211,6 +269,27 @@ describe('native Chromium import excludes the Google cookie family', () => { { host_key: '.google.com', name: 'SID', value: 'live-sid' } ]) }) + + it('fails the import and restores the full snapshot when the bulk clear rejects', async () => { + const sourceCookiesPath = seedSource([ + { domain: '.example.com', name: 'session', value: 'new' } + ]) + seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }]) + cookiesGetMock.mockResolvedValue([ + existingCookie('.google.com', 'SID'), + existingCookie('.example.com', 'stale') + ]) + clearStorageDataMock.mockRejectedValue(new Error('cookie store unavailable')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result).toMatchObject({ ok: false }) + expect(result.ok || result.reason).toContain('Could not clear existing cookies') + expect(clearStorageDataMock).toHaveBeenCalledOnce() + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'stale']) + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + }) }) // Why: freshly imported rows store the decrypted value as a BLOB; read both forms as text. diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts index 0969921724c..02d656fd3bc 100644 --- a/src/main/browser/browser-cookie-import-policy.test.ts +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { DatabaseSync } from 'node:sqlite' import type { Cookie } from 'electron' import { + bulkClearCookiesExcept, isGoogleSourceBoundCookie, isNonTransplantableCookieDomain, NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, - removeAllCookiesExcept, replaceCookiesForImportedDomains } from './browser-cookie-import-policy' @@ -207,104 +207,87 @@ describe('NON_TRANSPLANTABLE_HOST_KEY_SQL', () => { }) }) -describe('removeAllCookiesExcept', () => { - it('removes only the cookies the predicate does not exclude', async () => { - const get = vi - .fn() - .mockResolvedValue([ - cookie('.google.com', 'SID'), - cookie('.example.com', 'session'), - cookie('other.test', 'tracker', '/scoped') - ]) - const remove = vi.fn().mockResolvedValue(undefined) +describe('bulkClearCookiesExcept', () => { + it('bulk clears a large jar once and restores only excluded cookies', async () => { + const existing = [ + cookie('.google.com', 'SID'), + cookie('accounts.google.com', 'ACCOUNT'), + ...Array.from({ length: 1_000 }, (_, index) => + cookie(`site-${index}.example`, `session-${index}`) + ) + ] + const get = vi.fn().mockResolvedValue(existing) const set = vi.fn().mockResolvedValue(undefined) + const clearStorageData = vi.fn().mockResolvedValue(undefined) - await removeAllCookiesExcept({ get, remove, set }, (c) => c.domain === '.google.com') + await bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, (existingCookie) => + isNonTransplantableCookieDomain(existingCookie.domain ?? '') + ) - expect(remove.mock.calls).toEqual([ - ['https://example.com/', 'session'], - ['https://other.test/scoped', 'tracker'] - ]) - expect(set).not.toHaveBeenCalled() + expect(get).toHaveBeenCalledOnce() + expect(get).toHaveBeenCalledWith({}) + expect(clearStorageData).toHaveBeenCalledOnce() + expect(clearStorageData).toHaveBeenCalledWith({ storages: ['cookies'] }) + expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'ACCOUNT']) }) - it('restores every successfully removed cookie when another removal fails', async () => { - const get = vi - .fn() - .mockResolvedValue([ - cookie('.example.com', 'first', '/one'), - cookie('.example.com', 'second', '/two'), - cookie('.example.com', 'third', '/three') - ]) - const remove = vi.fn().mockImplementation(async (_url: string, name: string) => { - if (name === 'second') { - throw new Error('store unavailable') + it('restores the complete snapshot when the bulk clear rejects', async () => { + const existing = [ + cookie('.google.com', 'SID'), + cookie('.example.com', 'first'), + cookie('.other.test', 'second') + ] + const get = vi.fn().mockResolvedValue(existing) + const set = vi.fn().mockResolvedValue(undefined) + const clearStorageData = vi.fn().mockRejectedValue(new Error('store unavailable')) + + await expect( + bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, () => true) + ).rejects.toThrow('Could not clear existing cookies') + + expect(clearStorageData).toHaveBeenCalledOnce() + expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'first', 'second']) + }) + + it('rolls back the complete snapshot when excluded-cookie restoration fails', async () => { + const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')] + const get = vi.fn().mockResolvedValue(existing) + let googleAttempts = 0 + const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => { + if (name === 'SID' && googleAttempts++ === 0) { + throw new Error('transient restore failure') } }) - const set = vi.fn().mockResolvedValue(undefined) + const clearStorageData = vi.fn().mockResolvedValue(undefined) - await expect(removeAllCookiesExcept({ get, remove, set }, () => false)).rejects.toThrow( - 'Could not clear existing cookies' - ) - expect(remove).toHaveBeenCalledTimes(3) - expect(set.mock.calls.map(([details]) => details.name)).toEqual(['first', 'third']) - }) - - it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => { - const get = vi - .fn() - .mockResolvedValue( - Array.from({ length: 12 }, (_, index) => cookie('.example.com', `${index}`)) + await expect( + bulkClearCookiesExcept( + { cookies: { get, set }, clearStorageData }, + (existingCookie) => existingCookie.domain === '.google.com' ) - let releaseRemovals: (() => void) | undefined - const removalsReleased = new Promise((resolve) => { - releaseRemovals = resolve - }) - let active = 0 - let maxActive = 0 - const remove = vi.fn().mockImplementation(async () => { - active++ - maxActive = Math.max(maxActive, active) - await removalsReleased - active-- - }) - const set = vi.fn().mockResolvedValue(undefined) + ).rejects.toThrow('Could not preserve excluded cookies') - const clearing = removeAllCookiesExcept({ get, remove, set }, () => false) - await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8)) - expect(maxActive).toBe(8) - releaseRemovals?.() - await clearing - - expect(remove).toHaveBeenCalledTimes(12) - expect(set).not.toHaveBeenCalled() + expect(clearStorageData).toHaveBeenCalledOnce() + expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session']) }) - it('serializes cookies that share Electron removal coordinates', async () => { - const get = vi - .fn() - .mockResolvedValue([ - cookie('.example.com', 'session'), - { ...cookie('example.com', 'session'), hostOnly: true } - ]) - let releaseFirst: (() => void) | undefined - const firstReleased = new Promise((resolve) => { - releaseFirst = resolve + it('reports when the complete-snapshot rollback also fails', async () => { + const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')] + const get = vi.fn().mockResolvedValue(existing) + const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => { + if (name === 'SID') { + throw new Error('persistent restore failure') + } }) - const remove = vi - .fn() - .mockImplementationOnce(() => firstReleased) - .mockResolvedValueOnce(undefined) - const set = vi.fn().mockResolvedValue(undefined) + const clearStorageData = vi.fn().mockResolvedValue(undefined) - const clearing = removeAllCookiesExcept({ get, remove, set }, () => false) - await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) - releaseFirst?.() - await clearing + await expect( + bulkClearCookiesExcept( + { cookies: { get, set }, clearStorageData }, + (existingCookie) => existingCookie.domain === '.google.com' + ) + ).rejects.toThrow('Cookie preservation and rollback failed') - expect(remove.mock.calls).toEqual([ - ['https://example.com/', 'session'], - ['https://example.com/', 'session'] - ]) + expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session']) }) }) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index 8f6008ac7a4..d1cd02402fe 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -1,6 +1,5 @@ -import type { Cookie, Cookies } from 'electron' +import type { Cookie, Cookies, Session } from 'electron' import { parse as parseDomain } from 'psl' -import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([ 'SIDCC', @@ -64,7 +63,6 @@ export function normalizeCookieImportDomain(domain: string): string | null { // its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports // users actually asked for. const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const -const COOKIE_CLEAR_CONCURRENCY = 8 export function isNonTransplantableCookieDomain(domain: string): boolean { const normalized = normalizeCookieDomain(domain) @@ -196,53 +194,55 @@ export async function restoreImportedDomainCookies( } } -// Why: clearStorageData wipes the whole jar, including the non-transplantable families an -// import is never allowed to remove; this is the clear step that can leave them in place. -export async function removeAllCookiesExcept( - store: Pick, +type CookieClearSession = { + cookies: Pick + clearStorageData: Session['clearStorageData'] +} + +async function restoreCookieClearSnapshot( + store: Pick, + snapshot: readonly Cookie[], + originalError: unknown, + rollbackMessage: string +): Promise { + try { + await restoreImportedDomainCookies(store, snapshot) + } catch (rollbackError) { + throw new AggregateError([originalError, rollbackError], rollbackMessage) + } + throw originalError +} + +// Why: after a bulk clear starts, Electron cannot reveal whether a rejected operation mutated +// the jar, so keep the complete snapshot until excluded cookies have been restored. +export async function bulkClearCookiesExcept( + targetSession: CookieClearSession, isExcluded: (cookie: Cookie) => boolean ): Promise { - const existingCookies = await store.get({}) - const removableGroups = new Map() - for (const cookie of existingCookies) { - if (isExcluded(cookie)) { - continue - } - const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null - const url = domain ? cookieRemovalUrl(cookie, domain) : null - if (!url) { - continue - } - const key = JSON.stringify([url, cookie.name]) - const group = removableGroups.get(key) ?? [] - group.push({ cookie, url }) - removableGroups.set(key, group) + const snapshot = await targetSession.cookies.get({}) + const excludedCookies = snapshot.filter(isExcluded) + + try { + await targetSession.clearStorageData({ storages: ['cookies'] }) + } catch (clearError) { + await restoreCookieClearSnapshot( + targetSession.cookies, + snapshot, + new AggregateError([clearError], 'Could not clear existing cookies'), + 'Cookie bulk clear and rollback failed' + ) } - const removedCookies: Cookie[] = [] - const results = await mapSettledWithConcurrency( - [...removableGroups.values()], - COOKIE_CLEAR_CONCURRENCY, - async (group) => { - // Why: identical remove keys must stay ordered so duplicate scoped cookies are not raced. - for (const { cookie, url } of group) { - await store.remove(url, cookie.name) - removedCookies.push(cookie) - } - } - ) - const failures = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - if (failures.length === 0) { - return - } try { - await restoreImportedDomainCookies(store, removedCookies) - } catch (restoreError) { - throw new AggregateError([...failures, restoreError], 'Cookie clearing and rollback failed') + await restoreImportedDomainCookies(targetSession.cookies, excludedCookies) + } catch (preservationError) { + await restoreCookieClearSnapshot( + targetSession.cookies, + snapshot, + new AggregateError([preservationError], 'Could not preserve excluded cookies'), + 'Cookie preservation and rollback failed' + ) } - throw new AggregateError(failures, 'Could not clear existing cookies') } export async function replaceCookiesForImportedDomains( diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts index 79553d3a651..8b5c07bb057 100644 --- a/src/main/browser/browser-cookie-import-replacement.test.ts +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -79,6 +79,7 @@ describe('validated cookie replacement', () => { totalCookies: 3, importedCookies: 1, skippedCookies: 2, + googleCookiesSkipped: 2, domains: ['example.com'] }) expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'old-example']]) @@ -99,6 +100,7 @@ describe('validated cookie replacement', () => { totalCookies: 1, importedCookies: 0, skippedCookies: 1, + googleCookiesSkipped: 1, domains: [] }) expect(cookiesGetMock).not.toHaveBeenCalled() @@ -215,6 +217,7 @@ describe('native Chromium integrity-cookie accounting', () => { totalCookies: 3, importedCookies: 1, skippedCookies: 2, + googleCookiesSkipped: 2, domains: ['example.com'] }) expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['AEC']) @@ -246,6 +249,7 @@ describe('native Chromium integrity-cookie accounting', () => { totalCookies: 3, importedCookies: 0, skippedCookies: 3, + googleCookiesSkipped: 2, domains: [] }) expect(clearStorageDataMock).not.toHaveBeenCalled() diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 50e232c0b7e..5a22727bf2b 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -511,9 +511,8 @@ describe('importCookiesFromBrowser Chromium', () => { ['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix)) ).toEqual(sourceFilesBefore) expect(cookiesRemoveMock).not.toHaveBeenCalled() - // Why: STA-3811 — the pre-import clear is selective now, so a wholesale wipe would - // take the non-transplantable families with it. - expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(clearStorageDataMock).toHaveBeenCalledOnce() + expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] }) // Why: STA-3514 — imports must never impersonate the source browser; the // session keeps the engine UA the registry set at startup. expect(setUserAgentMock).not.toHaveBeenCalled() diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 6d5f1661703..50ec961eb7f 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -75,12 +75,12 @@ import type { } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' import { + bulkClearCookiesExcept, isGoogleSourceBoundCookie, isNonTransplantableCookieDomain, NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, normalizeCookieImportDomain, - removeAllCookiesExcept, replaceCookiesForImportedDomains, restoreImportedDomainCookies, type CookieImportMode @@ -584,6 +584,7 @@ async function importValidatedCookies( ) const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length + const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped const invalidDomainSkipped = cookies.length - validDomainCookies.length diag( `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` @@ -693,6 +694,7 @@ async function importValidatedCookies( totalCookies: totalInput, importedCookies: importedCount, skippedCookies: skipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), domains: [...domainSet].sort() } @@ -1590,7 +1592,12 @@ export async function importCookiesFromBrowser( const needsSourceKey = sourceRows.some((sourceRow) => { const encRaw = sourceRow.encrypted_value - return encRaw instanceof Uint8Array && encRaw.length > 0 + if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) { + return false + } + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)) }) const sourceKey = needsSourceKey ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) @@ -1653,6 +1660,20 @@ export async function importCookiesFromBrowser( } for (const sourceRow of sourceRows) { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + + if (isGoogleSourceBoundCookie(name, domain)) { + integritySkipped++ + continue + } + + // Why: transplanting these replaces a working sign-in with a session the site rejects. + if (isNonTransplantableCookieDomain(domain)) { + nonTransplantableSkipped++ + continue + } + const encRaw = sourceRow.encrypted_value // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null @@ -1674,20 +1695,6 @@ export async function importCookiesFromBrowser( decryptedValue = Buffer.alloc(0) } - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - - if (isGoogleSourceBoundCookie(name, domain)) { - integritySkipped++ - continue - } - - // Why: transplanting these replaces a working sign-in with a session the site rejects. - if (isNonTransplantableCookieDomain(domain)) { - nonTransplantableSkipped++ - continue - } - let validDomain = sourceDomainValidity.get(domain) if (validDomain === undefined) { validDomain = normalizeCookieImportDomain(domain) !== null @@ -1740,6 +1747,7 @@ export async function importCookiesFromBrowser( diag( ` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies` ) + const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped if (decryptedCookies.length === 0) { closeStagingDb() @@ -1751,6 +1759,7 @@ export async function importCookiesFromBrowser( totalCookies: sourceRows.length, importedCookies: 0, skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), domains: [] } } @@ -1771,9 +1780,11 @@ export async function importCookiesFromBrowser( // Why: clear stale cookies first; mixing them with the imported set makes sites reject the // session. Non-transplantable families are exempt — nothing was imported for them, and their // live session is the only one that works. - await removeAllCookiesExcept(targetSession.cookies, (cookie) => + await bulkClearCookiesExcept(targetSession, (cookie) => isNonTransplantableCookieDomain(cookie.domain ?? '') ) + // Why: after excluded cookies survive this boundary, restart staging owns later set failures; + // restoring stale non-Google cookies would recreate the mixed jar this import must replace. diag( ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` ) @@ -1842,6 +1853,7 @@ export async function importCookiesFromBrowser( totalCookies: sourceRows.length, importedCookies: imported, skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), domains: [...domainSet].sort(), ...(warning ? { warning } : {}) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c10727a620d..473157aacf7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -756,7 +756,8 @@ "import": { "toast": { "restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.", - "restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again." + "restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.", + "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google." } } } diff --git a/src/renderer/src/lib/browser-cookie-import-toast.test.ts b/src/renderer/src/lib/browser-cookie-import-toast.test.ts index a59c7a93f03..6a23e0f4a43 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.test.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.test.ts @@ -69,4 +69,52 @@ describe('emitBrowserCookieImportToast', () => { expect(successToastMock).toHaveBeenCalledWith('Imported 3 cookies.') expect(warningToastMock).not.toHaveBeenCalled() }) + + it('shows a separate Google sign-in warning after the concise success toast', () => { + emitBrowserCookieImportToast( + { ...summary, importedCookies: 2, skippedCookies: 1, googleCookiesSkipped: 1 }, + 'Imported 2 cookies.' + ) + + expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.') + expect(warningToastMock).toHaveBeenCalledWith( + 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' + ) + expect(successToastMock.mock.invocationCallOrder[0]).toBeLessThan( + warningToastMock.mock.invocationCallOrder[0] + ) + }) + + it('does not infer a Google warning from generic skipped cookies', () => { + emitBrowserCookieImportToast( + { ...summary, importedCookies: 2, skippedCookies: 1 }, + 'Imported 2 cookies.' + ) + + expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.') + expect(warningToastMock).not.toHaveBeenCalled() + }) + + it('keeps both applicable warnings when restart fallback is unavailable', () => { + emitBrowserCookieImportToast( + { + ...summary, + importedCookies: 1, + skippedCookies: 2, + googleCookiesSkipped: 1, + warning: { + code: 'restart-fallback-unavailable', + loadedCookies: 1, + failedCookies: 1 + } + }, + 'Imported 1 cookie.' + ) + + expect(successToastMock).not.toHaveBeenCalled() + expect(warningToastMock.mock.calls.map(([message]) => message)).toEqual([ + 'Imported 1 of 2 cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.', + 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' + ]) + }) }) diff --git a/src/renderer/src/lib/browser-cookie-import-toast.ts b/src/renderer/src/lib/browser-cookie-import-toast.ts index 4bdd031f83f..6ded5ef9969 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.ts @@ -24,6 +24,18 @@ function formatCookieImportWarning(warning: CookieImportWarning): string { } } +function emitGoogleCookieImportWarning(summary: BrowserCookieImportSummary): void { + if (!summary.googleCookiesSkipped) { + return + } + toast.warning( + translate( + 'auto.lib.browser.cookie.import.toast.googleCookiesSkipped', + 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' + ) + ) +} + // Why: a degraded import returns ok:true with a warning, so every call site must route it to a // warning toast instead of reporting an unqualified success (#9355). export function emitBrowserCookieImportToast( @@ -33,7 +45,8 @@ export function emitBrowserCookieImportToast( const warning = summary.warning if (warning) { toast.warning(formatCookieImportWarning(warning)) - return + } else { + toast.success(successMessage) } - toast.success(successMessage) + emitGoogleCookieImportWarning(summary) } diff --git a/src/shared/types.ts b/src/shared/types.ts index 34cc0e43c35..6d9656a8b2c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1079,6 +1079,7 @@ export type BrowserCookieImportSummary = { totalCookies: number importedCookies: number skippedCookies: number + googleCookiesSkipped?: number domains: string[] warning?: { code: 'restart-fallback-unavailable'