Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions browse/src/cookie-import-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
21 changes: 16 additions & 5 deletions browse/src/cookie-picker-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down
154 changes: 149 additions & 5 deletions browse/src/cookie-picker-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 -->
Expand All @@ -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');

Expand Down Expand Up @@ -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...</div>';
$sourceFooter.textContent = '';
$search.value = '';

try {
// 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(name));
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>';
}
}
Expand Down Expand Up @@ -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));
Expand All @@ -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) {
Expand All @@ -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]);
Expand Down
Loading