storebound/cli/console/session.ts
CRT e21e274c2a browser: one launch resolver; console fill never reports a hidden error
`console fill --fresh-profile` failed with "Executable doesn't exist ...
chromium-1187" when only Playwright's headless shell was downloaded,
although Google Chrome was installed (also with STOREBOUND_CHROME set).
The Chrome launch did run, but failed because an earlier storebound run
still held the profile, and the fallback rethrew the FIRST error.

Every launch (renders, captures, console profile and copy modes, the
desktop sidecar) now goes through one resolver that checks the exact
executable before trying it:
- headless: STOREBOUND_CHROME, headless shell, full Chromium, Chrome
- visible / persistent: STOREBOUND_CHROME, Google Chrome, full Chromium
  (channel "chromium"); never the headless shell
- copy mode: Google Chrome only
A missing browser is skipped; an installed one that fails reports its own
error; nothing usable names what was looked for plus the doctor /
playwright install hint. A profile whose SingletonLock names a live
process is reported before launching.
2026-10-12 06:17:19 +08:00

261 lines
16 KiB
TypeScript

// The browser `storebound console fill` works in, picked so the owner signs in as rarely as possible (the log says which):
// (a) attach: Chrome already runs with remote debugging (a DevToolsActivePort file in its user data dir, or
// --cdp http://127.0.0.1:<port>): connectOverCDP and one new tab in that session; storebound closes only its tab.
// (b) copy (default on macOS when Google Chrome is installed): a temporary profile with ONLY `Local State` and the
// profile's `Cookies` (+ `Cookies-journal`), filtered down to the cookies App Store Connect and Play Console use
// (apple.com, google.com domains: appstoreconnect / idmsa / play / accounts), opened in real Google Chrome
// (Chrome 136+ refuses automation on its default user data dir, a copy is fine). Chrome decrypts the cookies with
// its own Keychain key ("Chrome Safe Storage"); macOS may ask once to allow that. Never copied: passwords
// (Login Data), history, extensions, anything else. The temporary profile is deleted when the run ends (also on
// an error, Ctrl+C or when the desktop app cancels). `--chrome-profile <dir name>` picks a profile ("Profile 1");
// default: the last used one. `--fresh-profile` skips (a) and (b).
// (c) storebound's own persistent profile (~/.storebound/console-profile/<store>): the owner signs in there once.
// Cookies are never printed, logged or sent anywhere: only file names and counts are reported.
// Keeping the sign-in (b, c): App Store Connect's and Google's sign-in cookies are partly SESSION cookies (no expiry).
// Chromium writes them to its Cookies file but deletes them at the next start unless that start restores the last
// session, so storebound launches with --restore-last-session (the tabs it brings back are closed again; storebound
// works in one new tab) and always closes the browser gracefully (window closed, Ctrl+C), never kills it, so Chromium
// flushes its cookie store. Apple keeps a long sign-in only with "Keep me signed in" / "Trust this browser" ticked
// (storebound says so when it sees the sign-in page). Copy mode: the owner's Chrome Cookies file holds the session
// cookies only while that Chrome runs, or after it quit with "On startup: Continue where you left off" on (a normal
// Chrome start without it deletes them); otherwise the owner signs in once in the window storebound opens.
// The session.restore_on_startup preference is not written: it is a tracked (MAC-protected) Chrome preference, and an
// edit from outside can be reset by Chrome; the switch does the same for this launch only.
// Sources (checked October 2026): Chrome user data dir and DevToolsActivePort
// https://chromium.googlesource.com/chromium/src/+/main/docs/user_data_dir.md ; Chrome 136 remote debugging change
// https://developer.chrome.com/blog/remote-debugging-port ; Playwright connectOverCDP / ignoreDefaultArgs
// https://playwright.dev/docs/api/class-browsertype
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { chromiumFor, type LaunchDeps, launchPersistent } from '../shots/browser';
/* eslint-disable @typescript-eslint/no-explicit-any */
/** Hosts whose cookies the consoles need; a cookie is kept when its domain applies to one of them. */
export const CONSOLE_HOSTS = ['appstoreconnect.apple.com', 'idmsa.apple.com', 'apple.com', 'play.google.com', 'accounts.google.com'];
/** Files of a Chrome profile that are never copied (passwords, history, extensions, form data ...). */
export const NEVER_COPY = /(^|\/)(Login Data|History|Extensions|Web Data|Bookmarks|Sessions|Preferences|Secure Preferences|Local Storage|IndexedDB|Account Web Data)/;
/** Google Chrome's user data dir on this OS. */
export function chromeDataDir(platform: string = process.platform, home: string = homedir(), env: Record<string, string | undefined> = process.env): string {
if (platform === 'darwin') return join(home, 'Library', 'Application Support', 'Google', 'Chrome');
if (platform === 'win32') return join(env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), 'Google', 'Chrome', 'User Data');
return join(env.XDG_CONFIG_HOME ?? join(home, '.config'), 'google-chrome');
}
/** The profile dir name: --chrome-profile, else Local State's last used profile, else Default. */
export function pickProfile(localState: string | null, requested?: string): string {
if (requested) {
if (!/^[\w .-]+$/.test(requested) || requested.includes('..')) throw new Error('--chrome-profile: a profile folder name such as "Default" or "Profile 1"');
return requested;
}
try { const last = JSON.parse(localState ?? '{}')?.profile?.last_used; if (typeof last === 'string' && /^[\w .-]+$/.test(last) && !last.includes('..')) return last; } catch { /* default */ }
return 'Default';
}
/**
* The files the copy takes, as [source relative to the user data dir, destination relative to the temp dir]: Local
* State and the profile's cookie store (Cookies, or Network/Cookies on newer layouts) with its journal. The profile
* always lands in `Default` of the temp dir. Nothing else, ever.
*/
export function sessionFiles(profile: string, exists: (rel: string) => boolean): [string, string][] {
const out: [string, string][] = [];
if (exists('Local State')) out.push(['Local State', 'Local State']);
for (const sub of ['', 'Network/']) {
const db = `${profile}/${sub}Cookies`;
if (!exists(db)) continue;
out.push([db, `Default/${sub}Cookies`]);
if (exists(`${db}-journal`)) out.push([`${db}-journal`, `Default/${sub}Cookies-journal`]);
}
for (const [s] of out) if (NEVER_COPY.test(s)) throw new Error(`session copy: refusing to copy ${s}`);
return out;
}
/** A cookie stored for `hostKey` (".google.com" or "play.google.com") is sent to one of `hosts`. */
export function cookieApplies(hostKey: string, hosts: string[] = CONSOLE_HOSTS): boolean {
const d = hostKey.replace(/^\./, '').toLowerCase();
if (!d.includes('.')) return false; // never a whole top-level domain
return hosts.some(h => h === d || h.endsWith(`.${d}`));
}
/** Deletes every cookie of the copied DB that the consoles never get. Returns counts only (never values or names). */
export async function filterCookies(dbPath: string, hosts: string[] = CONSOLE_HOSTS): Promise<{ kept: number; removed: number }> {
const { Database } = await import('bun:sqlite');
const db = new Database(dbPath);
try {
const keys = (db.query('SELECT DISTINCT host_key AS h FROM cookies').all() as { h: string }[]).map(r => r.h);
const drop = keys.filter(k => !cookieApplies(k, hosts));
const count = (db.query('SELECT COUNT(*) AS n FROM cookies').get() as { n: number }).n;
const del = db.prepare('DELETE FROM cookies WHERE host_key = ?');
db.transaction(() => { for (const k of drop) del.run(k); })();
const kept = (db.query('SELECT COUNT(*) AS n FROM cookies').get() as { n: number }).n;
db.exec('VACUUM');
return { kept, removed: count - kept };
} finally { db.close(); }
}
export interface SessionCopy { dir: string; profile: string; files: string[]; filtered: boolean; kept?: number; cleanup: () => void }
/**
* Copies the session files of `src` (a Chrome user data dir) into a new temp dir and filters the cookies to `hosts`.
* On any error the temp dir is removed before the error is thrown.
*/
export async function copySession(src: string, opts: { profile?: string; hosts?: string[]; tmpRoot?: string } = {}): Promise<SessionCopy> {
const ls = existsSync(join(src, 'Local State')) ? readFileSync(join(src, 'Local State'), 'utf8') : null;
const profile = pickProfile(ls, opts.profile);
const files = sessionFiles(profile, rel => existsSync(join(src, rel)));
if (!files.some(([s]) => s.endsWith('Cookies'))) throw new Error(`no Chrome cookie store in profile "${profile}"`);
const dir = mkdtempSync(join(opts.tmpRoot ?? tmpdir(), 'storebound-chrome-'));
const cleanup = () => {
// Chrome may still write a file while it exits: retry, and only stop once the folder is gone
for (let i = 0; i < 5 && existsSync(dir); i++) { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { Bun.sleepSync(100); } }
};
try {
for (const [s, d] of files) { mkdirSync(dirname(join(dir, d)), { recursive: true }); copyFileSync(join(src, s), join(dir, d)); }
let filtered = false, kept: number | undefined;
try {
for (const [, d] of files.filter(([, d]) => d.endsWith('Cookies'))) kept = (kept ?? 0) + (await filterCookies(join(dir, d), opts.hosts)).kept;
filtered = true;
} catch { /* the whole cookie store stays (said in the log) */ }
return { dir, profile, files: files.map(([s]) => s), filtered, ...(kept !== undefined ? { kept } : {}), cleanup };
} catch (e) { cleanup(); throw e; }
}
/** The DevTools endpoint of a Chrome running with remote debugging (its DevToolsActivePort file), or null. */
export function devToolsEndpoint(dataDir: string): string | null {
try {
const [port] = readFileSync(join(dataDir, 'DevToolsActivePort'), 'utf8').split('\n');
return port && /^\d+$/.test(port.trim()) ? `http://127.0.0.1:${port.trim()}` : null;
} catch { return null; }
}
const LOCAL_CDP = /^(https?|wss?):\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/;
export type BrowserMode = 'attach' | 'copy' | 'profile';
export interface ConsoleBrowser {
mode: BrowserMode;
context: any;
/** The tab storebound works in. */
page: any;
/** The tabs storebound may look at (its own tab and the popups it opened; in copy / profile mode every tab). */
pages: () => any[];
/** Resolves when the owner closed the window (copy / profile) or storebound's tab (attach). */
closed: Promise<void>;
isClosed: () => boolean;
/** Closes gracefully (cookies flushed): the whole browser (copy / profile) or storebound's tab (attach). Max 10 s. */
close: () => Promise<void>;
/** Removes the temporary profile (copy mode); safe to call twice. */
cleanup: () => void;
/** Copy mode: the temporary profile (deleted by cleanup and when the window closes). */
tempDir?: string;
}
export interface OpenOpts {
store: 'apple' | 'play';
/** --cdp: a local DevTools endpoint to attach to. */
cdp?: string;
/** --chrome-profile. */
chromeProfile?: string;
/** --fresh-profile: skip attach and copy. */
fresh?: boolean;
/** storebound's own profile dir (mode c). */
profileDir: string;
/** Overrides for tests: Chrome's user data dir, the hosts kept, headless, the platform. */
chromeDir?: string;
hosts?: string[];
headless?: boolean;
platform?: string;
/** Tests: Playwright's launch timeout for modes (b) and (c) (default: Playwright's 30 s). */
launchTimeoutMs?: number;
/** Tests: Playwright's chromium and the installed browsers the launch resolver sees. */
launchDeps?: LaunchDeps;
say: (m: string) => void;
}
function wrapContext(mode: BrowserMode, context: any, page: any, cleanup: () => void): ConsoleBrowser {
let closed = false;
const done = new Promise<void>(r => context.on('close', async () => {
closed = true;
cleanup();
await Bun.sleep(300); // a late write of the exiting browser
cleanup();
r();
}));
return { mode, context, page, pages: () => context.pages(), closed: done, isClosed: () => closed, close: () => gracefully(() => context.close()), cleanup };
}
/** Runs a graceful close, at most `ms`; errors (already closed) are ignored. */
async function gracefully(fn: () => Promise<unknown>, ms = 10_000): Promise<void> {
await Promise.race([Promise.resolve().then(fn).catch(() => { /* already closed */ }), Bun.sleep(ms)]);
}
/**
* Launch switches of the copy and profile modes: bring back the last session's cookies (session cookies included,
* see the header). The restored tabs are closed again by ownTab.
*/
export const KEEP_SESSION_ARGS = ['--restore-last-session'];
/** One new tab for storebound; the tabs the session restore brought back are closed (they would compete for "the active tab"). */
async function ownTab(context: any): Promise<any> {
const restored: any[] = context.pages();
const page = await context.newPage();
for (const p of restored) await p.close().catch(() => { /* gone */ });
return page;
}
/** Opens the browser in the first mode that works: attach, copy, then storebound's own profile. */
export async function openConsoleBrowser(root: string, o: OpenOpts): Promise<ConsoleBrowser> {
const chromeDir = o.chromeDir ?? chromeDataDir(o.platform);
if (!o.fresh) {
// (a) attach to a running Chrome with remote debugging
if (o.cdp && !LOCAL_CDP.test(o.cdp)) throw new Error('--cdp: only a local DevTools endpoint (http://127.0.0.1:<port>)');
const endpoint = o.cdp ?? devToolsEndpoint(chromeDir);
if (endpoint) {
try {
const c = o.launchDeps?.pw ?? await chromiumFor(root);
const browser = await c.connectOverCDP(endpoint, { timeout: 5000 });
const context = browser.contexts()[0] ?? await browser.newContext();
const page = await context.newPage();
const own = new Set<any>([page]);
page.on('popup', (p: any) => own.add(p));
let closed = false;
const done = new Promise<void>(r => page.on('close', () => { closed = true; r(); }));
o.say('browser: attached to your running Chrome (remote debugging); storebound works in one new tab and closes only that tab');
return { mode: 'attach', context, page, pages: () => [...own].filter(p => !p.isClosed()), closed: done, isClosed: () => closed, close: () => gracefully(() => page.close()), cleanup: () => { /* nothing copied */ } };
} catch (e) {
if (o.cdp) throw new Error(`--cdp: could not attach to ${o.cdp} (${(e as Error).message.split('\n')[0]})`);
o.say('browser: Chrome has a DevTools port file but storebound could not attach; trying your Chrome sign-in instead');
}
}
// (b) a copy of only the session files, opened in real Google Chrome
if ((o.platform ?? process.platform) === 'darwin' && existsSync(chromeDir)) {
let copy: SessionCopy | null = null;
try {
copy = await copySession(chromeDir, { ...(o.chromeProfile ? { profile: o.chromeProfile } : {}), ...(o.hosts ? { hosts: o.hosts } : {}) });
const context = await launchPersistent(root, copy.dir, {
headless: o.headless ?? false, viewport: null, args: KEEP_SESSION_ARGS, ...(o.launchTimeoutMs ? { timeout: o.launchTimeoutMs } : {}),
// Playwright's mock keychain would hide Chrome's own key, and the cookies could not be read
ignoreDefaultArgs: ['--use-mock-keychain', '--password-store=basic'],
}, { ...o.launchDeps, kind: 'chrome' }); // only real Google Chrome decrypts the owner's cookies
o.say(`browser: your Chrome sign-in (profile "${copy.profile}"). storebound copied only ${copy.files.join(', ')} into a temporary profile`
+ `${copy.filtered ? `, kept only the cookies for App Store Connect and Play Console (${copy.kept ?? 0})` : ' (the whole cookie file: it could not be filtered)'}`
+ ', and deletes it when this run ends. Passwords, history and extensions are never copied. macOS may ask once to let Google Chrome use "Chrome Safe Storage" in your Keychain: that is Chrome reading its own cookies. Opt out: --fresh-profile');
const page = await ownTab(context);
const cp = copy;
return { ...wrapContext('copy', context, page, () => cp.cleanup()), tempDir: cp.dir };
} catch (e) {
copy?.cleanup();
o.say(`browser: could not use your Chrome sign-in (${(e as Error).message.split('\n')[0]}); using storebound's own profile`);
}
}
}
// (c) storebound's persistent profile, the owner signs in there
mkdirSync(o.profileDir, { recursive: true });
const context = await launchPersistent(root, o.profileDir, { headless: o.headless ?? false, viewport: null, args: KEEP_SESSION_ARGS, ...(o.launchTimeoutMs ? { timeout: o.launchTimeoutMs } : {}) }, { ...o.launchDeps, kind: 'visible' });
o.say(`browser: storebound's own profile (${o.profileDir}); sign in there once, it is kept for the next run`);
const page = await ownTab(context);
return wrapContext('profile', context, page, () => { /* kept on purpose */ });
}