diff --git a/src/main/browser/cookies/browser-cookie-import.test.ts b/src/main/browser/cookies/browser-cookie-import.test.ts index f66989b0e7b..442803cee33 100644 --- a/src/main/browser/cookies/browser-cookie-import.test.ts +++ b/src/main/browser/cookies/browser-cookie-import.test.ts @@ -685,7 +685,8 @@ describe('detectInstalledBrowsers', () => { 'firefox', 'safari', 'comet', - 'helium' + 'helium', + 'vivaldi' ] for (const browser of browsers) { expect(validFamilies).toContain(browser.family) diff --git a/src/main/browser/cookies/browser-cookie-import.vivaldi.test.ts b/src/main/browser/cookies/browser-cookie-import.vivaldi.test.ts new file mode 100644 index 00000000000..ecd51d856b2 --- /dev/null +++ b/src/main/browser/cookies/browser-cookie-import.vivaldi.test.ts @@ -0,0 +1,73 @@ +import { pbkdf2Sync } from 'node:crypto' +import type * as fsModule from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { existingPaths, runProcessSyncMock } = vi.hoisted(() => ({ + existingPaths: new Set(), + runProcessSyncMock: vi.fn() +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: (p: string) => existingPaths.has(p.replaceAll('\\', '/')) + } +}) +vi.mock('@orca/process-host', () => ({ runProcessSync: runProcessSyncMock })) + +import { detectInstalledBrowsers } from './sources/browser-cookie-detection' +import { getEncryptionKey } from './sources/browser-cookie-key' + +const originalPlatform = process.platform +const originalEnv = { ...process.env } + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }) +} + +beforeEach(() => { + existingPaths.clear() + runProcessSyncMock.mockReset() + process.env.HOME = '/home/test' + process.env.LOCALAPPDATA = 'C:/Users/test/AppData/Local' + process.env.XDG_CONFIG_HOME = '/home/test/.config' +}) + +afterEach(() => { + setPlatform(originalPlatform) + process.env = { ...originalEnv } +}) + +describe('Vivaldi cookie import source', () => { + it.each([ + ['darwin', '/home/test/Library/Application Support/Vivaldi/Default/Cookies'], + ['win32', 'C:/Users/test/AppData/Local/Vivaldi/User Data/Default/Network/Cookies'], + ['linux', '/home/test/.config/vivaldi/Default/Cookies'] + ] as const)('detects the %s profile store', (platform, cookiesPath) => { + setPlatform(platform) + existingPaths.add(cookiesPath) + + const vivaldi = detectInstalledBrowsers().find((browser) => browser.family === 'vivaldi') + + expect(vivaldi?.label).toBe('Vivaldi') + expect(vivaldi?.cookiesPath.replaceAll('\\', '/')).toBe(cookiesPath) + }) + + it('reads the Linux keyring secret Vivaldi stores under the Chrome application name', () => { + setPlatform('linux') + existingPaths.add('/home/test/.config/vivaldi/Default/Cookies') + runProcessSyncMock.mockImplementation(({ args }: { args: string[] }) => + args.join(' ') === 'lookup application chrome' + ? { code: 0, timedOut: false, stdout: 'vivaldi-secret\n', stderr: '' } + : { code: 1, timedOut: false, stdout: '', stderr: '' } + ) + const vivaldi = detectInstalledBrowsers().find((browser) => browser.family === 'vivaldi') + + const key = getEncryptionKey(vivaldi!.keychainService!, vivaldi!.keychainAccount!, vivaldi) + + expect(key).toMatchObject({ + keysByVersion: { v11: pbkdf2Sync('vivaldi-secret', 'saltysalt', 1, 16, 'sha1') } + }) + }) +}) diff --git a/src/main/browser/cookies/sources/browser-cookie-detection-types.ts b/src/main/browser/cookies/sources/browser-cookie-detection-types.ts index 872d544d1e2..d414c9c9b67 100644 --- a/src/main/browser/cookies/sources/browser-cookie-detection-types.ts +++ b/src/main/browser/cookies/sources/browser-cookie-detection-types.ts @@ -22,6 +22,8 @@ export type ChromiumBrowserDef = { label: string keychainService: string keychainAccount: string + // Linux libsecret `application` attribute; defaults to keychainAccount lowercased without spaces. + linuxKeyringApplication?: string macRoot?: string winRoot?: string linuxRoot?: string @@ -77,6 +79,17 @@ export const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ keychainAccount: 'Helium', macRoot: 'net.imput.helium' // Only the macOS install is verified. + }, + { + family: 'vivaldi', + label: 'Vivaldi', + keychainService: 'Vivaldi Safe Storage', + keychainAccount: 'Vivaldi', + macRoot: 'Vivaldi', + winRoot: 'Vivaldi/User Data', + linuxRoot: 'vivaldi', + // Vivaldi on Linux keeps Chrome's libsecret entry name (matches yt-dlp and browser_cookie3). + linuxKeyringApplication: 'chrome' } ] diff --git a/src/main/browser/cookies/sources/browser-cookie-key.ts b/src/main/browser/cookies/sources/browser-cookie-key.ts index baa10ca6ecf..75e53f6c86c 100644 --- a/src/main/browser/cookies/sources/browser-cookie-key.ts +++ b/src/main/browser/cookies/sources/browser-cookie-key.ts @@ -35,7 +35,10 @@ export function getEncryptionKey( return getMacEncryptionKey(keychainService, keychainAccount) } if (process.platform === 'linux') { - return getLinuxEncryptionKey(keychainService, keychainAccount) + const application = CHROMIUM_BROWSERS.find( + (b) => b.family === browser?.family + )?.linuxKeyringApplication + return getLinuxEncryptionKey(keychainService, keychainAccount, application) } if (process.platform === 'win32' && browser) { return getWindowsEncryptionKey(browser) @@ -66,17 +69,17 @@ export function getMacEncryptionKey( export function getLinuxEncryptionKey( keychainService: string, - keychainAccount: string + keychainAccount: string, + application = keychainAccount.toLowerCase().replaceAll(' ', '') ): EncryptionKeyResult | null { // Linux v10 uses "peanuts"; v11 requires OS key storage, and CBC cannot authenticate a wrong key. const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') let keyringPassword = '' let keyringFailure: LinuxCookieKeyFailure = 'key-unavailable' - const app = keychainAccount.toLowerCase().replaceAll(' ', '') for (const args of [ ['lookup', 'service', keychainService, 'account', keychainAccount], - ['lookup', 'application', app] + ['lookup', 'application', application] ]) { try { keyringPassword = runKeychainCommand('secret-tool', args, 5_000) diff --git a/src/shared/browser-cookie-import-sources.test.ts b/src/shared/browser-cookie-import-sources.test.ts index 8d5a0032203..5179e84090b 100644 --- a/src/shared/browser-cookie-import-sources.test.ts +++ b/src/shared/browser-cookie-import-sources.test.ts @@ -10,6 +10,7 @@ describe('getBrowserCookieImportSourceLabels', () => { 'Brave', 'Comet', 'Helium', + 'Vivaldi', 'Firefox', 'Safari' ]) @@ -21,6 +22,7 @@ describe('getBrowserCookieImportSourceLabels', () => { 'Microsoft Edge', 'Brave', 'Comet', + 'Vivaldi', 'Firefox' ]) }) @@ -30,6 +32,7 @@ describe('getBrowserCookieImportSourceLabels', () => { 'Google Chrome', 'Microsoft Edge', 'Brave', + 'Vivaldi', 'Firefox' ]) }) diff --git a/src/shared/browser-cookie-import-sources.ts b/src/shared/browser-cookie-import-sources.ts index 0e2186a0628..a1eb159ee6e 100644 --- a/src/shared/browser-cookie-import-sources.ts +++ b/src/shared/browser-cookie-import-sources.ts @@ -6,7 +6,8 @@ const CHROMIUM_COOKIE_IMPORT_SOURCES = [ { label: 'Arc', mac: true, win: false, linux: false }, { label: 'Brave', mac: true, win: true, linux: true }, { label: 'Comet', mac: true, win: true, linux: false }, - { label: 'Helium', mac: true, win: false, linux: false } + { label: 'Helium', mac: true, win: false, linux: false }, + { label: 'Vivaldi', mac: true, win: true, linux: true } ] as const export function getBrowserCookieImportSourceLabels( diff --git a/src/shared/browser-workspace-types.ts b/src/shared/browser-workspace-types.ts index 23c39377ad2..c63618cfb85 100644 --- a/src/shared/browser-workspace-types.ts +++ b/src/shared/browser-workspace-types.ts @@ -145,6 +145,7 @@ export type BrowserSessionProfileSource = { | 'safari' | 'comet' | 'helium' + | 'vivaldi' | 'manual' profileName?: string importedAt: number diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9fcb514dab2..f71771894a0 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -54,6 +54,7 @@ export const BROWSER_FAMILY_LABELS: Record = { chromium: 'Chromium', comet: 'Comet', helium: 'Helium', + vivaldi: 'Vivaldi', arc: 'Arc', edge: 'Microsoft Edge', brave: 'Brave',