396 lines
27 KiB
TypeScript
396 lines
27 KiB
TypeScript
// The browser `storebound console fill` works in (the log says which):
|
|
// (default) storebound's own persistent profile, one per store (~/.storebound/console-profile/<store>, or --profile /
|
|
// STOREBOUND_CONSOLE_PROFILE): the owner signs in there once, by hand (storebound never types passwords), and the
|
|
// sign-in is kept between runs. Nothing of the owner's own Chrome is read or copied.
|
|
// (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.
|
|
// The owner's own browser, in place: no cookie leaves it.
|
|
// (b) copy, OPT-IN ONLY (`--use-chrome-session google|apple`, macOS): a temporary profile with ONLY `Local State` and
|
|
// the profile's `Cookies` (+ `Cookies-journal`), filtered down to the cookies of the ONE store the form is for
|
|
// (apple: apple.com hosts; google: play.google.com / accounts.google.com), opened in real Google Chrome. Never
|
|
// copied: passwords (Login Data), history, extensions, anything else. Deleted when the run ends.
|
|
// Why the copy is opt-in (an incident, October 2026): a copied Google session reached Play Console once, then Google
|
|
// answered /v3/signin/rejected and the account chooser. Google binds its session cookies to the device: Chrome's Device
|
|
// Bound Session Credentials keep a non-exportable key in the TPM / Secure Enclave and Google only renews the
|
|
// short-lived cookies for the browser holding it (public on Windows in Chrome 146, rolling out on macOS from Chrome
|
|
// 147), and Google signs out a session whose cookie looks stolen: "the user is logged out of their account for that
|
|
// session and any related suspicious sessions on that device" and must pass an extra verification to sign back in.
|
|
// A copy of the owner's Google cookies used by a second Chrome is exactly what that detection looks for, so it can
|
|
// sign the owner out of their REAL Chrome or flag the account. Apple publishes no device binding or cookie-theft
|
|
// sign-out for Apple ID web sessions (fastlane's spaceauth reuses them on other machines; Apple ties them to a region),
|
|
// but it does not rule one out either, so the Apple copy is opt-in as well.
|
|
// The window: storebound starts Chrome ITSELF (`--user-data-dir=<its profile> --remote-debugging-port=0`; allowed on a
|
|
// non-default user data dir) and attaches over CDP, instead of Playwright's launch: no `--enable-automation` (no
|
|
// "controlled by automated test software" bar), `--disable-blink-features=AutomationControlled` (navigator.webdriver
|
|
// is false), none of Playwright's other switches (mock keychain, disabled sync / component updates ...), the browser's
|
|
// own user agent. Google decides by itself whether it accepts a sign-in there; when it answers /signin/rejected,
|
|
// storebound says so (googleRejected).
|
|
// Keeping the sign-in: 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 (CDP Browser.close, 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).
|
|
// Cookies are never printed, logged or sent anywhere: only file names and counts are reported.
|
|
// The Chrome storebound starts never outlives its run (cli/console/chrome-guard.ts, an October 2026 incident: an
|
|
// orphaned headless Chrome kept the owner's own Chrome from opening from the Dock): exit, Ctrl+C, SIGTERM / SIGHUP and
|
|
// a watchdog for the parent's death end it, and every start first ends the headless ones earlier runs left behind.
|
|
// Sources (checked October 2026): DBSC https://blog.google/security/protecting-cookies-with-device-bound-session-credentials/
|
|
// and https://developer.chrome.com/docs/web-platform/device-bound-session-credentials ; Google's sign-out on suspicious
|
|
// session cookies https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies ;
|
|
// Apple sessions on other machines https://docs.fastlane.tools/getting-started/ios/authentication/ ; 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
|
|
// https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp
|
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder, sandboxArgs } from '../shots/browser';
|
|
import { guardChrome, reapStaleChromes } from './chrome-guard';
|
|
import * as rt from '../lib/rt';
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
/** Whose sign-in an opt-in copy takes: Apple's (App Store Connect) or Google's (Play Console). */
|
|
export type SessionProvider = 'apple' | 'google';
|
|
/** Per provider, the hosts whose cookies an opt-in copy keeps; a cookie is kept when its domain applies to one of them. */
|
|
export const PROVIDER_HOSTS: Record<SessionProvider, string[]> = {
|
|
apple: ['appstoreconnect.apple.com', 'idmsa.apple.com', 'apple.com'],
|
|
google: ['play.google.com', 'accounts.google.com'],
|
|
};
|
|
/** Both consoles' hosts (an opt-in copy always takes ONE provider's: PROVIDER_HOSTS). */
|
|
export const CONSOLE_HOSTS = [...PROVIDER_HOSTS.apple, ...PROVIDER_HOSTS.google];
|
|
/** The provider whose sign-in a store's console uses. */
|
|
export const storeProvider = (store: 'apple' | 'play'): SessionProvider => (store === 'play' ? 'google' : 'apple');
|
|
|
|
/** `--use-chrome-session <google|apple[,..]>`: the providers named; anything else is an error. Absent: none. */
|
|
export function parseUseChromeSession(v: string | boolean | undefined): SessionProvider[] {
|
|
if (v === undefined || v === false) return [];
|
|
const names = typeof v === 'string' ? v.split(',').map(x => x.trim().toLowerCase()).filter(Boolean) : [];
|
|
if (!names.length || names.some(n => n !== 'google' && n !== 'apple')) throw new Error('--use-chrome-session: google | apple (copies that sign-in from your Chrome; read the warning in the console-autofill skill first)');
|
|
return [...new Set(names)] as SessionProvider[];
|
|
}
|
|
|
|
/** Said before an opt-in copy of the owner's Google cookies (DBSC / cookie-theft sign-out, see the header). */
|
|
export const GOOGLE_COPY_WARNING = 'WARNING --use-chrome-session google: Google binds its sign-in cookies to your device (Device Bound Session Credentials) and signs out sessions whose cookies look copied. Using a copy of them in a second Chrome can make Google refuse the copy (/signin/rejected) AND sign you out of your real Chrome, or ask you to verify your account again. Without this flag storebound uses its own profile: sign in there once.';
|
|
/** Said before an opt-in copy of the owner's Apple cookies. */
|
|
export const APPLE_COPY_NOTE = 'note --use-chrome-session apple: Apple publishes no device binding for its web sign-in, but it does not rule out a security check either; if App Store Connect asks you to sign in or verify again, do that, and prefer storebound\'s own profile (run without this flag).';
|
|
|
|
/** Google refused the sign-in in this window (accounts.google.com/v3/signin/rejected and older /signin/.../rejected). */
|
|
export function googleRejected(url: string): boolean {
|
|
try {
|
|
const u = new URL(url);
|
|
return u.hostname === 'accounts.google.com' && /(^|\/)signin\/(v\d+\/)?rejected(\/|$)/i.test(u.pathname);
|
|
} catch { return false; }
|
|
}
|
|
/** What storebound says when Google refused the sign-in (googleRejected). */
|
|
export const GOOGLE_REJECTED = 'Google refused sign-in in this window (/signin/rejected). storebound uses a normal Chrome window; try again, or sign in at accounts.google.com first in this window.';
|
|
|
|
/** What to do the first time in storebound's own profile (the owner signs in by hand; storebound never types passwords). */
|
|
export function firstSignInSteps(store: 'apple' | 'play'): string {
|
|
return store === 'play'
|
|
? 'first run in this profile: in the window, sign in to the Google account that owns your Play developer account yourself (email, password, 2-step verification; storebound never types them), tick "Stay signed in" if asked. If Google answers "Couldn\'t sign you in", open accounts.google.com in that window and sign in there first. The sign-in is kept for the next runs.'
|
|
: 'first run in this profile: in the window, sign in to App Store Connect yourself (Apple ID, password, two-factor code; storebound never types them) and tick "Keep me signed in" / "Trust" this browser. The sign-in is kept for the next runs.';
|
|
}
|
|
|
|
/** 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 }> {
|
|
// bun:sqlite under Bun, node:sqlite on Node 22.5+; without SQLite (Node 20) this throws and the caller says so
|
|
const db = await rt.openSqlite(dbPath);
|
|
try {
|
|
const keys = db.all<{ h: string }>('SELECT DISTINCT host_key AS h FROM cookies').map(r => r.h);
|
|
const drop = keys.filter(k => !cookieApplies(k, hosts));
|
|
const count = Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM cookies')!.n);
|
|
db.exec('BEGIN');
|
|
try { for (const k of drop) db.run('DELETE FROM cookies WHERE host_key = ?', k); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; }
|
|
const kept = Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM cookies')!.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 { rt.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;
|
|
/** Copy / profile: the command line storebound started Chrome with (never automation switches). */
|
|
args?: string[];
|
|
}
|
|
|
|
export interface OpenOpts {
|
|
store: 'apple' | 'play';
|
|
/** --cdp: a local DevTools endpoint to attach to. */
|
|
cdp?: string;
|
|
/** --chrome-profile (only with useChromeSession). */
|
|
chromeProfile?: string;
|
|
/** --use-chrome-session: the providers whose Chrome sign-in the owner opted in to copy. Default: none (own profile). */
|
|
useChromeSession?: SessionProvider[];
|
|
/** --fresh-profile: skip attach and copy. */
|
|
fresh?: boolean;
|
|
/** storebound's own profile dir (the default). */
|
|
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: how long the browser may take to start (default 30 s). */
|
|
launchTimeoutMs?: number;
|
|
/** Tests: Playwright's chromium and the installed browsers the launch resolver sees. */
|
|
launchDeps?: LaunchDeps;
|
|
say: (m: string) => void;
|
|
}
|
|
|
|
/** 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 */ }), rt.sleep(ms)]);
|
|
}
|
|
|
|
/**
|
|
* Launch switch 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'];
|
|
/** No automation marker a page can see: navigator.webdriver stays false (also with --headless, used by tests only). */
|
|
export const NO_AUTOMATION_ARGS = ['--disable-blink-features=AutomationControlled'];
|
|
/** Switches storebound never passes to the console window (Playwright's launch adds the first). */
|
|
export const AUTOMATION_SWITCHES = ['--enable-automation', '--remote-debugging-pipe', '--use-mock-keychain', '--password-store=basic'];
|
|
|
|
/**
|
|
* The command line of the Chrome storebound starts itself for the console window: its own user data dir, a DevTools
|
|
* port the OS picks (written to <dir>/DevToolsActivePort, loopback only), session restore, no automation markers, no
|
|
* first-run dialogs; --no-sandbox only where Chromium cannot start without it (root on Linux, a container:
|
|
* sandboxArgs). Nothing else: Chrome keeps its own user agent, keychain and updates.
|
|
*/
|
|
export function consoleChromeArgs(userDataDir: string, o: { headless?: boolean; sandbox?: string[] } = {}): string[] {
|
|
return [`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', ...KEEP_SESSION_ARGS, ...NO_AUTOMATION_ARGS,
|
|
...(o.sandbox ?? sandboxArgs()), ...(o.headless ? ['--headless=new'] : [])];
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** What the browser decision is, before anything starts (pure; tests). */
|
|
export function browserPlan(o: Pick<OpenOpts, 'store' | 'cdp' | 'fresh' | 'useChromeSession' | 'platform'>, chromeDirExists: boolean): { attach: boolean; copy: SessionProvider | null } {
|
|
const provider = storeProvider(o.store);
|
|
const copy = !o.fresh && (o.useChromeSession ?? []).includes(provider) && (o.platform ?? process.platform) === 'darwin' && chromeDirExists ? provider : null;
|
|
return { attach: !o.fresh, copy };
|
|
}
|
|
|
|
/**
|
|
* Starts `exe` on `userDataDir` (consoleChromeArgs) and attaches over CDP. Chrome is closed with CDP Browser.close (a
|
|
* normal shutdown: cookies flushed), SIGTERM after 10 s; the window counts as closed when Chrome exits or its last tab
|
|
* is closed (macOS keeps Chrome running without windows: then storebound closes it gracefully).
|
|
*/
|
|
async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { label: string; path: string }, userDataDir: string, o: OpenOpts, cleanup: () => void): Promise<ConsoleBrowser> {
|
|
await reapStaleChromes({ say: o.say });
|
|
const holder = profileHolder(userDataDir);
|
|
if (holder) throw new Error(`the browser profile ${userDataDir} is still open in another browser (process ${holder}), probably an earlier storebound run that waits for its window to close. Close that window (or end that run) and start again`);
|
|
const portFile = join(userDataDir, 'DevToolsActivePort');
|
|
rmSync(portFile, { force: true });
|
|
const args = consoleChromeArgs(userDataDir, { headless: o.headless ?? false });
|
|
const proc = rt.spawn([exe.path, ...args], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
|
|
let exited = false;
|
|
// eslint-disable-next-line prefer-const
|
|
let close: () => Promise<void>;
|
|
// until it exits: process exit, a signal (graceful close, then the copy is removed) or this process dying ends it
|
|
const release = proc.pid ? guardChrome(proc.pid, `--user-data-dir=${userDataDir}`, async () => { await close(); cleanup(); }) : () => {};
|
|
const gone = proc.exited.then(() => { exited = true; release(); });
|
|
close = async () => { if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); } };
|
|
const end = Date.now() + (o.launchTimeoutMs ?? 30_000);
|
|
let endpoint: string | null = null;
|
|
while (!endpoint && !exited && Date.now() < end) { endpoint = devToolsEndpoint(userDataDir); if (!endpoint) await rt.sleep(100); }
|
|
if (!endpoint) {
|
|
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); }
|
|
throw new Error(`${exe.label} did not start${exited ? ` (exit code ${proc.exitCode})` : ' in time'}`);
|
|
}
|
|
let browser: any;
|
|
try {
|
|
const pw = o.launchDeps?.pw ?? await chromiumFor(root);
|
|
browser = await pw.connectOverCDP(endpoint, { timeout: Math.max(5000, end - Date.now()) });
|
|
} catch (e) {
|
|
proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]);
|
|
throw new Error(`${exe.label}: could not attach (${(e as Error).message.split('\n')[0]})`, { cause: e });
|
|
}
|
|
const context = browser.contexts()[0] ?? await browser.newContext();
|
|
let closing: Promise<void> | null = null;
|
|
close = () => (closing ??= (async () => {
|
|
if (exited) return;
|
|
await gracefully(async () => { const s = await browser.newBrowserCDPSession(); await s.send('Browser.close'); });
|
|
await Promise.race([gone, rt.sleep(10_000)]);
|
|
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); }
|
|
})());
|
|
let closed = false;
|
|
const done = gone.then(async () => { closed = true; cleanup(); await rt.sleep(300); cleanup(); });
|
|
let page: any;
|
|
try { page = await ownTab(context); } catch (e) { await close(); throw e; } // never a running Chrome without its run
|
|
const watch = (p: any) => p.on('close', () => { if (!exited && context.pages().length === 0) void close(); });
|
|
for (const p of context.pages()) watch(p);
|
|
context.on('page', watch);
|
|
return { mode, context, page, pages: () => context.pages(), closed: done, isClosed: () => closed, close, cleanup, args: [exe.path, ...args], ...(mode === 'copy' ? { tempDir: userDataDir } : {}) };
|
|
}
|
|
|
|
/** The executable for the console window: kind "visible" (own profile) or "chrome" (a copy: only real Chrome decrypts it). */
|
|
function executableFor(root: string, kind: 'visible' | 'chrome', o: OpenOpts): Promise<{ label: string; path: string }> {
|
|
return (async () => {
|
|
const installed = o.launchDeps?.installed ?? installedBrowsers(o.launchDeps?.pw ?? await chromiumFor(root).catch(() => null));
|
|
return persistentExecutable(kind, installed, process.env, { headless: o.headless ?? false, platform: o.platform ?? process.platform });
|
|
})();
|
|
}
|
|
|
|
/** Opens the browser: attach (a running Chrome with remote debugging), an opt-in copy, else storebound's own profile. */
|
|
export async function openConsoleBrowser(root: string, o: OpenOpts): Promise<ConsoleBrowser> {
|
|
const chromeDir = o.chromeDir ?? chromeDataDir(o.platform);
|
|
const plan = browserPlan(o, existsSync(chromeDir));
|
|
if (plan.attach) {
|
|
// (a) attach to a running Chrome with remote debugging: the owner's own browser, nothing copied
|
|
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; using storebound\'s own profile');
|
|
}
|
|
}
|
|
}
|
|
const asked = (o.useChromeSession ?? []).filter(p => p === storeProvider(o.store));
|
|
if (asked.length && !plan.copy) o.say(`browser: --use-chrome-session ${asked[0]} needs macOS and Google Chrome's data folder (${chromeDir}); using storebound's own profile`);
|
|
if (plan.copy) {
|
|
// (b) OPT-IN: a copy of only this store's session cookies, opened in real Google Chrome
|
|
o.say(plan.copy === 'google' ? GOOGLE_COPY_WARNING : APPLE_COPY_NOTE);
|
|
let copy: SessionCopy | null = null;
|
|
try {
|
|
copy = await copySession(chromeDir, { ...(o.chromeProfile ? { profile: o.chromeProfile } : {}), hosts: o.hosts ?? PROVIDER_HOSTS[plan.copy] });
|
|
const exe = await executableFor(root, 'chrome', o);
|
|
const cp = copy;
|
|
const b = await startOwnChrome(root, 'copy', exe, cp.dir, o, () => cp.cleanup());
|
|
o.say(`browser: your Chrome sign-in for ${plan.copy === 'google' ? 'Google' : 'Apple'} (profile "${cp.profile}", opted in). storebound copied only ${cp.files.join(', ')} into a temporary profile`
|
|
+ `${cp.filtered ? `, kept only the ${plan.copy === 'google' ? 'Google' : 'Apple'} sign-in cookies (${cp.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.');
|
|
return b;
|
|
} 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`);
|
|
}
|
|
}
|
|
// (default) storebound's own persistent profile, the owner signs in there once
|
|
const fresh = !existsSync(o.profileDir);
|
|
mkdirSync(o.profileDir, { recursive: true });
|
|
const exe = await executableFor(root, 'visible', o);
|
|
const b = await startOwnChrome(root, 'profile', exe, o.profileDir, o, () => { /* kept on purpose */ });
|
|
o.say(`browser: storebound's own profile (${o.profileDir}) in ${exe.label}, started as a normal Chrome window (no automation flags); nothing is copied from your own Chrome. Sign in there once, it is kept for the next run`);
|
|
if (fresh) o.say(firstSignInSteps(o.store));
|
|
return b;
|
|
}
|