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
74 changes: 71 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 {
folder: string; // e.g. "Default", "Profile 1"
name: string; // display name, e.g. "Nik (Nik Redl)"
}

export interface DomainEntry {
domain: string;
count: number;
Expand Down Expand Up @@ -101,14 +106,77 @@ 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 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<string, { name?: string; gaia_name?: string }> = {};
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;
}

/**
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
82 changes: 78 additions & 4 deletions browse/src/cookie-picker-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -268,6 +293,9 @@ 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-wrap" class="profile-wrap hidden">
<select id="profile-select" class="profile-select"></select>
</div>
<div class="search-wrap">
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
</div>
Expand All @@ -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');
Expand Down Expand Up @@ -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 = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading profiles...</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));
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 = '<div class="imported-empty">Failed to load</div>';
}
}

// ─── Load Domains for Active Browser+Profile ──
async function loadDomains() {
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
$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 = '<div class="imported-empty">Failed to load domains</div>';
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);

Expand Down