console fill: never copy Google session cookies by default (device-bound); own profile in a Chrome started without automation switches
Google binds its session cookies to the device (Device Bound Session Credentials) and signs out sessions whose cookies look stolen, which can sign the owner out of their real Chrome. The default is now storebound's own profile per store; storebound starts Chrome itself (--user-data-dir, --remote-debugging-port=0, no --enable-automation, navigator.webdriver false) and attaches over CDP. --use-chrome-session google|apple is an explicit opt-in that warns. /signin/rejected is said plainly.
This commit is contained in:
parent
e0bef82b06
commit
0035931734
22 changed files with 456 additions and 156 deletions
|
|
@ -1,7 +1,7 @@
|
|||
// AI-assisted autofill of console forms that have no API, in a visible Chromium the owner signs in to:
|
||||
// storebound console fill lists the forms
|
||||
// storebound console fill <form> --dry [--json] the answers and the step-by-step guidance; opens no browser
|
||||
// storebound console fill <form> [--store apple|play] [--json] [--profile <dir>]
|
||||
// storebound console fill <form> [--store apple|play] [--json] [--profile <dir>] [--use-chrome-session google|apple]
|
||||
// Forms: apple-app-record, apple-age-rating (guide only: `storebound apple appinfo` sets it), apple-app-privacy,
|
||||
// play-app-record (Play Console's Create app form: the Play Developer API cannot create apps), play-content-rating,
|
||||
// play-target-audience. Answers come from the config only (cli/lib/console-answers.ts); anything
|
||||
|
|
@ -19,9 +19,11 @@
|
|||
// final button; the command ends when the window is closed. Locators are accessible names only
|
||||
// (cli/lib/console-forms.ts); a missing or ambiguous field means the UI changed: filling stops and the guidance is
|
||||
// printed instead. Not exposed over MCP.
|
||||
// Browser (cli/console/session.ts): the owner's running Chrome (--cdp / remote debugging), else a temporary copy of only
|
||||
// their Chrome sign-in cookies (macOS; deleted at the end), else storebound's own profile
|
||||
// (~/.storebound/console-profile/<store>, or --profile / STOREBOUND_CONSOLE_PROFILE / --fresh-profile).
|
||||
// Browser (cli/console/session.ts): storebound's own profile (~/.storebound/console-profile/<store>, or --profile /
|
||||
// STOREBOUND_CONSOLE_PROFILE) in a Chrome storebound starts without automation switches; the owner's running Chrome
|
||||
// when it has remote debugging (--cdp); a copy of the owner's Chrome sign-in only with --use-chrome-session google|apple
|
||||
// (opt-in: Google binds its cookies to the device and can sign the owner out of the real Chrome).
|
||||
// Google's /signin/rejected page is said plainly (GOOGLE_REJECTED).
|
||||
// Sources (checked October 2026): see cli/lib/console-answers.ts; Playwright launchPersistentContext, getByRole,
|
||||
// getByLabel: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context ,
|
||||
// https://playwright.dev/docs/locators
|
||||
|
|
@ -36,7 +38,7 @@ import { log, printJson, warn } from '../lib/log';
|
|||
import { openAcross, withAccountIds } from './accounts';
|
||||
import { appleAppId, withAppleAppId } from './app-id';
|
||||
import { bareUrl } from './debug';
|
||||
import { type ConsoleBrowser, openConsoleBrowser } from './session';
|
||||
import { type ConsoleBrowser, GOOGLE_REJECTED, googleRejected, openConsoleBrowser, parseUseChromeSession } from './session';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type Page = any;
|
||||
|
|
@ -741,7 +743,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (!form) {
|
||||
const forms = FORM_IDS.map(id => { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable }; });
|
||||
if (json) return printJson({ forms });
|
||||
console.log('storebound console fill <form> [--store apple|play] [--dry] [--json] [--profile <dir>] [--step-timeout <seconds>] [--debug-dump]\n');
|
||||
console.log('storebound console fill <form> [--store apple|play] [--dry] [--json] [--profile <dir>] [--use-chrome-session google|apple] [--step-timeout <seconds>] [--debug-dump]\n');
|
||||
for (const f of forms) console.log(` ${f.id.padEnd(22)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? '' : ' (guide only)'}`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -765,6 +767,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const b = await openConsoleBrowser(root, {
|
||||
store: meta.store, profileDir: dir, say, fresh: args.has('fresh-profile') || ownProfile,
|
||||
...(args.get('cdp') ? { cdp: args.get('cdp') } : {}), ...(args.get('chrome-profile') ? { chromeProfile: args.get('chrome-profile') } : {}),
|
||||
useChromeSession: parseUseChromeSession(args.get('use-chrome-session') ?? (args.has('use-chrome-session') ? true : undefined)),
|
||||
});
|
||||
// Ctrl+C: close the browser gracefully (Chromium writes its cookies on a clean exit, so the sign-in is kept for the
|
||||
// next run), then remove the temporary profile of copy mode; that also happens on every other exit path
|
||||
|
|
@ -887,7 +890,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
async function signedIn(): Promise<boolean> {
|
||||
const start = Date.now(), end = start + signInMs, first = firstWait;
|
||||
firstWait = false;
|
||||
let told = false, ok = 0, beat = start, watched = false, entered = false;
|
||||
let told = false, ok = 0, beat = start, watched = false, entered = false, rejected = false;
|
||||
const signInDump = async (p: Page | undefined, label: string) => {
|
||||
if (!o.debugEveryStep || !p || signInDumps >= 3) return;
|
||||
signInDumps++;
|
||||
|
|
@ -896,6 +899,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
while (!b.isClosed() && Date.now() < end) {
|
||||
const p = active();
|
||||
const w = p ? await signInWhy(p) : null;
|
||||
if (p && !rejected && googleRejected(p.url())) { rejected = true; say(GOOGLE_REJECTED); }
|
||||
const state = (): string => !p ? 'no tab open' : `${bareUrl(p.url()) || p.url()}: ${w ? `a sign-in page (${w.rule})` : reached(p) ? 'the console' : 'neither a sign-in page nor the console yet'}`;
|
||||
// the wait really starts: a sign-in page, or a page that is still not the console after a few rounds
|
||||
if (p && !entered && (w || (!reached(p) && Date.now() - start >= 3 * tick))) { entered = true; await signInDump(p, 'sign-in wait'); }
|
||||
|
|
@ -914,7 +918,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
sayWhy(w);
|
||||
if (!told) {
|
||||
told = true;
|
||||
if (first && b.mode === 'copy' && isSignInUrl(p.url())) say(`your Chrome session for ${consoleName} did not carry over (expired or tied to this device); sign in once in this window, or run with --fresh-profile`);
|
||||
if (first && b.mode === 'copy' && isSignInUrl(p.url())) say(`your copied Chrome session for ${consoleName} did not carry over (expired or tied to your own Chrome); sign in once in this window, or run without --use-chrome-session`);
|
||||
signInHint();
|
||||
}
|
||||
} else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); lastWhy = ''; return true; } }
|
||||
|
|
|
|||
|
|
@ -1,39 +1,92 @@
|
|||
// The browser `storebound console fill` works in, picked so the owner signs in as rarely as possible (the log says which):
|
||||
// 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.
|
||||
// (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).
|
||||
// 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 (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
|
||||
// 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.
|
||||
// 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, type LaunchDeps, launchPersistent } from '../shots/browser';
|
||||
import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder } 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'];
|
||||
/** 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)/;
|
||||
|
|
@ -151,52 +204,57 @@ export interface ConsoleBrowser {
|
|||
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. */
|
||||
/** --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 (mode c). */
|
||||
/** 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: Playwright's launch timeout for modes (b) and (c) (default: Playwright's 30 s). */
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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,
|
||||
* 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. Nothing else: Chrome keeps its own user agent, keychain and updates.
|
||||
*/
|
||||
export function consoleChromeArgs(userDataDir: string, o: { headless?: boolean } = {}): string[] {
|
||||
return [`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', ...KEEP_SESSION_ARGS, ...NO_AUTOMATION_ARGS, ...(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> {
|
||||
|
|
@ -206,11 +264,72 @@ async function ownTab(context: any): Promise<any> {
|
|||
return page;
|
||||
}
|
||||
|
||||
/** Opens the browser in the first mode that works: attach, copy, then storebound's own profile. */
|
||||
/** 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> {
|
||||
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 = Bun.spawn([exe.path, ...args], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
|
||||
let exited = false;
|
||||
const gone = proc.exited.then(() => { exited = true; });
|
||||
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 Bun.sleep(100); }
|
||||
if (!endpoint) {
|
||||
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, Bun.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, Bun.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();
|
||||
const close = async () => {
|
||||
if (exited) return;
|
||||
await gracefully(async () => { const s = await browser.newBrowserCDPSession(); await s.send('Browser.close'); });
|
||||
await Promise.race([gone, Bun.sleep(10_000)]);
|
||||
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]); }
|
||||
};
|
||||
let closed = false;
|
||||
const done = gone.then(async () => { closed = true; cleanup(); await Bun.sleep(300); cleanup(); });
|
||||
const page = await ownTab(context);
|
||||
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);
|
||||
})();
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!o.fresh) {
|
||||
// (a) attach to a running Chrome with remote debugging
|
||||
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) {
|
||||
|
|
@ -227,35 +346,36 @@ export async function openConsoleBrowser(root: string, o: OpenOpts): Promise<Con
|
|||
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`);
|
||||
o.say('browser: Chrome has a DevTools port file but storebound could not attach; using storebound\'s own profile');
|
||||
}
|
||||
}
|
||||
}
|
||||
// (c) storebound's persistent profile, the owner signs in there
|
||||
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 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 */ });
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,7 +291,8 @@
|
|||
"profile",
|
||||
"start-url",
|
||||
"step-timeout",
|
||||
"store"
|
||||
"store",
|
||||
"use-chrome-session"
|
||||
],
|
||||
"desktop": [
|
||||
"channel",
|
||||
|
|
@ -805,6 +806,7 @@
|
|||
"unity",
|
||||
"update-bigmac",
|
||||
"upload",
|
||||
"use-chrome-session",
|
||||
"variant",
|
||||
"variants",
|
||||
"vendor",
|
||||
|
|
@ -971,7 +973,7 @@
|
|||
"console fill": [
|
||||
"storebound console fill lists the forms",
|
||||
"storebound console fill <form> --dry [--json] the answers and the step-by-step guidance; opens no browser",
|
||||
"storebound console fill <form> [--store apple|play] [--json] [--profile <dir>]"
|
||||
"storebound console fill <form> [--store apple|play] [--json] [--profile <dir>] [--use-chrome-session google|apple]"
|
||||
],
|
||||
"desktop": [
|
||||
"storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that",
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export const DESKTOP_ONLY: ToolDef[] = [
|
|||
{ name: 'console_fill', title: 'Fill in a console form', command: ['console', 'fill'], write: true, network: true,
|
||||
description: 'Fills one store console form that has no API (App Store app record, age rating, App Privacy; Play content rating, target audience) from the config in a visible Chromium the owner signs in to; uncertain fields are highlighted. It moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save in its dialogs; content rating: Save draft) and never submits or publishes: the owner reviews every field and presses the final button (Publish, Submit, Save or Create). The dry run lists the answers; the real run lasts until the owner closes the browser.',
|
||||
props: { form: { type: 'string', enum: CONSOLE_FORMS, description: 'The console form' }, store: { type: 'string', enum: ['apple', 'play'], description: 'Default: from the form' },
|
||||
fresh_profile: { type: 'boolean', default: false, description: "Use storebound's own browser profile (sign in there) instead of the owner's Chrome sign-in (a temporary copy of only the console cookies, deleted at the end)" } },
|
||||
fresh_profile: { type: 'boolean', default: false, description: "Skip attaching to a Chrome that runs with remote debugging. storebound's own browser profile (the owner signs in there once) is always used otherwise; the owner's Chrome cookies are never copied here" } },
|
||||
required: ['form'], args: i => [String(i.form), ...opt('store', i.store), ...(i.fresh_profile === true ? ['--fresh-profile'] : [])] },
|
||||
{ name: 'prices_current', title: 'Current prices', command: ['prices', 'export'], write: false, network: true,
|
||||
description: 'Read-only: the current price of every product on both stores per region (Apple manual prices, with with_automatic also the equalized ones). Writes nothing.',
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@
|
|||
// - visible / persistent windows (`console fill`): STOREBOUND_CHROME, Google Chrome (channel "chrome"), then
|
||||
// Playwright's full Chromium (channel "chromium"). Never the headless shell: it has no window, and it drops session
|
||||
// cookies (no --restore-last-session), so a sign-in would not survive.
|
||||
// - "chrome" (console fill's copy of the owner's Chrome sign-in: only real Google Chrome can decrypt those cookies
|
||||
// with its own Keychain key): Google Chrome only.
|
||||
// - "chrome" (console fill's opt-in copy of the owner's Chrome sign-in: only real Google Chrome can decrypt those
|
||||
// cookies with its own Keychain key): Google Chrome only.
|
||||
// `console fill` itself does not launch through Playwright: it takes the executable from `persistentExecutable` (same
|
||||
// order) and starts Chrome on its own (cli/console/session.ts), so the window carries no automation switches.
|
||||
// A candidate that is not installed is skipped; one that IS installed but fails to start stops the search with its own
|
||||
// error (an earlier "Executable doesn't exist" never hides it). A persistent profile another browser still holds (its
|
||||
// SingletonLock names a live process) is reported as such before anything is launched.
|
||||
|
|
@ -149,6 +151,22 @@ export interface LaunchDeps {
|
|||
installed?: Installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The executable `console fill` starts itself (no Playwright launch, so no automation switches): kind "visible" =
|
||||
* STOREBOUND_CHROME, Google Chrome, then Playwright's full Chromium; kind "chrome" = Google Chrome only. Never the
|
||||
* headless shell. Nothing installed: the same error as withCandidates.
|
||||
*/
|
||||
export function persistentExecutable(kind: 'visible' | 'chrome', i: Installed, env: Record<string, string | undefined> = process.env): { label: string; path: string } {
|
||||
const list: { label: string; path: string }[] = [];
|
||||
if (kind === 'visible' && i.storeboundChrome) list.push({ label: `STOREBOUND_CHROME (${i.storeboundChrome})`, path: i.storeboundChrome });
|
||||
if (i.chrome) list.push({ label: `Google Chrome (${i.chrome})`, path: i.chrome });
|
||||
if (kind === 'visible' && i.chromium) list.push({ label: `Playwright Chromium (${i.chromium})`, path: i.chromium });
|
||||
if (list[0]) return list[0];
|
||||
const skipped = env.STOREBOUND_CHROME && !i.storeboundChrome && kind !== 'chrome' ? `; STOREBOUND_CHROME ${env.STOREBOUND_CHROME}: no such file` : '';
|
||||
throw new Error(`no usable browser (looked for ${LOOKED_FOR[kind]})${skipped}. `
|
||||
+ (kind === 'chrome' ? 'Install Google Chrome.' : 'Install Google Chrome, or run `bunx playwright install chromium` in your project; `storebound doctor` lists what is missing.'));
|
||||
}
|
||||
|
||||
/** A browser for renders and captures (headless unless `headless: false`). */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function launch(root: string, opts: Record<string, unknown>, deps: LaunchDeps = {}): Promise<any> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue