feat: support Chrome multi-profile cookie import

Previously cookie-import-browser only read from Chrome's Default profile,
making it impossible to import cookies from other profiles (e.g. Profile 3).
This was a common issue for users with multiple Chrome profiles.

Changes:
- Add listProfiles() to discover all Chrome profiles with cookie DBs
- Read profile display names from Chrome's Preferences files
- Add profile selector pills in the cookie picker UI
- Pass profile parameter through domains/import API endpoints
- Add --profile flag to CLI direct import mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Max Li
2026-03-15 00:01:51 -07:00
parent d7c732b282
commit fa0e9e38cf
4 changed files with 171 additions and 14 deletions
+65 -3
View File
@@ -47,6 +47,11 @@ export interface BrowserInfo {
aliases: string[];
}
export interface ProfileEntry {
name: string; // e.g. "Default", "Profile 1", "Profile 3"
displayName: string; // human-friendly name from Preferences, or falls back to dir name
}
export interface DomainEntry {
domain: string;
count: number;
@@ -101,16 +106,73 @@ const keyCache = new Map<string, Buffer>();
// ─── Public API ─────────────────────────────────────────────────
/**
* Find which browsers are installed (have a cookie DB on disk).
* Find which browsers are installed (have a cookie DB on disk 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
if (fs.existsSync(path.join(browserDir, 'Default', 'Cookies'))) return true;
// Check numbered profiles (Profile 1, Profile 2, etc.)
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
return entries.some(e =>
e.isDirectory() && e.name.startsWith('Profile ') &&
fs.existsSync(path.join(browserDir, e.name, 'Cookies'))
);
} catch { return false; }
});
}
/**
* List available profiles for a browser.
*/
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);
if (!fs.existsSync(browserDir)) return [];
const profiles: ProfileEntry[] = [];
// Scan for directories that contain a Cookies DB
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(browserDir, { withFileTypes: true });
} catch {
return [];
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
const cookiePath = path.join(browserDir, entry.name, 'Cookies');
if (!fs.existsSync(cookiePath)) continue;
// Try to read display name from Preferences
let displayName = entry.name;
try {
const prefsPath = path.join(browserDir, entry.name, 'Preferences');
if (fs.existsSync(prefsPath)) {
const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
const profileName = prefs?.profile?.name;
if (profileName && typeof profileName === 'string') {
displayName = profileName;
}
}
} catch {
// Ignore — fall back to directory name
}
profiles.push({ name: entry.name, displayName });
}
return profiles;
}
/**
* List unique cookie domains + counts from a browser's DB. No decryption.
*/
+16 -5
View File
@@ -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=<name> — list domains + counts
// GET /cookie-picker/profiles?browser=<name> — 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=<name>&profile=<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({
+86 -4
View File
@@ -101,6 +101,30 @@ export function getCookiePickerHTML(serverPort: number): string {
background: #4ade80;
}
/* ─── Profile Pills ─────────────────── */
.profile-pills {
display: flex;
gap: 6px;
padding: 0 20px 12px;
flex-wrap: wrap;
}
.profile-pill {
padding: 4px 10px;
border-radius: 14px;
border: 1px solid #2a2a2a;
background: #141414;
color: #888;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.profile-pill:hover { border-color: #444; color: #bbb; }
.profile-pill.active {
border-color: #60a5fa;
background: #0a1a2a;
color: #60a5fa;
}
/* ─── Search ──────────────────────────── */
.search-wrap {
padding: 0 20px 12px;
@@ -268,6 +292,7 @@ export function getCookiePickerHTML(serverPort: number): string {
<div class="panel panel-left">
<div class="panel-header">Source Browser</div>
<div id="browser-pills" class="browser-pills"></div>
<div id="profile-pills" class="profile-pills" style="display:none"></div>
<div class="search-wrap">
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
</div>
@@ -291,11 +316,14 @@ export function getCookiePickerHTML(serverPort: number): string {
(function() {
const BASE = '${baseUrl}';
let activeBrowser = null;
let activeProfile = 'Default';
let allProfiles = [];
let allDomains = [];
let importedSet = {}; // domain → count
let inflight = {}; // domain → true (prevents double-click)
const $pills = document.getElementById('browser-pills');
const $profilePills = document.getElementById('profile-pills');
const $search = document.getElementById('search');
const $sourceDomains = document.getElementById('source-domains');
const $importedDomains = document.getElementById('imported-domains');
@@ -380,22 +408,76 @@ 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 = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading...</div>';
$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));
allProfiles = profileData.profiles || [];
if (allProfiles.length > 1) {
// Show profile pills when multiple profiles exist
$profilePills.style.display = 'flex';
renderProfilePills();
// Auto-select profile with the most recent/largest cookie DB, or Default
activeProfile = allProfiles[0].name;
} else {
$profilePills.style.display = 'none';
activeProfile = allProfiles.length === 1 ? allProfiles[0].name : 'Default';
}
await loadDomains();
} catch (err) {
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
$profilePills.style.display = 'none';
}
}
// ─── Render Profile Pills ─────────────
function renderProfilePills() {
let html = '';
for (const p of allProfiles) {
const isActive = p.name === activeProfile;
const label = p.displayName || p.name;
html += '<button class="profile-pill' + (isActive ? ' active' : '') + '" data-profile="' + escHtml(p.name) + '">' + escHtml(label) + '</button>';
}
$profilePills.innerHTML = html;
$profilePills.querySelectorAll('.profile-pill').forEach(btn => {
btn.addEventListener('click', () => selectProfile(btn.dataset.profile));
});
}
// ─── Select Profile ───────────────────
async function selectProfile(profileName) {
activeProfile = profileName;
renderProfilePills();
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
$sourceFooter.textContent = '';
$search.value = '';
await loadDomains();
}
// ─── Load Domains ─────────────────────
async function loadDomains() {
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 = '<div class="imported-empty">Failed to load domains</div>';
}
}
@@ -453,7 +535,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) {
+4 -2
View File
@@ -309,16 +309,18 @@ export async function handleWriteCommand(
case 'cookie-import-browser': {
// Two modes:
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain>
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain> [--profile <profile>]
// 2. Open picker UI: cookie-import-browser [browser]
const browserArg = args[0];
const domainIdx = args.indexOf('--domain');
const profileIdx = args.indexOf('--profile');
const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default';
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
// Direct import mode — no UI
const domain = args[domainIdx + 1];
const browser = browserArg || 'comet';
const result = await importCookies(browser, [domain]);
const result = await importCookies(browser, [domain], profile);
if (result.cookies.length > 0) {
await page.context().addCookies(result.cookies);
}