merge: PR #65 - feat: support Chrome multi-profile cookie import

This commit is contained in:
Garry Tan
2026-03-22 22:51:56 -07:00
4 changed files with 242 additions and 16 deletions
+72 -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,80 @@ 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.
// Prefer account email — signed-in Chrome profiles often have generic
// names like "Person 2" while the email is far more readable.
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 email = prefs?.account_info?.[0]?.email;
if (email && typeof email === 'string') {
displayName = email;
} else {
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({
+150 -6
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;
@@ -189,7 +213,22 @@ export function getCookiePickerHTML(serverPort: number): string {
border-top: 1px solid #222;
font-size: 12px;
color: #666;
display: flex;
align-items: center;
justify-content: space-between;
}
.btn-import-all {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid #333;
background: #1a1a1a;
color: #4ade80;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.btn-import-all:hover { border-color: #4ade80; background: #0a2a14; }
.btn-import-all:disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
/* ─── Imported Panel ──────────────────── */
.imported-empty {
@@ -268,13 +307,14 @@ 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>
<div class="domain-list" id="source-domains">
<div class="loading-row"><span class="spinner"></span> Detecting browsers...</div>
</div>
<div class="panel-footer" id="source-footer"></div>
<div class="panel-footer" id="source-footer"><span id="source-footer-text"></span><button class="btn-import-all" id="btn-import-all" style="display:none">Import All</button></div>
</div>
<!-- Right Panel: Imported -->
@@ -291,15 +331,19 @@ 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');
const $sourceFooter = document.getElementById('source-footer');
const $sourceFooter = document.getElementById('source-footer-text');
const $btnImportAll = document.getElementById('btn-import-all');
const $importedFooter = document.getElementById('imported-footer');
const $banner = document.getElementById('banner');
@@ -380,22 +424,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>';
}
}
@@ -437,6 +535,16 @@ export function getCookiePickerHTML(serverPort: number): string {
const totalCookies = allDomains.reduce((s, d) => s + d.count, 0);
$sourceFooter.textContent = totalDomains + ' domains · ' + totalCookies.toLocaleString() + ' cookies';
// Show/hide Import All button
const unimported = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
if (unimported.length > 0) {
$btnImportAll.style.display = '';
$btnImportAll.disabled = false;
$btnImportAll.textContent = 'Import All (' + unimported.length + ')';
} else {
$btnImportAll.style.display = 'none';
}
// Click handlers
$sourceDomains.querySelectorAll('.btn-add[data-domain]').forEach(btn => {
btn.addEventListener('click', () => importDomain(btn.dataset.domain));
@@ -453,7 +561,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) {
@@ -471,6 +579,42 @@ export function getCookiePickerHTML(serverPort: number): string {
}
}
// ─── Import All ───────────────────────
async function importAll() {
const query = $search.value.toLowerCase();
const filtered = query
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
: allDomains;
const toImport = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
if (toImport.length === 0) return;
$btnImportAll.disabled = true;
$btnImportAll.textContent = 'Importing...';
const domains = toImport.map(d => d.domain);
try {
const data = await api('/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ browser: activeBrowser, domains: domains, profile: activeProfile }),
});
if (data.domainCounts) {
for (const [d, count] of Object.entries(data.domainCounts)) {
importedSet[d] = (importedSet[d] || 0) + count;
}
}
renderImported();
} catch (err) {
showBanner('Import all failed: ' + err.message, 'error',
err.action === 'retry' ? () => importAll() : null);
} finally {
renderSourceDomains();
}
}
$btnImportAll.addEventListener('click', importAll);
// ─── Render Imported ───────────────────
function renderImported() {
const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);
+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);
}