From 43c058ed29908f033fc049752007791a86502970 Mon Sep 17 00:00:00 2001 From: red545 Date: Mon, 23 Mar 2026 13:21:25 +0100 Subject: [PATCH] feat: add Chrome/Arc profile picker to cookie import Chromium browsers support multiple profiles (accounts), but cookie import always read from the Default profile. Users with multiple Chrome or Arc profiles couldn't import cookies from non-default accounts. Changes: - Add listProfiles() that reads Local State JSON to discover profiles with human-readable display names (account name + gaia name) - Add GET /cookie-picker/profiles API endpoint - Pass profile param through domains and import endpoints - Add profile dropdown in picker UI (auto-hidden for single-profile browsers) - Update findInstalledBrowsers() to detect browsers with any profile Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/cookie-import-browser.ts | 74 ++++++++++++++++++++++++-- browse/src/cookie-picker-routes.ts | 21 ++++++-- browse/src/cookie-picker-ui.ts | 82 +++++++++++++++++++++++++++-- 3 files changed, 165 insertions(+), 12 deletions(-) diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 29d9db3e3..2dd1138f5 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -47,6 +47,11 @@ export interface BrowserInfo { aliases: string[]; } +export interface ProfileEntry { + folder: string; // e.g. "Default", "Profile 1" + name: string; // display name, e.g. "Nik (Nik Redl)" +} + export interface DomainEntry { domain: string; count: number; @@ -101,14 +106,77 @@ const keyCache = new Map(); // ─── Public API ───────────────────────────────────────────────── /** - * Find which browsers are installed (have a cookie DB on disk). + * Find which browsers are installed (have a cookie DB in any profile). */ export function findInstalledBrowsers(): BrowserInfo[] { const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); return BROWSER_REGISTRY.filter(b => { - const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies'); - try { return fs.existsSync(dbPath); } catch { return false; } + const browserDir = path.join(appSupport, b.dataDir); + try { + if (!fs.existsSync(browserDir)) return false; + // Check Default profile first, then any Profile N directory + if (fs.existsSync(path.join(browserDir, 'Default', 'Cookies'))) return true; + return fs.readdirSync(browserDir).some(entry => + entry.startsWith('Profile ') && + fs.existsSync(path.join(browserDir, entry, 'Cookies')) + ); + } catch { return false; } + }); +} + +/** + * List profiles for a browser by reading Local State JSON. + * Returns folders that have a Cookies DB on disk. + */ +export function listProfiles(browserName: string): ProfileEntry[] { + const browser = resolveBrowser(browserName); + const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); + const browserDir = path.join(appSupport, browser.dataDir); + const localStatePath = path.join(browserDir, 'Local State'); + + // Read display names from Local State + let profileNames: Record = {}; + try { + const raw = fs.readFileSync(localStatePath, 'utf-8'); + const data = JSON.parse(raw); + profileNames = data?.profile?.info_cache ?? {}; + } catch { + // No Local State — fall back to folder names only + } + + // Find profile folders that have a Cookies DB + const profiles: ProfileEntry[] = []; + try { + const entries = fs.readdirSync(browserDir); + for (const folder of entries) { + if (folder !== 'Default' && !folder.startsWith('Profile ')) continue; + const cookiePath = path.join(browserDir, folder, 'Cookies'); + if (!fs.existsSync(cookiePath)) continue; + + const info = profileNames[folder]; + let displayName = folder; + if (info) { + const parts = [info.name, info.gaia_name].filter(Boolean); + displayName = parts.length > 0 + ? (info.name && info.gaia_name && info.name !== info.gaia_name + ? `${info.name} (${info.gaia_name})` + : parts[0]!) + : folder; + } + profiles.push({ folder, name: displayName }); + } + } catch { + // If we can't read the directory, return empty + } + + // Sort: Default first, then by folder name + profiles.sort((a, b) => { + if (a.folder === 'Default') return -1; + if (b.folder === 'Default') return 1; + return a.folder.localeCompare(b.folder, undefined, { numeric: true }); }); + + return profiles; } /** diff --git a/browse/src/cookie-picker-routes.ts b/browse/src/cookie-picker-routes.ts index 6a4a43192..0e6972484 100644 --- a/browse/src/cookie-picker-routes.ts +++ b/browse/src/cookie-picker-routes.ts @@ -14,7 +14,7 @@ */ import type { BrowserManager } from './browser-manager'; -import { findInstalledBrowsers, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser'; +import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser'; import { getCookiePickerHTML } from './cookie-picker-ui'; // ─── State ────────────────────────────────────────────────────── @@ -90,13 +90,24 @@ export async function handleCookiePickerRoute( }, { port }); } - // GET /cookie-picker/domains?browser= — list domains + counts + // GET /cookie-picker/profiles?browser= — list profiles for a browser + if (pathname === '/cookie-picker/profiles' && req.method === 'GET') { + const browserName = url.searchParams.get('browser'); + if (!browserName) { + return errorResponse("Missing 'browser' parameter", 'missing_param', { port }); + } + const profiles = listProfiles(browserName); + return jsonResponse({ profiles }, { port }); + } + + // GET /cookie-picker/domains?browser=&profile= — list domains + counts if (pathname === '/cookie-picker/domains' && req.method === 'GET') { const browserName = url.searchParams.get('browser'); if (!browserName) { return errorResponse("Missing 'browser' parameter", 'missing_param', { port }); } - const result = listDomains(browserName); + const profile = url.searchParams.get('profile') || 'Default'; + const result = listDomains(browserName, profile); return jsonResponse({ browser: result.browser, domains: result.domains, @@ -112,14 +123,14 @@ export async function handleCookiePickerRoute( return errorResponse('Invalid JSON body', 'bad_request', { port }); } - const { browser, domains } = body; + const { browser, domains, profile } = body; if (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port }); if (!domains || !Array.isArray(domains) || domains.length === 0) { return errorResponse("Missing or empty 'domains' array", 'missing_param', { port }); } // Decrypt cookies from the browser DB - const result = await importCookies(browser, domains); + const result = await importCookies(browser, domains, profile || 'Default'); if (result.cookies.length === 0) { return jsonResponse({ diff --git a/browse/src/cookie-picker-ui.ts b/browse/src/cookie-picker-ui.ts index 010c2dd75..af4484bac 100644 --- a/browse/src/cookie-picker-ui.ts +++ b/browse/src/cookie-picker-ui.ts @@ -101,6 +101,31 @@ export function getCookiePickerHTML(serverPort: number): string { background: #4ade80; } + /* ─── Profile Select ────────────────────── */ + .profile-wrap { + padding: 0 20px 12px; + } + .profile-select { + width: 100%; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid #333; + background: #141414; + color: #e0e0e0; + font-size: 13px; + outline: none; + transition: border-color 0.15s; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' stroke='%23666' fill='none' stroke-width='1.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 32px; + } + .profile-select:focus { border-color: #555; } + .profile-wrap.hidden { display: none; } + /* ─── Search ──────────────────────────── */ .search-wrap { padding: 0 20px 12px; @@ -268,6 +293,9 @@ export function getCookiePickerHTML(serverPort: number): string {
Source Browser
+
@@ -291,11 +319,14 @@ export function getCookiePickerHTML(serverPort: number): string { (function() { const BASE = '${baseUrl}'; let activeBrowser = null; + let activeProfile = 'Default'; let allDomains = []; let importedSet = {}; // domain → count let inflight = {}; // domain → true (prevents double-click) const $pills = document.getElementById('browser-pills'); + const $profileWrap = document.getElementById('profile-wrap'); + const $profileSelect = document.getElementById('profile-select'); const $search = document.getElementById('search'); const $sourceDomains = document.getElementById('source-domains'); const $importedDomains = document.getElementById('imported-domains'); @@ -380,22 +411,58 @@ export function getCookiePickerHTML(serverPort: number): string { // ─── Select Browser ──────────────────── async function selectBrowser(name) { activeBrowser = name; + activeProfile = 'Default'; // Update pills $pills.querySelectorAll('.pill').forEach(p => { p.classList.toggle('active', p.textContent === name); }); - $sourceDomains.innerHTML = '
Loading domains...
'; + $sourceDomains.innerHTML = '
Loading profiles...
'; $sourceFooter.textContent = ''; $search.value = ''; try { - const data = await api('/domains?browser=' + encodeURIComponent(name)); + // Fetch profiles for this browser + const profileData = await api('/profiles?browser=' + encodeURIComponent(name)); + const profiles = profileData.profiles || []; + + if (profiles.length > 1) { + // Show profile dropdown + $profileWrap.classList.remove('hidden'); + $profileSelect.innerHTML = ''; + for (const p of profiles) { + const opt = document.createElement('option'); + opt.value = p.folder; + opt.textContent = p.name + (p.name !== p.folder ? ' (' + p.folder + ')' : ''); + $profileSelect.appendChild(opt); + } + $profileSelect.value = 'Default'; + activeProfile = 'Default'; + } else { + $profileWrap.classList.add('hidden'); + activeProfile = profiles.length === 1 ? profiles[0].folder : 'Default'; + } + + await loadDomains(); + } catch (err) { + showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null); + $sourceDomains.innerHTML = '
Failed to load
'; + } + } + + // ─── Load Domains for Active Browser+Profile ── + async function loadDomains() { + $sourceDomains.innerHTML = '
Loading domains...
'; + $sourceFooter.textContent = ''; + + try { + const data = await api('/domains?browser=' + encodeURIComponent(activeBrowser) + + '&profile=' + encodeURIComponent(activeProfile)); allDomains = data.domains; renderSourceDomains(); } catch (err) { - showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null); + showBanner(err.message, 'error', err.action === 'retry' ? () => loadDomains() : null); $sourceDomains.innerHTML = '
Failed to load domains
'; } } @@ -453,7 +520,7 @@ export function getCookiePickerHTML(serverPort: number): string { const data = await api('/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ browser: activeBrowser, domains: [domain] }), + body: JSON.stringify({ browser: activeBrowser, domains: [domain], profile: activeProfile }), }); if (data.domainCounts) { @@ -529,6 +596,13 @@ export function getCookiePickerHTML(serverPort: number): string { } } + // ─── Profile Change ──────────────────── + $profileSelect.addEventListener('change', () => { + activeProfile = $profileSelect.value; + $search.value = ''; + loadDomains(); + }); + // ─── Search ──────────────────────────── $search.addEventListener('input', renderSourceDomains);