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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## 0.3.10 — 2026-03-15

### Added
- **Windows cookie import** — full cookie decryption pipeline for Chrome, Edge, and Brave on Windows using DPAPI + AES-256-GCM via PowerShell. Supports v10 cookies (Chrome < 127); v20 App-Bound cookies fail gracefully with clear messaging.
- **Platform dispatcher (`cookie-import.ts`)** — routes `findInstalledBrowsers`, `listDomains`, `importCookies` to macOS or Windows module based on `process.platform`.
- **Shared module (`cookie-import-shared.ts`)** — extracted types, Chromium epoch utils, sameSite mapping, profile validation, and DB copy-when-locked helper shared by both platform modules.
- **`better-sqlite3` dependency** — prebuilt native SQLite for Node.js/tsx on Windows (bun:sqlite unavailable).
- **Windows cookie import tests** — 27 new tests covering AES-256-GCM round-trip, fixture DB structure, shared module utils, and corrupt data handling.
- **Chrome 96+ `Network/Cookies` fallback** — both macOS and Windows now check `Network/Cookies` first, then legacy `Cookies` path.
- **Cross-platform browser launch** — `cmd /c start` (Windows), `open` (macOS), `xdg-open` (Linux) for cookie picker UI.

### Changed
- Refactored `cookie-import-browser.ts` to import shared code from `cookie-import-shared.ts`, reducing duplication.
- `cookie-picker-routes.ts` and `write-commands.ts` now import from platform dispatcher instead of macOS-specific module.
- `win-server.ts` simplified — removed bun:sqlite error handler (no longer needed with better-sqlite3).

### Fixed
- `cli.ts`: fixed `IS_WINDOWS` used before declaration (ReferenceError on Windows).
- Windows: clear error message when browser DB is exclusively locked ("Close all Chrome windows and try again").

## 0.3.9 — 2026-03-15

### Added
Expand Down
18 changes: 9 additions & 9 deletions TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@
**Priority:** P3
**Depends on:** Sessions

### v20 encryption format support
### v20 App-Bound Encryption support

**What:** AES-256-GCM support for future Chromium cookie DB versions (currently v10).
**What:** Chrome 127+ v20 cookies use App-Bound Encryption (IElevator COM service). Currently blocked — DPAPI key for v20 is inaccessible to third-party tools.

**Why:** Future Chromium versions may change encryption format. Proactive support prevents breakage.
**Why:** Most modern Chrome/Edge cookies now use v20 format. v10 AES-256-GCM decryption works (added v0.3.10).

**Effort:** S
**Priority:** P3
**Effort:** L
**Priority:** P2

### State persistence

Expand Down Expand Up @@ -138,13 +138,13 @@
**Effort:** M
**Priority:** P4

### Linux/Windows cookie decryption
### Linux cookie decryption

**What:** GNOME Keyring / kwallet / DPAPI support for non-macOS cookie import.
**What:** GNOME Keyring / kwallet support for Linux cookie import.

**Why:** Cross-platform cookie import. Currently macOS-only (Keychain).
**Why:** Cross-platform cookie import. Windows done (v0.3.10), Linux remaining.

**Effort:** L
**Effort:** M
**Priority:** P4

## Ship
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.9
0.3.10
126 changes: 106 additions & 20 deletions browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@

import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';

const config = resolveConfig();
const MAX_START_WAIT = 8000; // 8 seconds to start
const IS_WINDOWS = process.platform === 'win32';
const MAX_START_WAIT = IS_WINDOWS ? 20000 : 8000; // Windows needs more time (Node.js + tsx startup)
const TMPDIR = IS_WINDOWS ? (os.tmpdir() || process.env.TEMP || 'C:\\Temp') : '/tmp';

