console fill: sign-in only by the top-level URL, the main frame's form or an Apple sign-in frame

Google's account widget and cookie rotation frames (accounts.google.com /
ogs.google.com) on a signed-in Play Console no longer count as a sign-in
page. Each detection logs its rule and URL; heartbeats name the page; a
copied Chrome session that lands on a sign-in URL is said plainly; the
sign-in wait gets a watchdog dump, and --debug-dump up to 3 dumps.
Fixtures and tests for the chooser and home with the widget frames and a
top-level accounts.google.com page; pitfall 16.
This commit is contained in:
CRTkafa 2026-10-12 07:28:42 +08:00
parent b48407115e
commit 3aedf9e137
10 changed files with 294 additions and 44 deletions

View file

@ -11,7 +11,8 @@
// its gate. After sign-in it opens the form itself (navPlan in cli/lib/console-forms.ts: Apps > + > New App, the app's
// App Privacy page, Play app list > the app > App content > Start / Manage); those clicks go through navClick, which
// applies the same gate (only a link named exactly Start / Manage / Başla / Yönet is let through). A sign-in page at
// any point (signInPage: URL, frames, texts) -> storebound waits for the owner, then starts again at step 1. A step not
// any point (signInReason: the top-level URL, the main frame's form, an Apple sign-in frame; never a Google widget
// frame) -> storebound waits for the owner, then starts again at step 1. A step not
// found in time -> the printed steps, and storebound keeps watching (fillSession). With several Play developer
// accounts or App Store Connect teams behind one login, the account that has the app is found and remembered
// (cli/console/accounts.ts). The owner reviews each page, clicks Next, and presses the
@ -29,10 +30,11 @@ import { join } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { type Answer, type ConsoleResult, consoleResult, DISCLAIMER, FORM_IDS, type FormId, fillValue, formMeta, isForm, valueText } from '../lib/console-answers';
import type { ShipConfig } from '../lib/config';
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, forbiddenTick, hasToken, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan } from '../lib/console-forms';
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan } from '../lib/console-forms';
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';
/* eslint-disable @typescript-eslint/no-explicit-any */
@ -240,38 +242,64 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
return { section: null, filled: [], highlighted: [], missing: [], fallback: false };
}
/**
* A sign-in page, read-only: the page or any frame on a sign-in URL (App Store Connect /login, idmsa.apple.com /
* appleid.apple.com, accounts.google.com, Google /signin), a visible password field in any frame, or a visible text,
* label or placeholder only a sign-in form shows (isSignInText: "Sign in with Passkey", "Email or Phone Number" ...).
* Apple's form sits in an idmsa.apple.com frame and starts with no password field, so the URL and texts matter most.
* Only element texts are read (never values, cookies or storage).
*/
export async function signInPage(page: Page): Promise<boolean> {
if (isSignInUrl(page.url())) return true;
const frames: any[] = page.frames();
if (frames.some(f => isSignInUrl(f.url()))) return true;
for (const f of frames) {
try {
const r: { pw: boolean; texts: string[] } = await f.evaluate(() => {
const shown = (el: any) => !!(el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden');
const pw = [...document.querySelectorAll('input[type="password"]')].some(shown);
const texts: string[] = [];
for (const el of document.querySelectorAll('label, button, a, h1, h2, h3, legend, span, p, div, [role="button"], input, [aria-label]')) {
if (texts.length > 3000) break;
const e = el as any;
for (const t of [e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.children.length ? null : e.textContent, e.tagName === 'LABEL' ? e.textContent : null]) {
if (t && t.length <= 80 && shown(e)) texts.push(t);
}
}
return { pw, texts };
});
if (r.pw || r.texts.some(t => isSignInText(t, f !== page.mainFrame()))) return true;
} catch { /* a frame that navigates or went away */ }
}
return false;
/** Why a page counts as a sign-in page (signInReason): the rule that fired and the top-level URL (query dropped). */
export interface SignInWhy { rule: string; url: string }
/** Reads, in one frame, whether a password field and which sign-in texts are visible (element texts only, never values). */
async function signInForm(f: any, inFrame: boolean): Promise<{ pw: boolean; text: string | null }> {
const r: { pw: boolean; texts: string[] } = await f.evaluate(() => {
const shown = (el: any) => !!(el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden');
const pw = [...document.querySelectorAll('input[type="password"]')].some(shown);
const texts: string[] = [];
for (const el of document.querySelectorAll('label, button, a, h1, h2, h3, legend, span, p, div, [role="button"], input, [aria-label]')) {
if (texts.length > 3000) break;
const e = el as any;
for (const t of [e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.children.length ? null : e.textContent, e.tagName === 'LABEL' ? e.textContent : null]) {
if (t && t.length <= 80 && shown(e)) texts.push(t);
}
}
return { pw, texts };
});
return { pw: r.pw, text: r.texts.find(t => isSignInText(t, inFrame))?.trim().replace(/\s+/g, ' ') ?? null };
}
/**
* Why the page is a sign-in page, or null when it is not. Read-only, and only these rules count:
* 1. the TOP-LEVEL URL is a sign-in page (accounts.google.com, Google /signin or ServiceLogin, idmsa.apple.com /
* appleid.apple.com, App Store Connect /login: isSignInUrl);
* 2. the main frame shows a visible password field, or a visible text / label / placeholder only a sign-in form shows
* (isSignInText: "Email or phone", "Sign in with Passkey" ...);
* 3. a visible Apple sign-in frame (idmsa.apple.com / appleid.apple.com: Apple's form lives in one and starts with no
* password field) that shows a password field or a sign-in text.
* Any other frame never counts: Google pages (Play Console's home, its account chooser) embed accounts.google.com /
* ogs.google.com frames for the account widget and cookie rotation while the owner IS signed in. The header's avatar,
* name and email are not sign-in texts either. Only element texts are read (never values, cookies or storage).
*/
export async function signInReason(page: Page): Promise<SignInWhy | null> {
const top: string = page.url();
const url = bareUrl(top) || top;
if (isSignInUrl(top)) return { rule: 'the top-level URL is a sign-in page', url };
try {
const m = await signInForm(page.mainFrame(), false);
if (m.pw) return { rule: 'a visible password field in the page', url };
if (m.text) return { rule: `a sign-in form in the page ("${m.text}")`, url };
} catch { /* the page navigates */ }
for (const f of page.frames() as any[]) {
if (f === page.mainFrame() || !isAppleSignInFrame(f.url())) continue;
try {
const el = await f.frameElement();
if (!await el.isVisible()) continue;
const r = await signInForm(f, true);
if (r.pw || r.text) return { rule: `an Apple sign-in frame (${bareUrl(f.url())})${r.text ? ` with "${r.text}"` : ' with a password field'}`, url };
} catch { /* a frame that navigates or went away */ }
}
return null;
}
/** A sign-in page (signInReason found a rule). */
export async function signInPage(page: Page): Promise<boolean> {
return !!await signInReason(page);
}
// ---- navigation to the form (after sign-in) ----
@ -692,7 +720,6 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
const clicked = (name: string) => { progress(`the page after "${name}"`); if (o.debugEveryStep) { const p = active(); if (p) void Bun.sleep(tick).then(() => dump(p, `after ${name}`)).catch(() => { /* gone */ }); } };
const meta = formMeta(form, config), res = consoleResult(form, config);
const active = (): Page | undefined => b.pages().at(-1);
const isSignIn = (p: Page) => signInPage(p).catch(() => false);
const reached = (p: Page) => consoleReached(meta.store, p.url(), startUrl);
let hinted = false;
const signInHint = () => {
@ -704,16 +731,56 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
: 'leave "Stay signed in" on, so the next run starts signed in');
};
/** Waits until the console is open and no sign-in page shows (two rounds in a row). false: window closed or time up. */
const signInWhy = (p: Page) => signInReason(p).catch(() => null);
let lastWhy = '', firstWait = true, signInDumps = 0;
/** Says which rule made the page a sign-in page (again only when the rule or the URL changes). */
const sayWhy = (w: SignInWhy) => {
const k = `${w.rule}@${w.url}`;
if (k !== lastWhy) { lastWhy = k; say(`sign-in detected because: ${w.rule} at ${w.url}`); }
};
const consoleName = meta.store === 'apple' ? 'App Store Connect' : 'Play Console';
/**
* Waits until the console is open and no sign-in page shows (two rounds in a row). false: window closed or time up.
* Never silent: the rule that made the page a sign-in page is said; every heartbeat says the page and what it is;
* the watchdog dumps the page once when the sign-in has not finished within stepMs; --debug-dump also dumps on
* entering the wait and at each heartbeat (at most 3 sign-in dumps per run). A copied Chrome session that lands on a
* sign-in URL right after the launch is said plainly (it did not carry over).
*/
async function signedIn(): Promise<boolean> {
const end = Date.now() + signInMs;
let told = false, ok = 0, beat = Date.now();
const start = Date.now();
const start = Date.now(), end = start + signInMs, first = firstWait;
firstWait = false;
let told = false, ok = 0, beat = start, watched = false, entered = false;
const signInDump = async (p: Page | undefined, label: string) => {
if (!o.debugEveryStep || !p || signInDumps >= 3) return;
signInDumps++;
await dump(p, label).catch(() => null);
};
while (!b.isClosed() && Date.now() < end) {
if (Date.now() - beat >= heartbeatMs) { beat = Date.now(); say(`still waiting for the sign-in, ${mins(Date.now() - start)}`); }
const p = active();
if (p && await isSignIn(p)) { ok = 0; if (!told) { signInHint(); told = true; } }
else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); return true; } }
const w = p ? await signInWhy(p) : null;
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'); }
if (Date.now() - beat >= heartbeatMs) {
beat = Date.now();
say(`still waiting for the sign-in, ${mins(Date.now() - start)} (${state()})`);
await signInDump(p, 'sign-in wait');
}
if (p && !watched && Date.now() - start >= stepMs) {
watched = true;
const d = await dump(p, 'sign-in not finished').catch(() => null);
say(`the sign-in has not finished after ${mins(Date.now() - start)}: ${state()}${d ? `. Debug saved to ${d.dir}` : ''}`);
}
if (p && w) {
ok = 0;
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`);
signInHint();
}
} else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); lastWhy = ''; return true; } }
else ok = 0;
await Bun.sleep(tick);
}
@ -875,7 +942,9 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
const p = active();
if (!await watch(p)) break;
if (!p) { await Bun.sleep(tick); continue; }
if (await signInPage(p).catch(() => true)) {
const w = await signInReason(p).catch(() => ({ rule: 'the page could not be read', url: bareUrl(p.url()) }));
if (w) {
sayWhy(w);
if (!signedOut && plan.length) signInHint();
signedOut = true;
await Bun.sleep(tick); continue;