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:
parent
b48407115e
commit
3aedf9e137
10 changed files with 294 additions and 44 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue