mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
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:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user