mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 14:34:49 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/chrome-extension-ctrl
# Conflicts: # browse/src/browser-manager.ts # browse/src/cli.ts
This commit is contained in:
+5
-2
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: browse
|
||||
preamble-tier: 1
|
||||
version: 1.1.0
|
||||
description: |
|
||||
Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with
|
||||
@@ -44,7 +45,8 @@ echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
echo '{"skill":"browse","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done
|
||||
# zsh-compatible: use find instead of glob to avoid NOMATCH error
|
||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done
|
||||
```
|
||||
|
||||
If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke
|
||||
@@ -104,6 +106,7 @@ This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely.
|
||||
2. **Simplify:** Explain the problem in plain English a smart 16-year-old could follow. No raw function names, no internal jargon, no implementation details. Use concrete examples and analogies. Say what it DOES, not what it's called.
|
||||
3. **Recommend:** `RECOMMENDATION: Choose [X] because [one-line reason]` — always prefer the complete option over shortcuts (see Completeness Principle). Include `Completeness: X/10` for each option. Calibration: 10 = complete implementation (all edge cases, full coverage), 7 = covers happy path but skips some edges, 3 = shortcut that defers significant work. If both options are 8+, pick the higher; if one is ≤5, flag it.
|
||||
4. **Options:** Lettered options: `A) ... B) ... C) ...` — when an option involves effort, show both scales: `(human: ~X / CC: ~Y)`
|
||||
5. **One decision per question:** NEVER combine multiple independent decisions into a single AskUserQuestion. Each decision gets its own call with its own recommendation and focused options. Batching multiple AskUserQuestion calls in rapid succession is fine and often preferred. Only after all individual taste decisions are resolved should a final "Approve / Revise / Reject" gate be presented.
|
||||
|
||||
Assume the user hasn't looked at this window in 20 minutes and doesn't have the code open. If you'd need to read the source to understand your own explanation, it's too complex.
|
||||
|
||||
@@ -487,7 +490,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| `click <sel>` | Click element |
|
||||
| `cookie <name>=<value>` | Set cookie on current page domain |
|
||||
| `cookie-import <json>` | Import cookies from JSON file |
|
||||
| `cookie-import-browser [browser] [--domain d]` | Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import) |
|
||||
| `cookie-import-browser [browser] [--domain d]` | Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import) |
|
||||
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
|
||||
| `dialog-dismiss` | Auto-dismiss next dialog |
|
||||
| `fill <sel> <val>` | Fill input |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: browse
|
||||
preamble-tier: 1
|
||||
version: 1.1.0
|
||||
description: |
|
||||
Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with
|
||||
|
||||
@@ -144,7 +144,39 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
async launch() {
|
||||
this.browser = await chromium.launch({ headless: true });
|
||||
// ─── Extension Support ────────────────────────────────────
|
||||
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
||||
// Extensions only work in headed mode, so we use an off-screen window.
|
||||
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
|
||||
const launchArgs: string[] = [];
|
||||
let useHeadless = true;
|
||||
|
||||
// Docker/CI: Chromium sandbox requires unprivileged user namespaces which
|
||||
// are typically disabled in containers. Detect container environment and
|
||||
// add --no-sandbox automatically.
|
||||
if (process.env.CI || process.env.CONTAINER) {
|
||||
launchArgs.push('--no-sandbox');
|
||||
}
|
||||
|
||||
if (extensionsDir) {
|
||||
launchArgs.push(
|
||||
`--disable-extensions-except=${extensionsDir}`,
|
||||
`--load-extension=${extensionsDir}`,
|
||||
'--window-position=-9999,-9999',
|
||||
'--window-size=1,1',
|
||||
);
|
||||
useHeadless = false; // extensions require headed mode; off-screen window simulates headless
|
||||
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
|
||||
}
|
||||
|
||||
this.browser = await chromium.launch({
|
||||
headless: useHeadless,
|
||||
// On Windows, Chromium's sandbox fails when the server is spawned through
|
||||
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
||||
// browsing user-specified URLs has marginal sandbox benefit.
|
||||
chromiumSandbox: process.platform !== 'win32',
|
||||
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
||||
});
|
||||
|
||||
// Chromium crash → exit with clear message
|
||||
this.browser.on('disconnected', () => {
|
||||
|
||||
+109
-40
@@ -15,7 +15,7 @@ import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
|
||||
const config = resolveConfig();
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
const MAX_START_WAIT = IS_WINDOWS ? 15000 : 8000; // Node+Chromium takes longer on Windows
|
||||
const MAX_START_WAIT = IS_WINDOWS ? 15000 : (process.env.CI ? 30000 : 8000); // Node+Chromium takes longer on Windows
|
||||
|
||||
export function resolveServerScript(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
@@ -76,6 +76,13 @@ export function resolveNodeServerScript(
|
||||
|
||||
const NODE_SERVER_SCRIPT = IS_WINDOWS ? resolveNodeServerScript() : null;
|
||||
|
||||
// On Windows, hard-fail if server-node.mjs is missing — the Bun path is known broken.
|
||||
if (IS_WINDOWS && !NODE_SERVER_SCRIPT) {
|
||||
throw new Error(
|
||||
'server-node.mjs not found. Run `bun run build` to generate the Windows server bundle.'
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerState {
|
||||
pid: number;
|
||||
port: number;
|
||||
@@ -97,6 +104,19 @@ function readState(): ServerState | null {
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
if (IS_WINDOWS) {
|
||||
// Bun's compiled binary can't signal Windows PIDs (always throws ESRCH).
|
||||
// Use tasklist as a fallback. Only for one-shot calls — too slow for polling loops.
|
||||
try {
|
||||
const result = Bun.spawnSync(
|
||||
['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
|
||||
{ stdout: 'pipe', stderr: 'pipe', timeout: 3000 }
|
||||
);
|
||||
return result.stdout.toString().includes(`"${pid}"`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
@@ -105,10 +125,42 @@ function isProcessAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP health check — definitive proof the server is alive and responsive.
|
||||
* Used in all polling loops instead of isProcessAlive() (which is slow on Windows).
|
||||
*/
|
||||
export async function isServerHealthy(port: number): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (!resp.ok) return false;
|
||||
const health = await resp.json() as any;
|
||||
return health.status === 'healthy';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Process Management ─────────────────────────────────────────
|
||||
async function killServer(pid: number): Promise<void> {
|
||||
if (!isProcessAlive(pid)) return;
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
// taskkill /T /F kills the process tree (Node + Chromium)
|
||||
try {
|
||||
Bun.spawnSync(
|
||||
['taskkill', '/PID', String(pid), '/T', '/F'],
|
||||
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000 }
|
||||
);
|
||||
} catch {}
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline && isProcessAlive(pid)) {
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try { process.kill(pid, 'SIGTERM'); } catch { return; }
|
||||
|
||||
// Wait up to 2s for graceful shutdown
|
||||
@@ -128,6 +180,10 @@ async function killServer(pid: number): Promise<void> {
|
||||
* Verifies PID ownership before sending signals.
|
||||
*/
|
||||
function cleanupLegacyState(): void {
|
||||
// No legacy state on Windows — /tmp and `ps` don't exist, and gstack
|
||||
// never ran on Windows before the Node.js fallback was added.
|
||||
if (IS_WINDOWS) return;
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync('/tmp').filter(f => f.startsWith('browse-server') && f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
@@ -165,44 +221,65 @@ function cleanupLegacyState(): void {
|
||||
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
|
||||
ensureStateDir(config);
|
||||
|
||||
// Clean up stale state file
|
||||
// Clean up stale state file and error log
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
try { fs.unlinkSync(path.join(config.stateDir, 'browse-startup-error.log')); } catch {}
|
||||
|
||||
// Start server as detached background process.
|
||||
// On Windows, Bun can't launch/connect to Playwright's Chromium (oven-sh/bun#4253, #9911).
|
||||
// Fall back to running the server under Node.js with Bun API polyfills.
|
||||
const useNode = IS_WINDOWS && NODE_SERVER_SCRIPT;
|
||||
const serverCmd = useNode
|
||||
? ['node', NODE_SERVER_SCRIPT]
|
||||
: ['bun', 'run', SERVER_SCRIPT];
|
||||
const proc = Bun.spawn(serverCmd, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv },
|
||||
});
|
||||
let proc: any = null;
|
||||
|
||||
// Don't hold the CLI open
|
||||
proc.unref();
|
||||
if (IS_WINDOWS && NODE_SERVER_SCRIPT) {
|
||||
// Windows: Bun.spawn() + proc.unref() doesn't truly detach on Windows —
|
||||
// when the CLI exits, the server dies with it. Use Node's child_process.spawn
|
||||
// with { detached: true } instead, which is the gold standard for Windows
|
||||
// process independence. Credit: PR #191 by @fqueiro.
|
||||
const launcherCode =
|
||||
`const{spawn}=require('child_process');` +
|
||||
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
||||
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
|
||||
`{BROWSE_STATE_FILE:${JSON.stringify(config.stateFile)}})}).unref()`;
|
||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
} else {
|
||||
// macOS/Linux: Bun.spawn + unref works correctly
|
||||
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv },
|
||||
});
|
||||
proc.unref();
|
||||
}
|
||||
|
||||
// Wait for state file to appear
|
||||
// Wait for server to become healthy.
|
||||
// Use HTTP health check (not isProcessAlive) — it's fast (~instant ECONNREFUSED)
|
||||
// and works reliably on all platforms including Windows.
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < MAX_START_WAIT) {
|
||||
const state = readState();
|
||||
if (state && isProcessAlive(state.pid)) {
|
||||
if (state && await isServerHealthy(state.port)) {
|
||||
return state;
|
||||
}
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
|
||||
// If we get here, server didn't start in time
|
||||
// Try to read stderr for error message
|
||||
const stderr = proc.stderr;
|
||||
if (stderr) {
|
||||
const reader = stderr.getReader();
|
||||
// Server didn't start in time — try to get error details
|
||||
if (proc?.stderr) {
|
||||
// macOS/Linux: read stderr from the spawned process
|
||||
const reader = proc.stderr.getReader();
|
||||
const { value } = await reader.read();
|
||||
if (value) {
|
||||
const errText = new TextDecoder().decode(value);
|
||||
throw new Error(`Server failed to start:\n${errText}`);
|
||||
}
|
||||
} else {
|
||||
// Windows: check startup error log (server writes errors to disk since
|
||||
// stderr is unavailable due to stdio: 'ignore' for detachment)
|
||||
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
|
||||
try {
|
||||
const errorLog = fs.readFileSync(errorLogPath, 'utf-8').trim();
|
||||
if (errorLog) {
|
||||
throw new Error(`Server failed to start:\n${errorLog}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
|
||||
}
|
||||
@@ -238,7 +315,10 @@ function acquireServerLock(): (() => void) | null {
|
||||
async function ensureServer(): Promise<ServerState> {
|
||||
const state = readState();
|
||||
|
||||
if (state && isProcessAlive(state.pid)) {
|
||||
// Health-check-first: HTTP is definitive proof the server is alive and responsive.
|
||||
// This replaces the PID-gated approach which breaks on Windows (Bun's process.kill
|
||||
// always throws ESRCH for Windows PIDs in compiled binaries).
|
||||
if (state && await isServerHealthy(state.port)) {
|
||||
// Check for binary version mismatch (auto-restart on update)
|
||||
const currentVersion = readVersionHash();
|
||||
if (currentVersion && state.binaryVersion && currentVersion !== state.binaryVersion) {
|
||||
@@ -246,21 +326,7 @@ async function ensureServer(): Promise<ServerState> {
|
||||
await killServer(state.pid);
|
||||
return startServer();
|
||||
}
|
||||
|
||||
// Server appears alive — do a health check
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const health = await resp.json() as any;
|
||||
if (health.status === 'healthy') {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Health check failed — server is dead or unhealthy
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// Guard: never silently replace a headed server with a headless one.
|
||||
@@ -272,6 +338,9 @@ async function ensureServer(): Promise<ServerState> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure state directory exists before lock acquisition (lock file lives there)
|
||||
ensureStateDir(config);
|
||||
|
||||
// Acquire lock to prevent concurrent restart races (TOCTOU)
|
||||
const releaseLock = acquireServerLock();
|
||||
if (!releaseLock) {
|
||||
@@ -280,7 +349,7 @@ async function ensureServer(): Promise<ServerState> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < MAX_START_WAIT) {
|
||||
const freshState = readState();
|
||||
if (freshState && isProcessAlive(freshState.pid)) return freshState;
|
||||
if (freshState && await isServerHealthy(freshState.port)) return freshState;
|
||||
await Bun.sleep(200);
|
||||
}
|
||||
throw new Error('Timed out waiting for another instance to start the server');
|
||||
@@ -289,7 +358,7 @@ async function ensureServer(): Promise<ServerState> {
|
||||
try {
|
||||
// Re-read state under lock in case another process just started the server
|
||||
const freshState = readState();
|
||||
if (freshState && isProcessAlive(freshState.pid)) {
|
||||
if (freshState && await isServerHealthy(freshState.port)) {
|
||||
return freshState;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport <WxH>' },
|
||||
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
|
||||
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' },
|
||||
'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <name>:<value>' },
|
||||
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
|
||||
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
/**
|
||||
* Chromium browser cookie import — read and decrypt cookies from real browsers
|
||||
*
|
||||
* Supports macOS Chromium-based browsers: Comet, Chrome, Arc, Brave, Edge.
|
||||
* Supports macOS and Linux Chromium-based browsers.
|
||||
* Pure logic module — no Playwright dependency, no HTTP concerns.
|
||||
*
|
||||
* Decryption pipeline (Chromium macOS "v10" format):
|
||||
* Decryption pipeline:
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────────┐
|
||||
* │ 1. Keychain: `security find-generic-password -s "<svc>" -w` │
|
||||
* │ → base64 password string │
|
||||
* │ 1. Resolve the cookie DB from the browser profile dir │
|
||||
* │ - macOS: ~/Library/Application Support/<browser>/<profile> │
|
||||
* │ - Linux: ~/.config/<browser>/<profile> │
|
||||
* │ │
|
||||
* │ 2. Key derivation: │
|
||||
* │ PBKDF2(password, salt="saltysalt", iter=1003, len=16, sha1) │
|
||||
* │ → 16-byte AES key │
|
||||
* │ 2. Derive the AES key │
|
||||
* │ - macOS v10: Keychain password, PBKDF2(..., iter=1003) │
|
||||
* │ - Linux v10: "peanuts", PBKDF2(..., iter=1) │
|
||||
* │ - Linux v11: libsecret/secret-tool password, iter=1 │
|
||||
* │ │
|
||||
* │ 3. For each cookie with encrypted_value starting with "v10": │
|
||||
* │ 3. For each cookie with encrypted_value starting with "v10"/ │
|
||||
* │ "v11": │
|
||||
* │ - Ciphertext = encrypted_value[3:] │
|
||||
* │ - IV = 16 bytes of 0x20 (space character) │
|
||||
* │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │
|
||||
* │ - Remove PKCS7 padding │
|
||||
* │ - Skip first 32 bytes (HMAC-SHA256 authentication tag) │
|
||||
* │ - Skip first 32 bytes of Chromium cookie metadata │
|
||||
* │ - Remaining bytes = cookie value (UTF-8) │
|
||||
* │ │
|
||||
* │ 4. If encrypted_value is empty but `value` field is set, │
|
||||
@@ -42,9 +45,16 @@ import * as os from 'os';
|
||||
|
||||
export interface BrowserInfo {
|
||||
name: string;
|
||||
dataDir: string; // relative to ~/Library/Application Support/
|
||||
dataDir: string; // primary storage dir (retained for compatibility with existing callers/tests)
|
||||
keychainService: string;
|
||||
aliases: string[];
|
||||
linuxDataDir?: string;
|
||||
linuxApplication?: 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 {
|
||||
@@ -81,15 +91,24 @@ export class CookieImportError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type BrowserPlatform = 'darwin' | 'linux';
|
||||
|
||||
interface BrowserMatch {
|
||||
browser: BrowserInfo;
|
||||
platform: BrowserPlatform;
|
||||
dbPath: string;
|
||||
}
|
||||
|
||||
// ─── Browser Registry ───────────────────────────────────────────
|
||||
// Hardcoded — NEVER interpolate user input into shell commands.
|
||||
|
||||
const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome'] },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'] },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'] },
|
||||
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome' },
|
||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium' },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave' },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge' },
|
||||
];
|
||||
|
||||
// ─── Key Cache ──────────────────────────────────────────────────
|
||||
@@ -101,23 +120,105 @@ 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; }
|
||||
return BROWSER_REGISTRY.filter(browser => {
|
||||
// Check Default profile on any platform
|
||||
if (findBrowserMatch(browser, 'Default') !== null) return true;
|
||||
// Check numbered profiles (Profile 1, Profile 2, etc.)
|
||||
for (const platform of getSearchPlatforms()) {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
if (!dataDir) continue;
|
||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
||||
try {
|
||||
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
||||
if (entries.some(e =>
|
||||
e.isDirectory() && e.name.startsWith('Profile ') &&
|
||||
fs.existsSync(path.join(browserDir, e.name, 'Cookies'))
|
||||
)) return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function listSupportedBrowserNames(): string[] {
|
||||
const hostPlatform = getHostPlatform();
|
||||
return BROWSER_REGISTRY
|
||||
.filter(browser => hostPlatform ? getDataDirForPlatform(browser, hostPlatform) !== null : true)
|
||||
.map(browser => browser.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* List available profiles for a browser.
|
||||
*/
|
||||
export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
const browser = resolveBrowser(browserName);
|
||||
const profiles: ProfileEntry[] = [];
|
||||
|
||||
// Scan each supported platform for profile directories
|
||||
for (const platform of getSearchPlatforms()) {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
if (!dataDir) continue;
|
||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
||||
if (!fs.existsSync(browserDir)) continue;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Avoid duplicates if the same profile appears on multiple platforms
|
||||
if (profiles.some(p => p.name === entry.name)) 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 });
|
||||
}
|
||||
|
||||
// Found profiles on this platform — no need to check others
|
||||
if (profiles.length > 0) break;
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* List unique cookie domains + counts from a browser's DB. No decryption.
|
||||
*/
|
||||
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
|
||||
const browser = resolveBrowser(browserName);
|
||||
const dbPath = getCookieDbPath(browser, profile);
|
||||
const db = openDb(dbPath, browser.name);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
try {
|
||||
const now = chromiumNow();
|
||||
const rows = db.query(
|
||||
@@ -144,9 +245,9 @@ export async function importCookies(
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
|
||||
const browser = resolveBrowser(browserName);
|
||||
const derivedKey = await getDerivedKey(browser);
|
||||
const dbPath = getCookieDbPath(browser, profile);
|
||||
const db = openDb(dbPath, browser.name);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const derivedKeys = await getDerivedKeys(match);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
|
||||
try {
|
||||
const now = chromiumNow();
|
||||
@@ -167,7 +268,7 @@ export async function importCookies(
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const value = decryptCookieValue(row, derivedKey);
|
||||
const value = decryptCookieValue(row, derivedKeys);
|
||||
const cookie = toPlaywrightCookie(row, value);
|
||||
cookies.push(cookie);
|
||||
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
|
||||
@@ -208,17 +309,61 @@ function validateProfile(profile: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function getCookieDbPath(browser: BrowserInfo, profile: string): string {
|
||||
validateProfile(profile);
|
||||
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
|
||||
const dbPath = path.join(appSupport, browser.dataDir, profile, 'Cookies');
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new CookieImportError(
|
||||
`${browser.name} is not installed (no cookie database at ${dbPath})`,
|
||||
'not_installed',
|
||||
);
|
||||
function getHostPlatform(): BrowserPlatform | null {
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') return process.platform;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSearchPlatforms(): BrowserPlatform[] {
|
||||
const current = getHostPlatform();
|
||||
const order: BrowserPlatform[] = [];
|
||||
if (current) order.push(current);
|
||||
for (const platform of ['darwin', 'linux'] as BrowserPlatform[]) {
|
||||
if (!order.includes(platform)) order.push(platform);
|
||||
}
|
||||
return dbPath;
|
||||
return order;
|
||||
}
|
||||
|
||||
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
|
||||
return platform === 'darwin' ? browser.dataDir : browser.linuxDataDir || null;
|
||||
}
|
||||
|
||||
function getBaseDir(platform: BrowserPlatform): string {
|
||||
return platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
||||
function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null {
|
||||
validateProfile(profile);
|
||||
for (const platform of getSearchPlatforms()) {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
if (!dataDir) continue;
|
||||
const dbPath = path.join(getBaseDir(platform), dataDir, profile, 'Cookies');
|
||||
try {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
return { browser, platform, dbPath };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
|
||||
const match = findBrowserMatch(browser, profile);
|
||||
if (match) return match;
|
||||
|
||||
const attempted = getSearchPlatforms()
|
||||
.map(platform => {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
return dataDir ? path.join(getBaseDir(platform), dataDir, profile, 'Cookies') : null;
|
||||
})
|
||||
.filter((entry): entry is string => entry !== null);
|
||||
|
||||
throw new CookieImportError(
|
||||
`${browser.name} is not installed (no cookie database at ${attempted.join(' or ')})`,
|
||||
'not_installed',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Internal: SQLite Access ────────────────────────────────────
|
||||
@@ -273,17 +418,40 @@ function openDbFromCopy(dbPath: string, browserName: string): Database {
|
||||
|
||||
// ─── Internal: Keychain Access (async, 10s timeout) ─────────────
|
||||
|
||||
async function getDerivedKey(browser: BrowserInfo): Promise<Buffer> {
|
||||
const cached = keyCache.get(browser.keychainService);
|
||||
if (cached) return cached;
|
||||
function deriveKey(password: string, iterations: number): Buffer {
|
||||
return crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
|
||||
}
|
||||
|
||||
const password = await getKeychainPassword(browser.keychainService);
|
||||
const derived = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
|
||||
keyCache.set(browser.keychainService, derived);
|
||||
function getCachedDerivedKey(cacheKey: string, password: string, iterations: number): Buffer {
|
||||
const cached = keyCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const derived = deriveKey(password, iterations);
|
||||
keyCache.set(cacheKey, derived);
|
||||
return derived;
|
||||
}
|
||||
|
||||
async function getKeychainPassword(service: string): Promise<string> {
|
||||
async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>> {
|
||||
if (match.platform === 'darwin') {
|
||||
const password = await getMacKeychainPassword(match.browser.keychainService);
|
||||
return new Map([
|
||||
['v10', getCachedDerivedKey(`darwin:${match.browser.keychainService}:v10`, password, 1003)],
|
||||
]);
|
||||
}
|
||||
|
||||
const keys = new Map<string, Buffer>();
|
||||
keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1));
|
||||
|
||||
const linuxPassword = await getLinuxSecretPassword(match.browser);
|
||||
if (linuxPassword) {
|
||||
keys.set(
|
||||
'v11',
|
||||
getCachedDerivedKey(`linux:${match.browser.keychainService}:v11`, linuxPassword, 1),
|
||||
);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
// Use async Bun.spawn with timeout to avoid blocking the event loop.
|
||||
// macOS may show an Allow/Deny dialog that blocks until the user responds.
|
||||
const proc = Bun.spawn(
|
||||
@@ -341,6 +509,47 @@ async function getKeychainPassword(service: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getLinuxSecretPassword(browser: BrowserInfo): Promise<string | null> {
|
||||
const attempts: string[][] = [
|
||||
['secret-tool', 'lookup', 'Title', browser.keychainService],
|
||||
];
|
||||
|
||||
if (browser.linuxApplication) {
|
||||
attempts.push(
|
||||
['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password_v2', 'application', browser.linuxApplication],
|
||||
['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password', 'application', browser.linuxApplication],
|
||||
);
|
||||
}
|
||||
|
||||
for (const cmd of attempts) {
|
||||
const password = await runPasswordLookup(cmd, 3_000);
|
||||
if (password) return password;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runPasswordLookup(cmd: string[], timeoutMs: number): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' });
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs),
|
||||
);
|
||||
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
if (exitCode !== 0) return null;
|
||||
|
||||
const password = stdout.trim();
|
||||
return password.length > 0 ? password : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal: Cookie Decryption ────────────────────────────────
|
||||
|
||||
interface RawCookie {
|
||||
@@ -356,7 +565,7 @@ interface RawCookie {
|
||||
samesite: number;
|
||||
}
|
||||
|
||||
function decryptCookieValue(row: RawCookie, key: Buffer): string {
|
||||
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string {
|
||||
// Prefer unencrypted value if present
|
||||
if (row.value && row.value.length > 0) return row.value;
|
||||
|
||||
@@ -364,16 +573,15 @@ function decryptCookieValue(row: RawCookie, key: Buffer): string {
|
||||
if (ev.length === 0) return '';
|
||||
|
||||
const prefix = ev.slice(0, 3).toString('utf-8');
|
||||
if (prefix !== 'v10') {
|
||||
throw new Error(`Unknown encryption prefix: ${prefix}`);
|
||||
}
|
||||
const key = keys.get(prefix);
|
||||
if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
|
||||
|
||||
const ciphertext = ev.slice(3);
|
||||
const iv = Buffer.alloc(16, 0x20); // 16 space characters
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
|
||||
// First 32 bytes are HMAC-SHA256 authentication tag; actual value follows
|
||||
// Chromium prefixes encrypted cookie payloads with 32 bytes of metadata.
|
||||
if (plaintext.length <= 32) return '';
|
||||
return plaintext.slice(32).toString('utf-8');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
|
||||
@@ -737,6 +737,13 @@ async function shutdown() {
|
||||
// Handle signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
||||
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
||||
if (process.platform === 'win32') {
|
||||
process.on('exit', () => {
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
// Emergency cleanup for crashes (OOM, uncaught exceptions, browser disconnect)
|
||||
function emergencyCleanup() {
|
||||
@@ -1121,5 +1128,14 @@ async function start() {
|
||||
|
||||
start().catch((err) => {
|
||||
console.error(`[browse] Failed to start: ${err.message}`);
|
||||
// Write error to disk for the CLI to read — on Windows, the CLI can't capture
|
||||
// stderr because the server is launched with detached: true, stdio: 'ignore'.
|
||||
try {
|
||||
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
|
||||
fs.mkdirSync(config.stateDir, { recursive: true });
|
||||
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
|
||||
} catch {
|
||||
// stateDir may not exist — nothing more we can do
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -82,8 +82,12 @@ export async function validateNavigationUrl(url: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// DNS rebinding protection: resolve hostname and check if it points to metadata IPs
|
||||
if (await resolvesToBlockedIp(hostname)) {
|
||||
// DNS rebinding protection: resolve hostname and check if it points to metadata IPs.
|
||||
// Skip for loopback/private IPs — they can't be DNS-rebinded and the async DNS
|
||||
// resolution adds latency that breaks concurrent E2E tests under load.
|
||||
const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
||||
const isPrivateNet = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/.test(hostname);
|
||||
if (!isLoopback && !isPrivateNet && await resolvesToBlockedIp(hostname)) {
|
||||
throw new Error(
|
||||
`Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.`
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, importCookies } from './cookie-import-browser';
|
||||
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -333,7 +335,7 @@ export async function handleWriteCommand(
|
||||
|
||||
const browsers = findInstalledBrowsers();
|
||||
if (browsers.length === 0) {
|
||||
throw new Error('No Chromium browsers found. Supported: Comet, Chrome, Arc, Brave, Edge');
|
||||
throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`);
|
||||
}
|
||||
|
||||
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`;
|
||||
|
||||
@@ -248,3 +248,69 @@ describe('version mismatch detection', () => {
|
||||
expect(shouldRestart).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isServerHealthy', () => {
|
||||
const { isServerHealthy } = require('../src/cli');
|
||||
const http = require('http');
|
||||
|
||||
test('returns true for a healthy server', async () => {
|
||||
const server = http.createServer((_req: any, res: any) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'healthy' }));
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, resolve));
|
||||
const port = server.address().port;
|
||||
try {
|
||||
expect(await isServerHealthy(port)).toBe(true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns false for an unhealthy server', async () => {
|
||||
const server = http.createServer((_req: any, res: any) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'unhealthy' }));
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, resolve));
|
||||
const port = server.address().port;
|
||||
try {
|
||||
expect(await isServerHealthy(port)).toBe(false);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns false when server is not running', async () => {
|
||||
// Use a port that's almost certainly not in use
|
||||
expect(await isServerHealthy(59999)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false on non-200 response', async () => {
|
||||
const server = http.createServer((_req: any, res: any) => {
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, resolve));
|
||||
const port = server.address().port;
|
||||
try {
|
||||
expect(await isServerHealthy(port)).toBe(false);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('startup error log', () => {
|
||||
test('write and read error log', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `browse-error-log-test-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const errorLogPath = path.join(tmpDir, 'browse-startup-error.log');
|
||||
const errorMsg = 'Cannot find module playwright';
|
||||
fs.writeFileSync(errorLogPath, `2026-03-23T00:00:00.000Z ${errorMsg}\n`);
|
||||
const content = fs.readFileSync(errorLogPath, 'utf-8').trim();
|
||||
expect(content).toContain(errorMsg);
|
||||
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO timestamp prefix
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Remaining bytes = actual cookie value
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, mock } from 'bun:test';
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
@@ -24,16 +24,26 @@ import * as os from 'os';
|
||||
|
||||
const TEST_PASSWORD = 'test-keychain-password';
|
||||
const TEST_KEY = crypto.pbkdf2Sync(TEST_PASSWORD, 'saltysalt', 1003, 16, 'sha1');
|
||||
const LINUX_V10_PASSWORD = 'peanuts';
|
||||
const LINUX_V10_KEY = crypto.pbkdf2Sync(LINUX_V10_PASSWORD, 'saltysalt', 1, 16, 'sha1');
|
||||
const LINUX_V11_PASSWORD = 'test-linux-secret';
|
||||
const LINUX_V11_KEY = crypto.pbkdf2Sync(LINUX_V11_PASSWORD, 'saltysalt', 1, 16, 'sha1');
|
||||
const IV = Buffer.alloc(16, 0x20);
|
||||
const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
|
||||
|
||||
// Fixture DB path
|
||||
const FIXTURE_DIR = path.join(import.meta.dir, 'fixtures');
|
||||
const FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies.db');
|
||||
const LINUX_FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies-linux.db');
|
||||
|
||||
// ─── Encryption Helper ──────────────────────────────────────────
|
||||
|
||||
function encryptCookieValue(value: string): Buffer {
|
||||
function encryptCookieValue(
|
||||
value: string,
|
||||
options?: { key?: Buffer; prefix?: 'v10' | 'v11' },
|
||||
): Buffer {
|
||||
const key = options?.key ?? TEST_KEY;
|
||||
const prefix = options?.prefix ?? 'v10';
|
||||
// 32-byte HMAC tag (random for test) + actual value
|
||||
const hmacTag = crypto.randomBytes(32);
|
||||
const plaintext = Buffer.concat([hmacTag, Buffer.from(value, 'utf-8')]);
|
||||
@@ -43,12 +53,11 @@ function encryptCookieValue(value: string): Buffer {
|
||||
const padLen = blockSize - (plaintext.length % blockSize);
|
||||
const padded = Buffer.concat([plaintext, Buffer.alloc(padLen, padLen)]);
|
||||
|
||||
const cipher = crypto.createCipheriv('aes-128-cbc', TEST_KEY, IV);
|
||||
const cipher = crypto.createCipheriv('aes-128-cbc', key, IV);
|
||||
cipher.setAutoPadding(false); // We padded manually
|
||||
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
||||
|
||||
// Prefix with "v10"
|
||||
return Buffer.concat([Buffer.from('v10'), encrypted]);
|
||||
return Buffer.concat([Buffer.from(prefix), encrypted]);
|
||||
}
|
||||
|
||||
function chromiumEpoch(unixSeconds: number): bigint {
|
||||
@@ -57,11 +66,11 @@ function chromiumEpoch(unixSeconds: number): bigint {
|
||||
|
||||
// ─── Create Fixture Database ────────────────────────────────────
|
||||
|
||||
function createFixtureDb() {
|
||||
function createFixtureDb(dbPath: string): Database {
|
||||
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
||||
if (fs.existsSync(FIXTURE_DB)) fs.unlinkSync(FIXTURE_DB);
|
||||
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
|
||||
|
||||
const db = new Database(FIXTURE_DB);
|
||||
const db = new Database(dbPath);
|
||||
db.run(`CREATE TABLE cookies (
|
||||
host_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
@@ -74,7 +83,11 @@ function createFixtureDb() {
|
||||
has_expires INTEGER NOT NULL DEFAULT 0,
|
||||
samesite INTEGER NOT NULL DEFAULT 1
|
||||
)`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function createMacFixtureDb() {
|
||||
const db = createFixtureDb(FIXTURE_DB);
|
||||
const insert = db.prepare(`INSERT INTO cookies
|
||||
(host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
@@ -110,6 +123,21 @@ function createFixtureDb() {
|
||||
db.close();
|
||||
}
|
||||
|
||||
function createLinuxFixtureDb() {
|
||||
const db = createFixtureDb(LINUX_FIXTURE_DB);
|
||||
const insert = db.prepare(`INSERT INTO cookies
|
||||
(host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
|
||||
const futureExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) + 86400 * 365));
|
||||
|
||||
insert.run('.linux-v10.com', 'sid', '', encryptCookieValue('linux-v10-value', { key: LINUX_V10_KEY, prefix: 'v10' }), '/', futureExpiry, 1, 1, 1, 1);
|
||||
insert.run('.linux-v11.com', 'auth', '', encryptCookieValue('linux-v11-value', { key: LINUX_V11_KEY, prefix: 'v11' }), '/', futureExpiry, 1, 1, 1, 1);
|
||||
insert.run('.linux-plain.com', 'plain', 'plain-linux', Buffer.alloc(0), '/', futureExpiry, 0, 0, 1, 1);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ─── Mock Setup ─────────────────────────────────────────────────
|
||||
// We need to mock:
|
||||
// 1. The Keychain access (getKeychainPassword) to return TEST_PASSWORD
|
||||
@@ -120,17 +148,18 @@ let findInstalledBrowsers: any;
|
||||
let listDomains: any;
|
||||
let importCookies: any;
|
||||
let CookieImportError: any;
|
||||
let originalSpawn: typeof Bun.spawn;
|
||||
|
||||
beforeAll(async () => {
|
||||
createFixtureDb();
|
||||
createMacFixtureDb();
|
||||
createLinuxFixtureDb();
|
||||
|
||||
// Mock Bun.spawn to return test password for keychain access
|
||||
const origSpawn = Bun.spawn;
|
||||
originalSpawn = Bun.spawn;
|
||||
// @ts-ignore - monkey-patching for test
|
||||
Bun.spawn = function(cmd: any, opts: any) {
|
||||
// Intercept security find-generic-password calls
|
||||
if (Array.isArray(cmd) && cmd[0] === 'security' && cmd[1] === 'find-generic-password') {
|
||||
const service = cmd[3]; // -s <service>
|
||||
// Return test password for any known test service
|
||||
return {
|
||||
stdout: new ReadableStream({
|
||||
@@ -146,8 +175,23 @@ beforeAll(async () => {
|
||||
kill: () => {},
|
||||
};
|
||||
}
|
||||
if (Array.isArray(cmd) && cmd[0] === 'secret-tool' && cmd[1] === 'lookup') {
|
||||
return {
|
||||
stdout: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(LINUX_V11_PASSWORD + '\n'));
|
||||
controller.close();
|
||||
}
|
||||
}),
|
||||
stderr: new ReadableStream({
|
||||
start(controller) { controller.close(); }
|
||||
}),
|
||||
exited: Promise.resolve(0),
|
||||
kill: () => {},
|
||||
};
|
||||
}
|
||||
// Pass through other spawn calls
|
||||
return origSpawn(cmd, opts);
|
||||
return originalSpawn(cmd, opts);
|
||||
};
|
||||
|
||||
// Import the module (uses our mocked Bun.spawn)
|
||||
@@ -159,8 +203,12 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore Bun.spawn
|
||||
// @ts-ignore - monkey-patching for test
|
||||
Bun.spawn = originalSpawn;
|
||||
// Clean up fixture DB
|
||||
try { fs.unlinkSync(FIXTURE_DB); } catch {}
|
||||
try { fs.unlinkSync(LINUX_FIXTURE_DB); } catch {}
|
||||
try { fs.rmdirSync(FIXTURE_DIR); } catch {}
|
||||
});
|
||||
|
||||
@@ -176,6 +224,35 @@ afterAll(() => {
|
||||
// 2. Decrypting them with the module's decryption logic
|
||||
// The actual DB path resolution is tested separately.
|
||||
|
||||
async function withInstalledProfile<T>(
|
||||
relativeBrowserDir: string,
|
||||
sourceDb: string,
|
||||
run: () => Promise<T>,
|
||||
profile = 'Default',
|
||||
): Promise<T> {
|
||||
const homeDir = os.homedir();
|
||||
const profileDir = path.join(homeDir, relativeBrowserDir, profile);
|
||||
const cookiesPath = path.join(profileDir, 'Cookies');
|
||||
const backupPath = path.join(profileDir, `Cookies.backup-${crypto.randomUUID()}`);
|
||||
const hadOriginal = fs.existsSync(cookiesPath);
|
||||
|
||||
fs.mkdirSync(profileDir, { recursive: true });
|
||||
if (hadOriginal) fs.copyFileSync(cookiesPath, backupPath);
|
||||
fs.copyFileSync(sourceDb, cookiesPath);
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (hadOriginal) {
|
||||
fs.copyFileSync(backupPath, cookiesPath);
|
||||
fs.unlinkSync(backupPath);
|
||||
} else {
|
||||
try { fs.unlinkSync(cookiesPath); } catch {}
|
||||
try { fs.rmdirSync(profileDir); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Cookie Import Browser', () => {
|
||||
@@ -351,6 +428,51 @@ describe('Cookie Import Browser', () => {
|
||||
expect(b).toHaveProperty('aliases');
|
||||
}
|
||||
});
|
||||
|
||||
test('detects linux-style Chromium profiles under ~/.config', async () => {
|
||||
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
|
||||
const browsers = findInstalledBrowsers();
|
||||
const names = browsers.map((browser: any) => browser.name);
|
||||
|
||||
expect(names).toContain('Chromium');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real Profile Imports', () => {
|
||||
test('imports Linux v10 cookies from ~/.config/chromium', async () => {
|
||||
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
|
||||
const result = await importCookies('chromium', ['.linux-v10.com'], 'GstackLinuxV10');
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.cookies[0].name).toBe('sid');
|
||||
expect(result.cookies[0].value).toBe('linux-v10-value');
|
||||
}, 'GstackLinuxV10');
|
||||
});
|
||||
|
||||
test('imports Linux v11 cookies when secret-tool returns a key', async () => {
|
||||
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
|
||||
const result = await importCookies('chromium', ['.linux-v11.com'], 'GstackLinuxV11');
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.cookies[0].name).toBe('auth');
|
||||
expect(result.cookies[0].value).toBe('linux-v11-value');
|
||||
}, 'GstackLinuxV11');
|
||||
});
|
||||
|
||||
test('lists domains from Linux Chromium profiles', async () => {
|
||||
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
|
||||
const result = listDomains('chromium', 'GstackLinuxDomains');
|
||||
const domains = result.domains.map((entry: any) => entry.domain);
|
||||
|
||||
expect(result.browser).toBe('Chromium');
|
||||
expect(domains).toContain('.linux-v10.com');
|
||||
expect(domains).toContain('.linux-v11.com');
|
||||
expect(domains).toContain('.linux-plain.com');
|
||||
}, 'GstackLinuxDomains');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Corrupt Data Handling', () => {
|
||||
|
||||
@@ -447,6 +447,24 @@ describe('gstack-update-check', () => {
|
||||
expect(cache).toContain('UP_TO_DATE');
|
||||
});
|
||||
|
||||
test('--force clears snooze so user can upgrade after snoozing', () => {
|
||||
writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
|
||||
writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
|
||||
writeSnooze('0.4.0', 1, nowEpoch() - 60); // snoozed 1 min ago (within 24h)
|
||||
|
||||
// Without --force: snoozed, silent
|
||||
const snoozed = run();
|
||||
expect(snoozed.exitCode).toBe(0);
|
||||
expect(snoozed.stdout).toBe('');
|
||||
|
||||
// With --force: snooze cleared, outputs upgrade
|
||||
const forced = run({}, ['--force']);
|
||||
expect(forced.exitCode).toBe(0);
|
||||
expect(forced.stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
|
||||
// Snooze file should be deleted
|
||||
expect(existsSync(join(stateDir, 'update-snoozed'))).toBe(false);
|
||||
});
|
||||
|
||||
// ─── Split TTL tests ─────────────────────────────────────────
|
||||
|
||||
test('UP_TO_DATE cache expires after 60 min (not 720)', () => {
|
||||
|
||||
Reference in New Issue
Block a user