From 85ea1cc4c5a1ddfba1f5d92eaef9482465fc056a Mon Sep 17 00:00:00 2001 From: dojineko Date: Wed, 28 Oct 2026 17:28:04 +0900 Subject: [PATCH] feat(browser): add Vivaldi as a Chromium cookie-import source (#11449) Detect Vivaldi profiles on macOS, Windows, and Linux and import their cookies like other Chromium browsers. On Linux, Vivaldi stores its cookie key under Chrome's libsecret application name, so the browser definition can override the keyring application used for the lookup. Co-authored-by: Neil --- .../cookies/browser-cookie-import.test.ts | 3 +- .../browser-cookie-import.vivaldi.test.ts | 73 +++++++++++++++++++ .../sources/browser-cookie-detection-types.ts | 13 ++++ .../cookies/sources/browser-cookie-key.ts | 11 ++- .../browser-cookie-import-sources.test.ts | 3 + src/shared/browser-cookie-import-sources.ts | 3 +- src/shared/browser-workspace-types.ts | 1 + src/shared/constants.ts | 1 + 8 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src/main/browser/cookies/browser-cookie-import.vivaldi.test.ts 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',