mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-17 17:51:27 +08: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[];
|
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 {
|
export interface DomainEntry {
|
||||||
domain: string;
|
domain: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -101,16 +106,73 @@ const keyCache = new Map<string, Buffer>();
|
|||||||
// ─── Public API ─────────────────────────────────────────────────
|
// ─── 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[] {
|
export function findInstalledBrowsers(): BrowserInfo[] {
|
||||||
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
|
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
|
||||||
return BROWSER_REGISTRY.filter(b => {
|
return BROWSER_REGISTRY.filter(b => {
|
||||||
const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies');
|
const browserDir = path.join(appSupport, b.dataDir);
|
||||||
try { return fs.existsSync(dbPath); } catch { return false; }
|
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.
|
* List unique cookie domains + counts from a browser's DB. No decryption.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserManager } from './browser-manager';
|
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';
|
import { getCookiePickerHTML } from './cookie-picker-ui';
|
||||||
|
|
||||||
// ─── State ──────────────────────────────────────────────────────
|
// ─── State ──────────────────────────────────────────────────────
|
||||||
@@ -90,13 +90,24 @@ export async function handleCookiePickerRoute(
|
|||||||
}, { port });
|
}, { 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') {
|
if (pathname === '/cookie-picker/domains' && req.method === 'GET') {
|
||||||
const browserName = url.searchParams.get('browser');
|
const browserName = url.searchParams.get('browser');
|
||||||
if (!browserName) {
|
if (!browserName) {
|
||||||
return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
|
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({
|
return jsonResponse({
|
||||||
browser: result.browser,
|
browser: result.browser,
|
||||||
domains: result.domains,
|
domains: result.domains,
|
||||||
@@ -112,14 +123,14 @@ export async function handleCookiePickerRoute(
|
|||||||
return errorResponse('Invalid JSON body', 'bad_request', { port });
|
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 (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port });
|
||||||
if (!domains || !Array.isArray(domains) || domains.length === 0) {
|
if (!domains || !Array.isArray(domains) || domains.length === 0) {
|
||||||
return errorResponse("Missing or empty 'domains' array", 'missing_param', { port });
|
return errorResponse("Missing or empty 'domains' array", 'missing_param', { port });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt cookies from the browser DB
|
// 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) {
|
if (result.cookies.length === 0) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
|
|||||||
@@ -101,6 +101,30 @@ export function getCookiePickerHTML(serverPort: number): string {
|
|||||||
background: #4ade80;
|
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 ──────────────────────────── */
|
||||||
.search-wrap {
|
.search-wrap {
|
||||||
padding: 0 20px 12px;
|
padding: 0 20px 12px;
|
||||||
@@ -268,6 +292,7 @@ export function getCookiePickerHTML(serverPort: number): string {
|
|||||||
<div class="panel panel-left">
|
<div class="panel panel-left">
|
||||||
<div class="panel-header">Source Browser</div>
|
<div class="panel-header">Source Browser</div>
|
||||||
<div id="browser-pills" class="browser-pills"></div>
|
<div id="browser-pills" class="browser-pills"></div>
|
||||||
|
<div id="profile-pills" class="profile-pills" style="display:none"></div>
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
|
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
|
||||||
</div>
|
</div>
|
||||||
@@ -291,11 +316,14 @@ export function getCookiePickerHTML(serverPort: number): string {
|
|||||||
(function() {
|
(function() {
|
||||||
const BASE = '${baseUrl}';
|
const BASE = '${baseUrl}';
|
||||||
let activeBrowser = null;
|
let activeBrowser = null;
|
||||||
|
let activeProfile = 'Default';
|
||||||
|
let allProfiles = [];
|
||||||
let allDomains = [];
|
let allDomains = [];
|
||||||
let importedSet = {}; // domain → count
|
let importedSet = {}; // domain → count
|
||||||
let inflight = {}; // domain → true (prevents double-click)
|
let inflight = {}; // domain → true (prevents double-click)
|
||||||
|
|
||||||
const $pills = document.getElementById('browser-pills');
|
const $pills = document.getElementById('browser-pills');
|
||||||
|
const $profilePills = document.getElementById('profile-pills');
|
||||||
const $search = document.getElementById('search');
|
const $search = document.getElementById('search');
|
||||||
const $sourceDomains = document.getElementById('source-domains');
|
const $sourceDomains = document.getElementById('source-domains');
|
||||||
const $importedDomains = document.getElementById('imported-domains');
|
const $importedDomains = document.getElementById('imported-domains');
|
||||||
@@ -380,22 +408,76 @@ export function getCookiePickerHTML(serverPort: number): string {
|
|||||||
// ─── Select Browser ────────────────────
|
// ─── Select Browser ────────────────────
|
||||||
async function selectBrowser(name) {
|
async function selectBrowser(name) {
|
||||||
activeBrowser = name;
|
activeBrowser = name;
|
||||||
|
activeProfile = 'Default';
|
||||||
|
|
||||||
// Update pills
|
// Update pills
|
||||||
$pills.querySelectorAll('.pill').forEach(p => {
|
$pills.querySelectorAll('.pill').forEach(p => {
|
||||||
p.classList.toggle('active', p.textContent === name);
|
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 = '';
|
$sourceFooter.textContent = '';
|
||||||
$search.value = '';
|
$search.value = '';
|
||||||
|
|
||||||
try {
|
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;
|
allDomains = data.domains;
|
||||||
renderSourceDomains();
|
renderSourceDomains();
|
||||||
} catch (err) {
|
} 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>';
|
$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', {
|
const data = await api('/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ browser: activeBrowser, domains: [domain] }),
|
body: JSON.stringify({ browser: activeBrowser, domains: [domain], profile: activeProfile }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.domainCounts) {
|
if (data.domainCounts) {
|
||||||
|
|||||||
@@ -309,16 +309,18 @@ export async function handleWriteCommand(
|
|||||||
|
|
||||||
case 'cookie-import-browser': {
|
case 'cookie-import-browser': {
|
||||||
// Two modes:
|
// 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]
|
// 2. Open picker UI: cookie-import-browser [browser]
|
||||||
const browserArg = args[0];
|
const browserArg = args[0];
|
||||||
const domainIdx = args.indexOf('--domain');
|
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) {
|
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
||||||
// Direct import mode — no UI
|
// Direct import mode — no UI
|
||||||
const domain = args[domainIdx + 1];
|
const domain = args[domainIdx + 1];
|
||||||
const browser = browserArg || 'comet';
|
const browser = browserArg || 'comet';
|
||||||
const result = await importCookies(browser, [domain]);
|
const result = await importCookies(browser, [domain], profile);
|
||||||
if (result.cookies.length > 0) {
|
if (result.cookies.length > 0) {
|
||||||
await page.context().addCookies(result.cookies);
|
await page.context().addCookies(result.cookies);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user