export function resolveServerScript(
env: Record<string, string | undefined> = process.env,
Expand All @@ -26,7 +29,10 @@ export function resolveServerScript(
}

// Dev mode: cli.ts runs directly from browse/src
if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) {
const isRealPath = IS_WINDOWS
? /^[A-Za-z]:[\\/]/.test(metaDir) && !metaDir.includes('$bunfs')
: metaDir.startsWith('/') && !metaDir.includes('$bunfs');
if (isRealPath) {
const direct = path.resolve(metaDir, 'server.ts');
if (fs.existsSync(direct)) {
return direct;
Expand Down Expand Up @@ -90,7 +96,11 @@ async function killServer(pid: number): Promise<void> {

// Force kill if still alive
if (isProcessAlive(pid)) {
try { process.kill(pid, 'SIGKILL'); } catch {}
if (IS_WINDOWS) {
try { Bun.spawnSync(['taskkill', '/F', '/PID', String(pid)], { stdout: 'pipe', stderr: 'pipe' }); } catch {}
} else {
try { process.kill(pid, 'SIGKILL'); } catch {}
}
}
}

Expand All @@ -100,19 +110,30 @@ async function killServer(pid: number): Promise<void> {
*/
function cleanupLegacyState(): void {
try {
const files = fs.readdirSync('/tmp').filter(f => f.startsWith('browse-server') && f.endsWith('.json'));
const files = fs.readdirSync(TMPDIR).filter(f => f.startsWith('browse-server') && f.endsWith('.json'));
for (const file of files) {
const fullPath = `/tmp/${file}`;
const fullPath = path.join(TMPDIR, file);
try {
const data = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
if (data.pid && isProcessAlive(data.pid)) {
// Verify this is actually a browse server before killing
const check = Bun.spawnSync(['ps', '-p', String(data.pid), '-o', 'command='], {
stdout: 'pipe', stderr: 'pipe', timeout: 2000,
});
const cmd = check.stdout.toString().trim();
if (cmd.includes('bun') || cmd.includes('server.ts')) {
try { process.kill(data.pid, 'SIGTERM'); } catch {}
if (IS_WINDOWS) {
// On Windows, use wmic to check process command line
const check = Bun.spawnSync(['wmic', 'process', 'where', `ProcessId=${data.pid}`, 'get', 'CommandLine'], {
stdout: 'pipe', stderr: 'pipe', timeout: 2000,
});
const cmd = check.stdout.toString().trim();
if (cmd.includes('bun') || cmd.includes('server.ts')) {
try { process.kill(data.pid, 'SIGTERM'); } catch {}
}
} else {
// Verify this is actually a browse server before killing
const check = Bun.spawnSync(['ps', '-p', String(data.pid), '-o', 'command='], {
stdout: 'pipe', stderr: 'pipe', timeout: 2000,
});
const cmd = check.stdout.toString().trim();
if (cmd.includes('bun') || cmd.includes('server.ts')) {
try { process.kill(data.pid, 'SIGTERM'); } catch {}
}
}
}
fs.unlinkSync(fullPath);
Expand All @@ -121,29 +142,94 @@ function cleanupLegacyState(): void {
}
}
// Clean up legacy log files too
const logFiles = fs.readdirSync('/tmp').filter(f =>
const logFiles = fs.readdirSync(TMPDIR).filter(f =>
f.startsWith('browse-console') || f.startsWith('browse-network') || f.startsWith('browse-dialog')
);
for (const file of logFiles) {
try { fs.unlinkSync(`/tmp/${file}`); } catch {}
try { fs.unlinkSync(path.join(TMPDIR, file)); } catch {}
}
} catch {
// /tmp read failed — skip legacy cleanup
// tmp read failed — skip legacy cleanup
}
}

// ─── Bun Resolution ────────────────────────────────────────────
/**
* Find the bun executable. On Windows, the browser-manager handles
* Playwright's pipe issue by launching Chromium via Node.js separately.
* The Bun server itself works fine on all platforms.
*/
function findBunExecutable(): string {
const whichCmd = IS_WINDOWS ? 'where' : 'which';
const check = Bun.spawnSync([whichCmd, 'bun'], { stdout: 'pipe', stderr: 'pipe', timeout: 3000 });
if (check.exitCode === 0) {
const found = check.stdout.toString().trim().split(/\r?\n/)[0];
if (found) return found;
}

const homedir = os.homedir();
const candidates = IS_WINDOWS
? [
path.join(homedir, '.bun', 'bin', 'bun.exe'),
path.join(process.env.LOCALAPPDATA || '', 'bun', 'bun.exe'),
path.join(process.env.APPDATA || '', 'bun', 'bun.exe'),
]
: [
path.join(homedir, '.bun', 'bin', 'bun'),
'/usr/local/bin/bun',
'/opt/homebrew/bin/bun',
];

for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) return candidate;
}

throw new Error(
`[browse] Cannot find bun executable. Install bun (https://bun.sh) and ensure it is in PATH.`
);
}

// ─── Server Lifecycle ──────────────────────────────────────────
async function startServer(): Promise<ServerState> {
ensureStateDir(config);

// Clean up stale state file
try { fs.unlinkSync(config.stateFile); } catch {}

// Start server as detached background process
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
});
// On Windows, use Node.js + tsx to run the server (Bun's pipes break Playwright).
// On macOS/Linux, use bun directly.
let proc: any;
if (IS_WINDOWS) {
// Resolve win-server.ts path relative to SERVER_SCRIPT
const winServerScript = path.resolve(path.dirname(SERVER_SCRIPT), 'win-server.ts');
// Find npx or tsx
// tsx binary: bun installs as .exe, npm installs as .cmd
let tsxPath = path.resolve(path.dirname(SERVER_SCRIPT), '..', 'node_modules', '.bin', 'tsx.exe');
let tsxExists = fs.existsSync(tsxPath);
if (!tsxExists) {
tsxPath = path.resolve(path.dirname(SERVER_SCRIPT), '..', 'node_modules', '.bin', 'tsx.cmd');
tsxExists = fs.existsSync(tsxPath);
}

if (tsxExists && fs.existsSync(winServerScript)) {
proc = Bun.spawn([tsxPath, winServerScript], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
});
} else {
// Fallback: try npx tsx
proc = Bun.spawn(['npx', 'tsx', winServerScript], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
});
}
} else {
const bunPath = findBunExecutable();
proc = Bun.spawn([bunPath, 'run', SERVER_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
});
}

// Don't hold the CLI open
proc.unref();
Expand Down
Loading