console fill apple-app-privacy: set up each data type from the real page ('Set Up <type>' is a plain text, clicked by its exact text in the Data Types section), fill its dialog step by step, dump and pause on an unknown step; '?' and Publish never clicked

This commit is contained in:
CRTkafa 2026-10-12 07:36:06 +08:00
parent 3aedf9e137
commit e0bef82b06
7 changed files with 500 additions and 28 deletions

View file

@ -30,7 +30,8 @@ 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, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan } from '../lib/console-forms';
import { APPLE_PRIVACY_TYPES } from '../lib/app-privacy';
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, setUpAllowed, setUpTexts, typeLabels } from '../lib/console-forms';
import { log, printJson, warn } from '../lib/log';
import { openAcross, withAccountIds } from './accounts';
import { appleAppId, withAppleAppId } from './app-id';
@ -196,7 +197,7 @@ export interface FillStep {
* ambiguous field -> fallback (nothing of that section is touched). Certain answers are set, the rest is highlighted
* and left empty. Never clicks Next / Save. `answers` carry the values the page gets (fillValue).
*/
export async function fillPage(page: Page, def: FormDef, answers: Answer[], done: Set<number> = new Set(), partial = false): Promise<FillStep> {
export async function fillPage(page: Page, def: FormDef, answers: Answer[], done: Set<number> = new Set(), partial = false, current?: string): Promise<FillStep> {
const byId = new Map(answers.map(a => [a.id, a]));
for (const [i, sec] of def.sections.entries()) {
if (done.has(i)) continue;
@ -209,6 +210,11 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
const c = await l.count();
if (c) { d = c === 1 ? { state: 'ok', loc: l } : { state: 'ambiguous' }; break; }
}
// the data type storebound just opened (App Privacy): its dialog may be titled otherwise; the one visible dialog
if (d.state === 'none' && sec.type && sec.type === current) {
const l = vis(page.getByRole('dialog'));
if (await l.count() === 1) d = { state: 'ok', loc: l };
}
if (d.state === 'none') continue;
if (d.state !== 'ok') return { section: i, title: sec.title, filled: [], highlighted: [], missing: [`dialog ${sec.title}`], fallback: true };
root = d.loc;
@ -343,14 +349,25 @@ export async function flowWhere(loc: Locator, dataTypes: string[]): Promise<Flow
const dt = DATA_TYPES_HEADING.filter((n): n is string => typeof n === 'string');
const r: { inDialog: boolean; inDataTypes: boolean } = await loc.evaluate((el: any, names: string[]) => {
const inDialog = !!el.closest('[role="dialog"], dialog');
const norm = (h: any) => (h.textContent ?? '').trim().replace(/\s+/g, ' ');
let inDataTypes = false;
// the nearest container around the control that has a heading decides: its first heading is "Data Types"
for (let a = el.parentElement; a && a !== el.ownerDocument.body; a = a.parentElement) {
const h = a.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
if (!h) continue;
inDataTypes = names.includes((h.textContent ?? '').trim().replace(/\s+/g, ' '));
inDataTypes = names.includes(norm(h));
break;
}
// or the last visible heading before the control (outside dialogs) is "Data Types": the real page puts the heading
// and its Edit in one box and the data types ("Set Up <type>") in the next one
if (!inDataTypes) {
let last: any = null;
for (const h of el.ownerDocument.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
if (h.closest('[role="dialog"], dialog') || !h.getClientRects().length || h.contains(el)) continue;
if (h.compareDocumentPosition(el) & 4) last = h; else break;
}
inDataTypes = !!last && names.includes(norm(last));
}
return { inDialog, inDataTypes };
}, dt);
return { privacyPage, dataTypes, ...r };
@ -371,7 +388,7 @@ export async function flowClick(loc: Locator, form: FormId, dataTypes: string[],
}
/** Visible controls named like `names` (buttons and links) that sit where `ok` says; the one match, or null. */
async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) => boolean, dataTypes: string[]): Promise<Locator | null> {
async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) => boolean, dataTypes: string[], pick: 'one' | 'first' = 'one'): Promise<Locator | null> {
const hits: Locator[] = [];
for (const role of ['button', 'link']) {
for (const n of names) {
@ -380,9 +397,119 @@ async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) =>
for (let i = 0; i < c; i++) { const l = all.nth(i); if (ok(await flowWhere(l, dataTypes))) hits.push(l); }
}
}
if (pick === 'first' && hits.length > 1) {
// the first in document order (Edit next to the Data Types heading comes before the Edit of any set-up type)
return firstInOrder(hits);
}
return hits.length === 1 ? hits[0]! : null;
}
async function firstInOrder(hits: Locator[]): Promise<Locator | null> {
let best: Locator | null = null;
for (const h of hits) {
if (!best) { best = h; continue; }
const earlier = await h.evaluate((a: any, b: any) => !!(a.compareDocumentPosition(b) & 4), await best.elementHandle({ timeout: ACTION_MS })).catch(() => false);
if (earlier) best = h;
}
return best;
}
// ---- App Privacy's Data Types section: "Set Up <type>" is a plain text (a <p> in a div with a click handler) ----
const textOf = async (loc: Locator): Promise<string> => ((await loc.evaluate((el: any) => el.textContent ?? '')) as string).trim().replace(/\s+/g, ' ');
/** "Set Up <type>" (or a console label of it) by exact text in the Data Types section, outside dialogs: the one match or null. */
async function setUpControl(page: Page, t: string, dataTypes: string[]): Promise<Locator | null> {
const hits: Locator[] = [];
for (const text of setUpTexts(t)) {
const all = vis(page.getByText(text, { exact: true }));
const c = await all.count();
for (let i = 0; i < c; i++) {
const l = all.nth(i);
if (setUpAllowed(await textOf(l), await flowWhere(l, dataTypes))) hits.push(l);
}
}
return hits.length === 1 ? hits[0]! : null;
}
/**
* The Set Up click: the element's whole text must be "Set Up <a data type storebound answers>" in the Data Types
* section of the App Privacy page, outside any dialog (setUpAllowed). Logged like every flow click.
*/
export async function setUpClick(loc: Locator, dataTypes: string[], say: (m: string) => void, onClick?: (name: string) => void): Promise<boolean> {
const text = await textOf(loc);
if (!setUpAllowed(text, await flowWhere(loc, dataTypes))) { warn(TAG, `not clicking "${text}": only "Set Up <data type>" in the Data Types section is clicked by its text`); return false; }
await loc.click({ timeout: ACTION_MS });
say(`clicked "${text}" (navigation)`);
onClick?.(text);
return true;
}
/** Every App Privacy type label the console shows (Apple's names and the console's own) -> Apple's name. */
const TYPE_OF_LABEL = new Map<string, string>(Object.values(APPLE_PRIVACY_TYPES).flatMap(([, n]) => typeLabels(n).map(l => [l, n] as [string, string])));
/** The leaf texts of the Data Types section (after its heading, up to the next heading, outside dialogs). Read-only. */
async function dataTypesTexts(page: Page): Promise<string[]> {
const dt = DATA_TYPES_HEADING.filter((n): n is string => typeof n === 'string');
return page.evaluate((names: string[]) => {
const shown = (e: any) => !!e.getClientRects().length;
const norm = (e: any) => (e.textContent ?? '').trim().replace(/\s+/g, ' ');
const hs = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')].filter(h => !h.closest('[role="dialog"], dialog') && shown(h));
const at = hs.findIndex(h => names.includes(norm(h)));
if (at < 0) return [];
const start = hs[at]!, end = hs[at + 1] ?? null;
const out: string[] = [];
for (const e of document.querySelectorAll('body *')) {
if (!(start.compareDocumentPosition(e) & 4) || start.contains(e)) continue;
if (end && !(e.compareDocumentPosition(end) & 4)) break;
if (e.children.length || e.closest('[role="dialog"], dialog') || !shown(e)) continue;
const t = norm(e);
if (t) out.push(t);
}
return out;
}, dt);
}
/** The data types the Data Types section lists (by their label or their "Set Up <type>"), as Apple's names. Read-only. */
export async function listedTypes(page: Page): Promise<string[]> {
const out = new Set<string>();
for (const t of await dataTypesTexts(page)) {
const name = TYPE_OF_LABEL.get(t.replace(/^Set Up /, '').replace(/ ayarla$/, ''));
if (name) out.add(name);
}
return [...out];
}
/**
* A data type set up before (its label in the Data Types section, no "Set Up"): the text of its row and its Edit
* (marked data-storebound-row-edit for the click; only when the row has exactly one Edit). Reads texts only.
*/
async function typeRow(page: Page, t: string): Promise<{ text: string; edit: Locator | null } | null> {
const dt = DATA_TYPES_HEADING.filter((n): n is string => typeof n === 'string');
const r: { text: string; edit: boolean } | null = await page.evaluate(([names, mine, all]: [string[], string[], string[]]) => {
const shown = (e: any) => !!e.getClientRects().length;
const norm = (e: any) => (e.textContent ?? '').trim().replace(/\s+/g, ' ');
document.querySelectorAll('[data-storebound-row-edit]').forEach(e => e.removeAttribute('data-storebound-row-edit'));
const hs = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')].filter(h => !h.closest('[role="dialog"], dialog') && shown(h));
const head = hs.find(h => names.includes(norm(h)));
if (!head) return null;
const leaves = [...document.querySelectorAll('body *')].filter(e => !e.children.length && !!(head.compareDocumentPosition(e) & 4) && !e.closest('[role="dialog"], dialog') && shown(e));
const leaf = mine.map(m => leaves.find(e => norm(e) === m)).find(Boolean);
if (!leaf) return null;
const other = (e: any) => { const x = norm(e).replace(/^Set Up /, ''); return all.includes(x) && !mine.includes(x); };
let row: any = leaf;
for (let a = leaf.parentElement; a && a !== document.body; a = a.parentElement) {
if (a.contains(head) || leaves.some(e => a.contains(e) && other(e))) break;
row = a;
}
const edits = [...row.querySelectorAll('button, a, [role="button"]')].filter((e: any) => /^(Edit|Düzenle)$/.test(norm(e)) && shown(e));
if (edits.length === 1) edits[0]!.setAttribute('data-storebound-row-edit', '1');
return { text: (row.innerText ?? norm(row)).replace(/\s+/g, ' '), edit: edits.length === 1 };
}, [dt, typeLabels(t), [...TYPE_OF_LABEL.keys()]]);
if (!r) return null;
return { text: r.text, edit: r.edit ? vis(page.locator('[data-storebound-row-edit="1"]')) : null };
}
/** Every outlined control of each uncertain answer has been set by the owner (a click, a choice, typing). Read-only. */
async function answered(pending: Locator[][]): Promise<boolean> {
for (const field of pending) {
@ -700,9 +827,11 @@ const mins = (ms: number): string => (ms < 60_000 ? `${Math.round(ms / 1000)} s`
* 3. a step not found -> the printed steps, and storebound keeps watching: a sign-in page that goes away -> from step
* 1; the owner moving on by hand (the + button, the New App menu item, the app in the list appear) -> from that step
* (resumeAt); the owner opening the form -> it is filled.
* 4. fills section by section and moves on by itself: Next (App Privacy: Get Started / Edit next to Data Types, each
* data type, and the draft Save of its dialogs). An uncertain answer stops it on that page until the owner set it.
* The final button (Publish, Submit, Save, Create) and agreements stay the owner's.
* 4. fills section by section and moves on by itself: Next (App Privacy: Get Started / Edit next to Data Types, then
* "Set Up <type>" for each collected type, a plain text clicked by its exact text (setUpClick), and the draft Save
* of its dialog). An uncertain answer stops it on that page until the owner set it; a type dialog step it does not
* know gets a debug dump and a pause naming the type (the watchdog). The final button (Publish, Submit, Save,
* Create), the "?" help buttons and agreements stay the owner's.
*/
export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promise<{ filled: number; highlighted: number; fallback: boolean; timedOut?: boolean; debugDir?: string }> {
const { form, config, say, startUrl } = o;
@ -717,7 +846,15 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
};
/** Something storebound knows happened; `next`: what it waits for now. */
const progress = (next: string) => { progressAt = Date.now(); lastBeat = progressAt; expecting = next; stalled = false; };
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 */ }); } };
/** App Privacy: the data type whose dialog storebound opened last (named in the log, the watchdog and the dumps). */
let currentType: string | undefined;
const clicked = (name: string) => {
const t = currentType;
// the type's last step was saved: what comes next is the App Privacy page again
const closing = !!t && def.sections.every((x, i) => x.type !== t || done.has(i));
progress(t && !closing ? `the "${t}" dialog step after "${name}"` : `the page after "${t ? `${t}: ` : ''}${name}"`);
if (o.debugEveryStep) { const p = active(); if (p) void Bun.sleep(tick).then(() => dump(p, t && !/^Set Up /.test(name) ? `${t} after ${name}` : `after ${name}`)).catch(() => { /* gone */ }); }
};
const meta = formMeta(form, config), res = consoleResult(form, config);
const active = (): Page | undefined => b.pages().at(-1);
const reached = (p: Page) => consoleReached(meta.store, p.url(), startUrl);
@ -841,7 +978,12 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
const last = def.sections.length - 1;
const privacy = form === 'apple-app-privacy';
/** App Privacy: the data types storebound answers (their entries in the Data Types section open their questions). */
const dataTypes = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))];
const types = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))];
/** The same with the console's own labels ("Purchases" for Purchase History): what the click gates accept. */
const dataTypes = [...new Set(types.flatMap(typeLabels))];
/** Rounds a set-up type has been seen without its "Set Up" (a re-render must not count as "set up before"). */
const setUpSeen = new Map<string, number>();
const typeDone = (t: string) => def.sections.forEach((x, i) => { if (x.type === t) done.add(i); });
/** The page was filled: its Next (App Privacy: Next, else the dialog's draft Save) is clicked when nothing waits. */
let pendingAdvance = false;
/** A control of the last filled section (its dialog / page holds the Next or Save to press). */
@ -896,7 +1038,8 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
opened.set(key, n + 1);
await flowClick(loc, form, dataTypes, say, clicked);
};
const editTypes = () => controlWhere(p, FLOW_NAMES.edit, w => w.inDataTypes && !w.inDialog, dataTypes);
// the first Edit of the section (next to its heading); a type set up before has its own Edit further down
const editTypes = () => controlWhere(p, FLOW_NAMES.edit, w => w.inDataTypes && !w.inDialog, dataTypes, 'first');
if (!done.has(0)) {
const start = await controlWhere(p, FLOW_NAMES.getStarted, w => w.privacyPage && !w.inDialog, dataTypes);
if (start) return open('get-started', 'Get Started', start);
@ -908,13 +1051,73 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
return;
}
done.add(0);
const typesAt = def.sections.findIndex(x => x.title === 'Data types');
if (typesAt >= 0 && await typesSaved(p)) {
done.add(typesAt);
progress('the first data type');
say('the Data Types section lists exactly the collected types of the config: storebound sets up each one');
return;
}
say('Data collection was answered before (the page lists data types): storebound opens the data types with Edit');
return open('edit', 'Edit next to Data Types', edit);
}
const typesAt = def.sections.findIndex(x => x.title === 'Data types');
if (typesAt >= 0 && !done.has(typesAt)) return open('edit-types', 'Edit next to Data Types', await editTypes());
const t = def.sections.find((x, i) => x.type && !done.has(i))?.type;
if (t) return open(`type:${t}`, t, await controlWhere(p, [t, `Set Up ${t}`, `${t} ayarla`], w => w.inDataTypes && !w.inDialog, dataTypes));
if (t) return setUpType(p, t);
}
/** The Data Types section lists exactly the collected types of the config (all certain): their list needs no Edit. */
async function typesSaved(p: Page): Promise<boolean> {
const ans = pageAnswers.filter(a => a.id.startsWith('type:'));
if (!ans.length || !ans.every(a => a.certain)) return false;
const want = ans.filter(a => a.value === true).map(a => a.id.slice(5)).sort();
const listed = (await listedTypes(p)).sort();
return want.length > 0 && listed.join('|') === want.join('|');
}
/**
* One collected data type, the App Privacy page with no dialog open: "Set Up <type>" (a plain text: clicked by its
* exact text, setUpClick), else a button / link named like the type, else the type was set up before: its Edit only
* when the purposes its row shows differ from the config; otherwise it is left as it is (logged).
*/
async function setUpType(p: Page, t: string): Promise<void> {
const key = `type:${t}`, n = opened.get(key) ?? 0;
const go = async (loc: Locator, how: (l: Locator) => Promise<boolean>): Promise<void> => {
if (n === 2) { opened.set(key, 3); warn(TAG, `${t}: its questions did not open after two clicks: the console UI may have changed. Open them in the browser; storebound fills them when they appear`); }
if (n >= 2) return;
opened.set(key, n + 1);
currentType = t;
if (!await how(loc)) currentType = undefined;
};
const setUp = await setUpControl(p, t, dataTypes);
if (setUp) return go(setUp, l => setUpClick(l, dataTypes, say, clicked));
const named = await controlWhere(p, [...typeLabels(t), ...setUpTexts(t)], w => w.inDataTypes && !w.inDialog, dataTypes);
if (named) return go(named, l => flowClick(l, form, dataTypes, say, clicked));
const row = await typeRow(p, t);
if (!row) { setUpSeen.delete(t); return; }
// twice in a row: a page that is re-rendering can show the label a moment before its "Set Up"
const seen = (setUpSeen.get(t) ?? 0) + 1;
setUpSeen.set(t, seen);
if (seen < 2) return;
if (n > 0) { // storebound opened it, its dialog closed before every step was filled: the owner finished it
typeDone(t);
progress(`the next data type after "${t}"`);
say(`${t} was finished in the browser: storebound goes on with the next data type`);
return;
}
const purposes = pageAnswers.filter(a => a.id.startsWith(`purpose:${t}:`));
const shows = (a: Answer) => row.text.includes(a.id.slice(`purpose:${t}:`.length));
const readable = purposes.some(shows);
const same = readable && purposes.every(a => a.certain && (a.value === true) === shows(a));
if (readable && !same && purposes.every(a => a.certain) && row.edit) {
say(`${t} was set up before with other purposes than the config: storebound opens it with its Edit`);
return go(row.edit, l => flowClick(l, form, dataTypes, say, clicked));
}
typeDone(t);
progress(`the next data type after "${t}"`);
say(same ? `${t} was set up before with the purposes of the config: left as it is`
: `${t} was set up before; storebound cannot compare its answers on this page and leaves it as it is: check it in the summary before Publish`);
}
/** A page storebound does not know: what is there (top buttons), the dump, and the manual steps. Once per wait. */
@ -922,8 +1125,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
stalled = true;
const dbg = await import('./debug');
const d = await dump(p, `unknown ${expecting}`).catch(() => null);
const top = dbg.topButtons(d?.summary ?? await dbg.summarize(p).catch(() => null));
say(`${expecting} is not a page storebound knows (nothing it can fill or click after ${mins(Date.now() - progressAt)}). Buttons there: ${top.length ? top.map(t => `"${t}"`).join(', ') : '(none)'}${d ? `. Debug saved to ${d.dir}` : ''}`);
// an open dialog: its own buttons (the page behind it has many more)
const dl = vis(p.getByRole('dialog'));
const inDialog = await dl.count().catch(() => 0) === 1;
const own: string[] = inDialog ? await dl.getByRole('button').filter({ visible: true }).evaluateAll((els: any[]) => els.map(e => (e.getAttribute('aria-label') || e.textContent || '').trim().replace(/\s+/g, ' ')).filter(Boolean).slice(0, 10)).catch(() => []) : [];
const top = own.length ? own : dbg.topButtons(d?.summary ?? await dbg.summarize(p).catch(() => null));
say(`${expecting} is not a page storebound knows (nothing it can fill or click after ${mins(Date.now() - progressAt)}). Buttons ${own.length ? 'in the dialog' : 'there'}: ${top.length ? top.map(t => `"${t}"`).join(', ') : '(none)'}${d ? `. Debug saved to ${d.dir}` : ''}`);
if (currentType && inDialog) say(`paused on "${currentType}": this step of its dialog is new to storebound. Answer it in the browser and go on in the dialog; storebound fills the next step it knows (it stops after ${mins(idleMs)} without progress)`);
say(`do this part yourself; storebound keeps watching and goes on when it sees a page it knows (it stops after ${mins(idleMs)} without progress):`);
for (const g of res.guidance) say(` ${g}`);
};
@ -972,13 +1180,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
}
}
let s: FillStep;
try { s = await fillPage(p, def, pageAnswers, done); } catch { await Bun.sleep(tick); continue; } // navigating
try { s = await fillPage(p, def, pageAnswers, done, false, currentType); } catch { await Bun.sleep(tick); continue; } // navigating
if (s.section !== null) {
// a page still loading can miss a field for a moment: only a miss that lasts (3 rounds, at least 1.5 s) is a UI change
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < 1500)) { if (misses === 1) firstMiss = Date.now(); await Bun.sleep(tick); continue; }
misses = 0;
// the miss lasts: fill every field that was found, leave the missing ones to the owner (only all-missing is a UI change)
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true); } catch { await Bun.sleep(tick); continue; } }
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await Bun.sleep(tick); continue; } }
if (!s.fallback && s.missing.length) warn(TAG, `${s.title}: could not find ${s.missing.map(id => labels.get(id) ?? id).join(', ')} on the page (the console UI may have changed): fill ${s.missing.length === 1 ? 'it' : 'them'} yourself`);
if (s.fallback) {
fallback = true;

View file

@ -63,6 +63,8 @@ const IARC_NAMES: Record<string, Name[]> = {
* read from a real console in October 2026): "Purchases" for Purchase History, "Other Data" for Other Data Types.
*/
export const TYPE_ALIASES: Record<string, string[]> = { 'Purchase History': ['Purchases'], 'Other Data Types': ['Other Data'] };
/** A data type's name and the console's other labels for it ("Purchase History", "Purchases"). */
export const typeLabels = (t: string): string[] => [t, ...(TYPE_ALIASES[t] ?? [])];
/**
* Candidate names of a checkbox labelled `label`: the exact name, then the name that STARTS with the label and goes on
@ -103,12 +105,13 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
] }];
const types = answers.filter(a => a.id.startsWith('type:'));
if (types.length) sections.push({ title: 'Data types', fields: types.map(a => ({ id: a.id, kind: 'checkbox', names: labelNames(a.id.slice(5), TYPE_ALIASES[a.id.slice(5)]) })) });
// one data type's dialog: purposes, linked to the user, tracking; one page each (Next between, Save at the end)
// or all on one page: each is its own section, the later two are `page`s
// one data type's dialog (opened by "Set Up <type>" in the Data Types section): purposes, linked to the user,
// tracking; one step each (Next between, Save at the end) or all on one page. Every part is a `page`: a dialog step
// with none of its fields is an unknown step (the watchdog dumps it and pauses), never a reason to stop the run
for (const a of types.filter(t => t.value === true && has(`linked:${t.id.slice(5)}`))) {
const n = a.id.slice(5);
const dialog = [n, new RegExp(`^${esc(n)}\\b`, 'i')];
sections.push({ title: `${n}: purposes`, dialog, type: n, fields: APPLE_PURPOSES.map(p => ({ id: `purpose:${n}:${p}`, kind: 'checkbox' as const, names: labelNames(p) })) });
const dialog = typeLabels(n).flatMap(l => [l, new RegExp(`^${esc(l)}\\b`, 'i')]);
sections.push({ title: `${n}: purposes`, dialog, type: n, page: true, fields: APPLE_PURPOSES.map(p => ({ id: `purpose:${n}:${p}`, kind: 'checkbox' as const, names: labelNames(p) })) });
sections.push({ title: `${n}: linked to the user`, dialog, type: n, page: true, fields: [{ id: `linked:${n}`, kind: 'radio', options: yesNo([/^Yes,.*\blinked to the user/i], [/^No,.*\blinked to the user/i]) }] });
sections.push({ title: `${n}: tracking`, dialog, type: n, page: true, fields: [{ id: `tracking:${n}`, kind: 'radio', options: yesNo([/^Yes,.*\btracking/i], [/^No,.*\btracking/i]) }] });
}
@ -364,7 +367,9 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
'apple-app-privacy': [
{ button: 'Get Started', class: 'navigation', where: 'App Privacy page, first time: opens the questionnaire', auto: true },
{ button: 'Edit (next to Data Types)', class: 'navigation', where: 'Data Types section: opens the data type list', auto: true },
{ button: '<data type> / Set Up <data type>', class: 'navigation', where: 'Data Types section: opens that type\'s questions', auto: true },
{ button: 'Set Up <data type>', class: 'navigation', where: 'Data Types section, one per collected type (a plain text with a click handler, not a button): opens that type\'s questions', auto: true },
{ button: 'Edit (next to one data type)', class: 'navigation', where: 'Data Types section, a type set up before: reopens its questions, only when its purposes differ from the config', auto: true },
{ button: '? / More information', class: 'navigation', where: 'next to each heading and data type: help only (never needed)', auto: false },
{ button: 'Next / Continue', class: 'navigation', where: 'questionnaire dialogs', auto: true },
{ button: 'Back', class: 'navigation', where: 'questionnaire dialogs (never needed)', auto: false },
{ button: 'Save', class: 'draft-save', where: 'questionnaire dialogs: stores the answers, not public until Publish', auto: true },
@ -434,6 +439,21 @@ export function flowClass(form: FormId, name: string, role: string, where: FlowW
return null;
}
/** The texts that open one data type's questions ("Set Up Coarse Location"; Turkish "<type> ayarla"), for its labels. */
export const setUpTexts = (t: string): string[] => typeLabels(t).flatMap(l => [`Set Up ${l}`, `${l} ayarla`]);
/**
* The Set Up click gate: App Store Connect shows "Set Up <type>" as a plain text (a <p> in a div with a click handler,
* no button or link role), so the role-based gate cannot see it. It is clicked only when its whole text is exactly
* "Set Up " + one of the data types storebound answers (or their console labels), on the App Privacy page, in the Data
* Types section and outside any dialog. Nothing else is clicked by text (the "?" help buttons, Publish never).
*/
export function setUpAllowed(text: string, where: FlowWhere): boolean {
const n = norm(text).toLocaleLowerCase('tr');
if (!where.privacyPage || !where.inDataTypes || where.inDialog || forbiddenControl(n)) return false;
return where.dataTypes.some(t => setUpTexts(t).some(x => x.toLocaleLowerCase('tr') === n));
}
/** The forms whose draft save storebound clicks (the owner agreed): nothing there is public or sent before the final button. */
export const AUTO_DRAFT_SAVE: FormId[] = ['apple-app-privacy', 'play-content-rating'];