- `storebound console plan <form> [--json] [--lookup]`: read-only plan for an AI agent that drives the browser itself: start URL (deep links), ordered steps, answer sheet with the console's exact labels (certain / ask owner, curly quotes noted), never-press list, verified UI facts, driving guidance (in-page async loop + window.__log), verify instructions. All six forms. - `storebound console verify <form> [--from-text <file|->]`: App Privacy per-type summaries from page text, Play Create app from input states, content rating / target audience summaries, App Store record and age rating from the API; exit 1 on a mismatch. - MCP: console_plan, console_verify (read-only); console fill stays off MCP. - App Privacy (verified 2026-10-12): two information steps, unlabeled purpose checkboxes (nearest parent text), "Definitions and Examples" never pressed; fixture and engine follow. - Play Create app (verified 2026-10-12): /developers/<id>/create-new-app, App package name + read-only Check availability, material-radio inner [role=radio] click, app dashboard URL and play.appId, service account "Add app" as an owner step. - console-autofill skill rewritten agent-first; agents-core rule 10; README; pitfalls 12, 18 (confirmed sign-out), 19; CHANGELOG; play-first-release.
526 lines
38 KiB
TypeScript
526 lines
38 KiB
TypeScript
// Where each answer of a console form lives on the page, as accessible names only (role + name / label), never CSS
|
|
// paths: `storebound console fill` locates every field of a section first and falls back to printed guidance when one
|
|
// is missing or ambiguous (the console UI changed). Several candidate names per field absorb small wording drift.
|
|
// Also the click gate (no Save / Submit / Publish / Next ... for field clicks), its scoped exceptions for moving
|
|
// through a form (navigation, App Privacy's draft Save: FORM_BUTTONS) and the sign-in page detector. Pure.
|
|
// The names are modeled on Apple's and Google's public help pages (sources in console-answers.ts), not recorded from a
|
|
// signed-in console: the exact App Privacy dialog titles, the IARC question wording and the Play category names could
|
|
// not be verified without an account, which is why every field has candidates and the engine falls back on a miss.
|
|
import { AGE_GROUPS, appleName, type ShipConfig } from './config';
|
|
import { AGE_LABELS, type Answer, APPLE_PURPOSES, type FormId } from './console-answers';
|
|
|
|
/** An accessible name: a string matches exactly, a RegExp matches the name. */
|
|
export type Name = string | RegExp;
|
|
|
|
export interface FieldDef {
|
|
/** Answer id. */
|
|
id: string;
|
|
kind: 'text' | 'select' | 'checkbox' | 'radio';
|
|
/** text / select / checkbox: candidate names of the control. radio: candidate names of the radiogroup (optional). */
|
|
names?: Name[];
|
|
/** radio: answer value (String(value)) -> candidate names of that radio button. */
|
|
options?: Record<string, Name[]>;
|
|
/** An agreement checkbox: located and highlighted, NEVER ticked (whatever the answer says). */
|
|
agreement?: boolean;
|
|
}
|
|
|
|
export interface SectionDef {
|
|
/** Shown to the owner ("page 2/3: Questionnaire"). */
|
|
title: string;
|
|
/** Candidate names of the dialog the section lives in; it is present when that dialog is visible. */
|
|
dialog?: Name[];
|
|
/** A later page of a multi-page dialog: not on the page yet (none of its fields seen) is not a UI change. */
|
|
page?: boolean;
|
|
/** App Privacy: the data type this section belongs to (its dialog opens from the Data Types section). */
|
|
type?: string;
|
|
fields: FieldDef[];
|
|
}
|
|
|
|
export interface FormDef { id: FormId; sections: SectionDef[] }
|
|
|
|
// Console labels use typographic quotes ("Developer’s Advertising", "the user’s identity"): every pattern built from a
|
|
// label accepts straight and curly quotes alike.
|
|
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/['\u2018\u2019]/g, "['\u2018\u2019]").replace(/["\u201c\u201d]/g, '["\u201c\u201d]');
|
|
/** A label as an exact-match pattern that tolerates straight vs curly quotes (for names that contain a quote). */
|
|
export const quoteTolerant = (s: string): RegExp => new RegExp(`^${esc(s)}$`);
|
|
const yesNo = (yes: Name[] = ['Yes'], no: Name[] = ['No']): Record<string, Name[]> => ({ true: yes, false: no });
|
|
|
|
/** IARC topics -> candidate radiogroup names (modeled; see the header). */
|
|
const IARC_NAMES: Record<string, Name[]> = {
|
|
violence: [/^Does the app contain (any )?violen/i, /violen/i],
|
|
fear: [/^Does the app contain .*(scary|fear|horror)/i, /(fear|horror)/i],
|
|
sexuality: [/^Does the app contain .*(sexual|nudity)/i, /(sexual|nudity)/i],
|
|
language: [/^Does the app contain .*(profan|language)/i, /profanit/i],
|
|
controlledSubstances: [/^Does the app contain .*(drug|alcohol|tobacco|controlled substance)/i, /controlled substance/i],
|
|
crudeHumor: [/crude humou?r/i],
|
|
gambling: [/^Does the app (allow|let) .*gambl.*real (money|currency)/i, /real[- ]money gambling/i],
|
|
simulatedGambling: [/simulated gambling/i],
|
|
lootBoxes: [/(random|randomi[sz]ed) (paid )?items/i, /loot box/i],
|
|
userInteraction: [/(users|players) .*(interact|communicate|exchange)/i],
|
|
sharesLocation: [/share .*(physical )?location/i],
|
|
digitalPurchases: [/digital (goods|purchases)/i, /purchase digital/i],
|
|
unrestrictedInternet: [/unrestricted (internet|web)/i, /web browser/i],
|
|
};
|
|
|
|
/**
|
|
* App Store Connect's labels where they differ from the names of Apple's help page (App Privacy > Data Collection,
|
|
* 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
|
|
* with its help text without a separator (the real Data Collection dialog: "NameIncluding first or last name",
|
|
* "Email AddressIncluding but not limited to ..."). The label must end where the help text starts (no lowercase
|
|
* letter follows), so "Contact" never matches "Contacts..."; two labels on one checkbox are told apart by find()
|
|
* (ambiguous) and by the exact names first.
|
|
*/
|
|
export function labelNames(label: string, aliases: string[] = []): Name[] {
|
|
const all = [label, ...aliases];
|
|
return [...all, ...all.map(l => new RegExp(`^${esc(l)}(?![a-z\u00e0-\u024f])`))];
|
|
}
|
|
|
|
/** Field definitions for the answers of one form (App Privacy has one section per collected data type). */
|
|
export function formDef(id: FormId, answers: Answer[]): FormDef {
|
|
const has = (aid: string) => answers.some(a => a.id === aid);
|
|
switch (id) {
|
|
case 'apple-app-record': return { id, sections: [{ title: 'New App', fields: [
|
|
{ id: 'platform:ios', kind: 'checkbox', names: ['iOS'] },
|
|
{ id: 'name', kind: 'text', names: ['Name', 'App Name'] },
|
|
{ id: 'language', kind: 'select', names: ['Primary Language', 'Primary language'] },
|
|
{ id: 'bundleId', kind: 'select', names: ['Bundle ID', 'Bundle Id'] },
|
|
{ id: 'sku', kind: 'text', names: ['SKU'] },
|
|
{ id: 'access', kind: 'radio', names: ['User Access'], options: { limited: ['Limited Access'], full: ['Full Access'] } },
|
|
] }] };
|
|
case 'apple-age-rating': return { id, sections: [] };
|
|
case 'play-app-record': return { id, sections: [{ title: 'Create app', fields: [
|
|
{ id: 'name', kind: 'text', names: ['App name', 'Uygulama adı'] },
|
|
{ id: 'packageName', kind: 'text', names: ['App package name', 'Package name', 'Uygulama paket adı', 'Paket adı'] },
|
|
{ id: 'language', kind: 'select', names: ['Default language', 'Varsayılan dil'] },
|
|
{ id: 'type', kind: 'radio', names: ['App or game', 'Uygulama veya oyun'], options: { App: ['App', 'Uygulama'], Game: ['Game', 'Oyun'] } },
|
|
{ id: 'price', kind: 'radio', names: ['Free or paid', 'Ücretsiz veya ücretli'], options: { Free: ['Free', 'Ücretsiz'], Paid: ['Paid', 'Ücretli'] } },
|
|
{ id: 'agree:policies', kind: 'checkbox', agreement: true, names: [/Developer Program Polic/i, /Geliştirici Program(ı)? Politika/iu] },
|
|
{ id: 'agree:exportLaws', kind: 'checkbox', agreement: true, names: [/export laws/i, /ihracat yasa/iu] },
|
|
] }] };
|
|
case 'apple-app-privacy': {
|
|
const sections: SectionDef[] = [{ title: 'Data collection', fields: [
|
|
{ id: 'collects', kind: 'radio', options: yesNo([/^Yes, we collect data/i], [/^No, we do not collect data/i]) },
|
|
] }];
|
|
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 (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 = 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]) }] });
|
|
}
|
|
return { id, sections };
|
|
}
|
|
case 'play-content-rating': return { id, sections: [
|
|
{ title: 'Email address and category', fields: [
|
|
{ id: 'email', kind: 'text', names: ['Email address', 'Email'] },
|
|
{ id: 'category', kind: 'radio', options: { 'Game': [/^Game\b/i], 'Social or communication': [/^Social/i], 'All other app types': [/^All other app types/i, /^Utility/i] } },
|
|
] },
|
|
{ title: 'Questionnaire', fields: answers.filter(a => a.id.startsWith('q:')).map(a => ({ id: a.id, kind: 'radio' as const, names: IARC_NAMES[a.id.slice(2)], options: yesNo() })) },
|
|
] };
|
|
case 'play-target-audience': return { id, sections: [
|
|
{ title: 'Target age', fields: AGE_GROUPS.map(g => ({ id: `age:${g}`, kind: 'checkbox' as const, names: [AGE_LABELS[g], AGE_LABELS[g].replace(/^Ages /, '')] })) },
|
|
...(has('appeal') ? [{ title: 'Appeal to children', fields: [{ id: 'appeal', kind: 'radio' as const, names: [/appeal to children/i], options: yesNo() }] }] : []),
|
|
{ title: 'Ads', fields: [{ id: 'ads', kind: 'radio', options: yesNo([/^Yes, my app contains ads/i], [/^No, my app does not contain ads/i]) }] },
|
|
] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The click gate: a control whose name says save, submit, publish, next... is never clicked by storebound (English and
|
|
* Turkish console wording). English words match whole words; Turkish stems match word starts (agglutinative suffixes).
|
|
*/
|
|
const EN = ['save', 'saved', 'submit', 'publish', 'create', 'send', 'apply', 'confirm', 'done', 'release', 'rollout', 'roll out', 'start', 'delete',
|
|
'remove', 'next', 'continue', 'finish', 'agree', 'accept', 'upload', 'deploy', 'discard', 'update', 'add for review', 'review', 'sign', 'log in', 'login',
|
|
// App Privacy's tracking step: a help panel, never needed (verified on the real dialog, October 2026)
|
|
'definitions and examples'];
|
|
const TR = ['kaydet', 'gönder', 'yayınla', 'yayımla', 'oluştur', 'onayla', 'tamam', 'bitti', 'bitir', 'sil', 'kaldır', 'başlat', 'sürüm',
|
|
'ileri', 'devam', 'sonraki', 'kabul', 'yükle', 'güncelle', 'incelemeye', 'giriş'];
|
|
const L = '\\p{L}\\p{N}';
|
|
// "uygula" (apply) is refused in every form, but not "uygulama" (app: Yeni Uygulama, Uygulama içeriği)
|
|
const FORBIDDEN = new RegExp(`(?<![${L}])(?:(?:${EN.map(esc).join('|')})(?![${L}])|(?:${TR.map(esc).join("|")}|uygula(?!ma)))`, 'iu');
|
|
|
|
/** Controls never pressed by storebound or by an agent following `console plan`, whatever the form: help panels and
|
|
* the final buttons (the agent-facing never-press list is built from this and FORM_BUTTONS). */
|
|
export const NEVER_PRESS = ['Publish', 'Submit', 'Create', 'Create app', 'Send', 'Release', 'Definitions and Examples', '?', 'More information'];
|
|
|
|
/** true when storebound must not click a control with this accessible name. */
|
|
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase());
|
|
|
|
/**
|
|
* Ticking a checkbox / radio never saves or sends anything, so the button word list above does not apply to them (their
|
|
* names carry help text: "Product InteractionSuch as ... saved place in a game"). What a tick must never do is accept an
|
|
* agreement: those are refused here.
|
|
*/
|
|
const AGREEMENT = /\b(?:i agree|agree to|accept(?:s|ed)?\b|terms of service|developer program polic|export laws?|declaration|consent)|kabul|onaylıyorum|beyan/iu;
|
|
export const forbiddenTick = (name: string): boolean => AGREEMENT.test(name.toLocaleLowerCase('tr').normalize('NFC'));
|
|
|
|
const SIGN_IN_HOSTS = ['appleid.apple.com', 'idmsa.apple.com', 'accounts.google.com'];
|
|
|
|
/**
|
|
* A sign-in page or frame (storebound never touches those; the owner signs in): Apple ID / Google accounts hosts, App
|
|
* Store Connect's /login, and Google sign-in paths (/signin, ServiceLogin) on any google.com host.
|
|
*/
|
|
export function isSignInUrl(url: string): boolean {
|
|
try {
|
|
const u = new URL(url), h = u.hostname;
|
|
if (SIGN_IN_HOSTS.includes(h)) return true;
|
|
if (h === 'appstoreconnect.apple.com' && /^\/login(\/|$)/.test(u.pathname)) return true;
|
|
return (h === 'google.com' || h.endsWith('.google.com')) && /\/(signin|ServiceLogin)(\/|$)/i.test(u.pathname);
|
|
} catch { return false; }
|
|
}
|
|
|
|
/** An Apple sign-in frame (idmsa.apple.com / appleid.apple.com): the only child frame whose form counts as a sign-in. */
|
|
export function isAppleSignInFrame(url: string): boolean {
|
|
try { const h = new URL(url).hostname; return h === 'idmsa.apple.com' || h === 'appleid.apple.com'; } catch { return false; }
|
|
}
|
|
|
|
/**
|
|
* Whole visible texts (or labels / placeholders) that only a sign-in form shows, English and Turkish. Apple's form
|
|
* lives in an idmsa.apple.com frame and starts with no password field (Email or Phone Number > Continue > passkey or
|
|
* password). `weak`: also used elsewhere (an account menu), so they count only inside a child frame.
|
|
*/
|
|
export const SIGN_IN_TEXTS = {
|
|
strong: ['Sign in with Passkey', 'Email or Phone Number', 'Sign in to App Store Connect', 'Keep me signed in', 'Email or phone',
|
|
'Geçiş Anahtarıyla Giriş Yap', 'Geçiş anahtarı ile giriş yap', 'Geçiş Anahtarı ile Giriş Yap', 'E-posta veya Telefon Numarası',
|
|
'App Store Connect’e giriş yap', "App Store Connect'e giriş yap", 'Oturumum açık kalsın', 'E-posta veya telefon'],
|
|
weak: ['Apple Account', 'Apple Hesabı', 'Apple ID'],
|
|
};
|
|
const textRe = (list: string[]) => new RegExp(`^\\s*(?:${list.map(esc).join('|')})\\s*$`, 'iu');
|
|
const STRONG = textRe(SIGN_IN_TEXTS.strong), WEAK = textRe(SIGN_IN_TEXTS.weak);
|
|
|
|
/** A text that marks a sign-in form (`inFrame`: it is inside a child frame, where the weak texts count too). */
|
|
export const isSignInText = (t: string, inFrame = false): boolean => {
|
|
const s = t.normalize('NFC').replace(/\s+/g, ' ');
|
|
return STRONG.test(s) || (inFrame && WEAK.test(s));
|
|
};
|
|
|
|
/** The console itself is open (signed in). `base` overrides the console origin (tests only, 127.0.0.1). */
|
|
export function consoleReached(store: 'apple' | 'play', url: string, base?: string): boolean {
|
|
try {
|
|
const u = new URL(url);
|
|
if (base) return u.origin === new URL(base).origin;
|
|
return store === 'apple' ? u.hostname === 'appstoreconnect.apple.com' && !u.pathname.startsWith('/login')
|
|
: u.hostname === 'play.google.com' && u.pathname.startsWith('/console') && !u.pathname.includes('signup');
|
|
} catch { return false; }
|
|
}
|
|
|
|
// ---- navigation to the form after sign-in (`storebound console fill` opens the form itself) ----
|
|
// Navigation clicks are allowed, saving / submitting / publishing never: every navigation click goes through navClick,
|
|
// which reads the element's real accessible name and checks it with navAllowed: the click gate above stays absolute.
|
|
// The one exception is FORM_OPENERS: a LINK whose whole name is exactly Start / Manage / Başla / Yönet (Play's App
|
|
// content entries), which only opens a form; a button named Start, or "Start rollout", stays refused.
|
|
// URLs (checked October 2026): App Store Connect app list /apps and an app's App Privacy page
|
|
// /apps/<appId>/distribution/privacy. Play Console has no deep link without the developer and app ids (the config has
|
|
// neither), so Play is navigated by role + name from the app list. A step not found in time -> the printed steps.
|
|
|
|
/** One navigation step. Steps are logged by `label`; a failed step is named in the fallback. */
|
|
export type NavStep =
|
|
| { kind: 'goto'; label: string; url: string }
|
|
/** Click the one visible control of one of `roles` named like one of `names`. `optional`: skipped when absent or when `unless` is already visible.
|
|
* `createApp`: the Play app list's Create app opener (createAppAllowed decides, never the plain gate). */
|
|
| { kind: 'click'; label: string; roles: string[]; names: Name[]; optional?: boolean; unless?: { roles: string[]; names: Name[] }; createApp?: boolean }
|
|
/** Pick the app from the list: rows / links whose name contains one of `tokens` (bundle id / package name) as a whole token, else whose name is `name`. */
|
|
| { kind: 'pickApp'; label: string; tokens: string[]; name?: string; search?: Name[] }
|
|
/** The Start / Manage link of the App content entry named like `entry`. */
|
|
| { kind: 'formLink'; label: string; entry: Name[] }
|
|
/** Wait for a visible element of one of `roles` named like `names` (the form is open); `level`: headings of that level only. */
|
|
| { kind: 'wait'; label: string; roles: string[]; names: Name[]; level?: number };
|
|
|
|
/** Link names that only open a form (whole name, links only): allowed although "start" is in the click gate. */
|
|
export const FORM_OPENERS = ['Start', 'Manage', 'Başla', 'Yönet'];
|
|
|
|
/**
|
|
* Play Console's page-level "Create app" button on Home (All apps) only OPENS the Create app form, but the same words
|
|
* are the form's final submit button. So it is let through only as the navigation step from the app list, never
|
|
* inside the form: the whole name is exactly one of these, the role is button or link, the page is the app list
|
|
* (an "All apps" heading or an /app-list URL), the Create app form is not open (no App name field on the page), and
|
|
* the control is not inside a form or dialog. Everything else named Create app is refused (forbiddenControl).
|
|
*/
|
|
export const CREATE_APP_OPENERS = ['Create app', 'Uygulama oluştur'];
|
|
export interface CreateAppWhere {
|
|
/** The page is the app list (All apps heading or /app-list URL). */
|
|
appList: boolean;
|
|
/** The Create app form is open on the page (its App name field is there). */
|
|
formOpen: boolean;
|
|
/** The control sits inside a <form>, role=form or a dialog. */
|
|
inForm: boolean;
|
|
}
|
|
export function createAppAllowed(name: string, role: string, where: CreateAppWhere): boolean {
|
|
const n = name.trim().replace(/\s+/g, ' ');
|
|
return CREATE_APP_OPENERS.some(o => o.toLocaleLowerCase('tr') === n.toLocaleLowerCase('tr')) && (role === 'button' || role === 'link')
|
|
&& where.appList && !where.formOpen && !where.inForm;
|
|
}
|
|
/** Headings of Play Console's app list (Home / All apps). */
|
|
export const ALL_APPS: Name[] = ['All apps', 'Tüm uygulamalar', 'Home', 'Ana sayfa'];
|
|
/** The Create app form's App name field (its presence means the form is open: Create app is the submit there). */
|
|
export const CREATE_FORM_FIELD: Name[] = ['App name', 'Uygulama adı'];
|
|
|
|
/** URL bases (tests point them at 127.0.0.1 fixtures). */
|
|
export interface NavBases { asc: string; play: string }
|
|
export const NAV_BASES: NavBases = { asc: 'https://appstoreconnect.apple.com', play: 'https://play.google.com/console' };
|
|
|
|
const APP_CONTENT: Name[] = ['App content', 'Uygulama içeriği', 'Uygulama İçeriği'];
|
|
const POLICY: Name[] = ['Policy and programs', 'Policy', 'Politika ve programlar', 'Politika'];
|
|
|
|
/**
|
|
* The steps that open a form after sign-in, built from the config only (apple.appId, apple.bundleId, play.packageName,
|
|
* apple.name / app.name). The last step waits for the form itself. Pure.
|
|
*/
|
|
export function navPlan(id: FormId, config: ShipConfig, bases: NavBases = NAV_BASES): NavStep[] {
|
|
const asc = bases.asc.replace(/\/$/, ''), play = bases.play.replace(/\/$/, '');
|
|
const name = config.app?.name;
|
|
const named = name ? { name } : {};
|
|
const appleNamed = name ? { name: appleName(config) } : {};
|
|
switch (id) {
|
|
case 'apple-app-record': return [
|
|
{ kind: 'goto', label: 'open Apps', url: `${asc}/apps` },
|
|
{ kind: 'click', label: 'the add button (+)', roles: ['button'], names: ['+', 'Add Apps', 'Add App', 'New App', 'Uygulama Ekle', 'Uygulama ekle', 'Yeni Uygulama', /^add apps?$/i] },
|
|
{ kind: 'click', label: 'New App in the menu', roles: ['menuitem', 'menuitemradio', 'link', 'button'], names: ['New App', 'Yeni Uygulama', /^new app$/i, /^yeni uygulama$/iu] },
|
|
{ kind: 'wait', label: 'the New App dialog', roles: ['dialog'], names: ['New App', 'Yeni Uygulama', /^new app\b/i, /^yeni uygulama\b/iu] },
|
|
];
|
|
case 'apple-age-rating': return [];
|
|
case 'play-app-record': return config.play?.developerId ? [
|
|
// verified 2026-10-12: the Create app form of one developer account has its own URL
|
|
{ kind: 'goto', label: 'open the Create app form of the developer account', url: playCreateApp(play, config.play.developerId) },
|
|
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
|
|
] : [
|
|
{ kind: 'goto', label: 'open Play Console', url: play },
|
|
{ kind: 'click', label: 'Create app on the app list', roles: ['button', 'link'], names: CREATE_APP_OPENERS, createApp: true },
|
|
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
|
|
];
|
|
case 'apple-app-privacy': {
|
|
const appId = config.apple?.appId;
|
|
const open: NavStep[] = appId ? [{ kind: 'goto', label: 'open App Privacy of the app (apple.appId)', url: `${asc}/apps/${encodeURIComponent(appId)}/distribution/privacy` }] : [
|
|
{ kind: 'goto', label: 'open Apps', url: `${asc}/apps` },
|
|
{ kind: 'pickApp', label: 'the app in the list', tokens: config.apple?.bundleId ? [config.apple.bundleId] : [], ...appleNamed },
|
|
{ kind: 'click', label: 'App Privacy in the sidebar', roles: ['link', 'tab'], names: ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i] },
|
|
];
|
|
return [...open, { kind: 'wait', label: 'the App Privacy page', roles: ['heading'], names: ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i] }];
|
|
}
|
|
case 'play-content-rating':
|
|
case 'play-target-audience': {
|
|
const [title, entry]: [string, Name[]] = id === 'play-content-rating'
|
|
? ['Content rating', ['Content rating', 'Content ratings', 'İçerik derecelendirmesi', /^content ratings?$/i]]
|
|
: ['Target audience and content', ['Target audience and content', 'Hedef kitle ve içerik', /^target audience\b/i, /^hedef kitle\b/iu]];
|
|
return [
|
|
config.play?.developerId
|
|
? { kind: 'goto', label: 'open the app list of the developer account', url: playAppList(play, config.play.developerId) }
|
|
: { kind: 'goto', label: 'open Play Console', url: play },
|
|
// by package name only when there is one: with several developer accounts, an app of the same name in another
|
|
// account must not count
|
|
{ kind: 'pickApp', label: 'the app in the list', tokens: config.play?.packageName ? [config.play.packageName] : [], ...(config.play?.packageName ? {} : named), search: [/^search/i, 'Uygulama ara', /^ara$/iu] },
|
|
{ kind: 'click', label: 'Policy and programs (opens the menu)', roles: ['button', 'link'], names: POLICY, optional: true, unless: { roles: ['link'], names: APP_CONTENT } },
|
|
{ kind: 'click', label: 'App content', roles: ['link'], names: APP_CONTENT },
|
|
{ kind: 'formLink', label: `${title}: Start or Manage`, entry },
|
|
{ kind: 'wait', label: `the ${title} form`, roles: ['heading'], names: entry, level: 1 },
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The navigation click gate: true when storebound may click a control with this accessible name and role while it
|
|
* opens a form. forbiddenControl stays absolute; the only exception is a FORM_OPENERS link (whole name).
|
|
*/
|
|
export function navAllowed(name: string, role: string): boolean {
|
|
const n = name.trim().replace(/\s+/g, ' ');
|
|
if (FORM_OPENERS.includes(n)) return role === 'link'; // a Start / Başla BUTTON may start something: never
|
|
return n.length > 0 && !forbiddenControl(n);
|
|
}
|
|
|
|
// ---- the buttons of each form: navigation, draft save, final submit ----
|
|
// The owner should only click what only the owner may decide. Every button of the console forms is classified here
|
|
// (help pages below, checked October 2026); `auto` says whether storebound clicks it itself:
|
|
// navigation only moves between pages or opens a dialog: storebound clicks it (flowAllowed), after the page's
|
|
// certain answers are filled and only when no answer on the page is uncertain
|
|
// draft-save stores answers that are not public yet, clicked by storebound: App Privacy's in-dialog Save (Apple
|
|
// keeps the answers in App Store Connect and puts them on the product page only after Publish: "Your
|
|
// responses will be published on your product page on the App Store" once you click Publish) and the
|
|
// Play content rating's Save draft ("To finish later, click Save draft"; IARC gets nothing before
|
|
// Submit). The owner agreed to both (October 2026)
|
|
// final-submit publishes, submits, creates or sends something for review: always the owner's click
|
|
// Sources: Apple "Manage app privacy" https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy ;
|
|
// Apple "Add a new app" https://developer.apple.com/help/app-store-connect/create-an-app-record/add-a-new-app ;
|
|
// Apple "Set an app age rating" https://developer.apple.com/help/app-store-connect/manage-app-information/set-an-app-age-rating ;
|
|
// Google "Content ratings" https://support.google.com/googleplay/android-developer/answer/9859655 ;
|
|
// Google "Target audience and content" https://support.google.com/googleplay/android-developer/answer/9867159 ;
|
|
// Google "Create and set up your app" https://support.google.com/googleplay/android-developer/answer/9859152
|
|
|
|
export type ButtonClass = 'navigation' | 'draft-save' | 'final-submit';
|
|
export interface ButtonRule { button: string; class: ButtonClass; where: string; auto: boolean }
|
|
|
|
const NEXT: Name[] = ['Next', 'Continue', 'İleri', 'Sonraki', 'Devam', 'Devam et'];
|
|
const BACK: Name[] = ['Back', 'Geri'];
|
|
const GET_STARTED: Name[] = ['Get Started', 'Başlayın', 'Başla'];
|
|
const EDIT: Name[] = ['Edit', 'Düzenle'];
|
|
const SAVE: Name[] = ['Save', 'Kaydet'];
|
|
const SAVE_DRAFT: Name[] = ['Save draft', 'Taslağı kaydet', 'Taslak olarak kaydet'];
|
|
const DONE: Name[] = ['Done', 'Bitti', 'Tamam'];
|
|
/** Play Create app: the read-only package name check next to App package name (verified 2026-10-12). */
|
|
const CHECK_AVAILABILITY: Name[] = ['Check availability', 'Kullanılabilirliği kontrol et', 'Uygunluğu kontrol et'];
|
|
/** The heading of App Privacy's Data Types section (Edit and the data types sit in it). */
|
|
export const DATA_TYPES_HEADING: Name[] = ['Data Types', 'Veri Türleri', 'Veri türleri'];
|
|
/** The App Privacy page heading. */
|
|
export const APP_PRIVACY_HEADING: Name[] = ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i];
|
|
/** Names the engine looks for. */
|
|
export const FLOW_NAMES = { next: NEXT, save: SAVE, saveDraft: SAVE_DRAFT, getStarted: GET_STARTED, edit: EDIT, checkAvailability: CHECK_AVAILABILITY };
|
|
|
|
/** Every button of every console form, classified (the console-autofill skill's table is written from this). */
|
|
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: '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: 'Definitions and Examples', class: 'navigation', where: 'a data type\'s tracking step: help only (never clicked)', auto: false },
|
|
{ button: 'Save', class: 'draft-save', where: 'questionnaire dialogs: stores the answers, not public until Publish', auto: true },
|
|
{ button: 'Publish (and its confirmation dialog)', class: 'final-submit', where: 'top right of the App Privacy page', auto: false },
|
|
],
|
|
'apple-app-record': [
|
|
{ button: '+ / New App', class: 'navigation', where: 'Apps: opens the New App dialog', auto: true },
|
|
{ button: 'Cancel', class: 'navigation', where: 'New App dialog: closes it, nothing saved (only to leave a team without the bundle id)', auto: true },
|
|
{ button: 'Create', class: 'final-submit', where: 'New App dialog: creates the app record', auto: false },
|
|
],
|
|
'apple-age-rating': [
|
|
{ button: 'Set Up Age Ratings / Edit', class: 'navigation', where: 'App Information (guide only: `storebound apple appinfo` sets the rating by API)', auto: false },
|
|
{ button: 'Next', class: 'navigation', where: 'age rating questionnaire (guide only)', auto: false },
|
|
{ button: 'Done / Save', class: 'final-submit', where: 'age rating questionnaire: stores the rating (guide only)', auto: false },
|
|
],
|
|
'play-app-record': [
|
|
{ button: 'Create app (app list)', class: 'navigation', where: 'Home / All apps: opens the Create app form', auto: true },
|
|
{ button: 'Check availability', class: 'navigation', where: 'the Create app form, next to App package name: read-only check ("Package name available")', auto: true },
|
|
{ button: 'Developer Program Policies / US export laws boxes', class: 'final-submit', where: 'the Create app form: agreements, never ticked', auto: false },
|
|
{ button: 'Create app (form)', class: 'final-submit', where: 'the Create app form: creates the app', auto: false },
|
|
],
|
|
'play-content-rating': [
|
|
{ button: 'Start / Start new questionnaire', class: 'navigation', where: 'App content: opens the questionnaire', auto: true },
|
|
{ button: 'Next', class: 'navigation', where: 'after the category, and from the questions to the Summary', auto: true },
|
|
{ button: 'Save draft', class: 'draft-save', where: 'questionnaire: stores the answers as the one draft of the app; nothing goes to IARC before Submit', auto: true },
|
|
{ button: 'Submit', class: 'final-submit', where: 'Summary: sends the questionnaire to IARC', auto: false },
|
|
],
|
|
'play-target-audience': [
|
|
{ button: 'Start / Manage', class: 'navigation', where: 'App content: opens the section', auto: true },
|
|
{ button: 'Next', class: 'navigation', where: 'between Target age, App details, Ads and Store presence', auto: true },
|
|
{ button: 'Save', class: 'final-submit', where: 'the last page: stores the declaration, which Google then reviews', auto: false },
|
|
],
|
|
};
|
|
|
|
const norm = (n: string): string => n.trim().replace(/\s+/g, ' ');
|
|
const nameIs = (n: string, names: Name[]): boolean => names.some(x => (typeof x === 'string' ? x.toLocaleLowerCase('tr') === n.toLocaleLowerCase('tr') : x.test(n)));
|
|
/** Words that publish, submit or create: final whatever the context. */
|
|
const FINAL = /(?<![\p{L}])(publish|submit|create|send|release|rollout|roll out|yayınla|yayımla|gönder|oluştur|incelemeye)/iu;
|
|
|
|
/** Where a control sits, read from the page by the engine. */
|
|
export interface FlowWhere {
|
|
/** The App Privacy page is open (its heading). */
|
|
privacyPage: boolean;
|
|
/** Inside a dialog. */
|
|
inDialog: boolean;
|
|
/** Inside App Privacy's Data Types section. */
|
|
inDataTypes: boolean;
|
|
/** App Privacy: the data types storebound answers (their names open their questions). */
|
|
dataTypes: string[];
|
|
}
|
|
|
|
/** The class of a control of `form` with this accessible name and role where it sits, or null (unknown: never clicked). */
|
|
export function flowClass(form: FormId, name: string, role: string, where: FlowWhere): ButtonClass | null {
|
|
const n = norm(name);
|
|
if (!n || !['button', 'link'].includes(role)) return null;
|
|
if (FINAL.test(n.toLocaleLowerCase('tr')) || FINAL.test(n)) return 'final-submit';
|
|
if (nameIs(n, NEXT) || nameIs(n, BACK)) return 'navigation';
|
|
if (form === 'play-app-record' && nameIs(n, CHECK_AVAILABILITY)) return 'navigation';
|
|
if (form === 'apple-app-privacy') {
|
|
if (nameIs(n, GET_STARTED) && where.privacyPage && !where.inDialog) return 'navigation';
|
|
if (nameIs(n, EDIT) && where.inDataTypes && !where.inDialog) return 'navigation';
|
|
const low = n.toLocaleLowerCase('tr');
|
|
if (where.inDataTypes && !where.inDialog && where.dataTypes.some(t => { const tl = t.toLocaleLowerCase('tr'); return low === tl || low === `set up ${tl}` || low === `${tl} ayarla`; })) return 'navigation';
|
|
if (nameIs(n, SAVE)) return 'draft-save';
|
|
return null;
|
|
}
|
|
if (nameIs(n, SAVE_DRAFT)) return 'draft-save';
|
|
if (nameIs(n, SAVE) || nameIs(n, DONE)) return 'final-submit';
|
|
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'];
|
|
|
|
/**
|
|
* The flow click gate: a navigation control of the form, App Privacy's Save inside a questionnaire dialog, or the
|
|
* content rating's Save draft (drafts: nothing is public or sent before Publish / Submit). Everything else (Publish,
|
|
* Submit, Create, a final Save, an unknown control) stays the owner's click.
|
|
*/
|
|
export function flowAllowed(form: FormId, name: string, role: string, where: FlowWhere): boolean {
|
|
const c = flowClass(form, name, role, where);
|
|
if (c === 'navigation') return true;
|
|
if (c !== 'draft-save' || !AUTO_DRAFT_SAVE.includes(form)) return false;
|
|
return form === 'apple-app-privacy' ? where.inDialog && where.privacyPage : true;
|
|
}
|
|
|
|
// ---- one login, several developer accounts / teams ----
|
|
// Switching the developer account (Play) or the team (App Store Connect) changes what the console shows, it writes
|
|
// nothing: such a click is let through when its target is one of the accounts storebound just read from the chooser or
|
|
// the team menu (exact name), is a link / menu item / option / row / button, and is never a sign-out or a deny-listed
|
|
// control (forbiddenControl stays absolute, so a studio named "Next Games" is not clicked: the owner picks it).
|
|
|
|
/** Play Console's Create app form of one developer account (verified 2026-10-12). */
|
|
export const playCreateApp = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/create-new-app`;
|
|
|
|
/** Play Console's app list of one developer account. */
|
|
export const playAppList = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app-list`;
|
|
|
|
/** The Play Console app id in a URL (/developers/<id>/app/<digits>/...), or null. */
|
|
export function playAppIdOf(url: string): string | null {
|
|
try { return new URL(url).pathname.match(/\/developers\/\d{1,25}\/app\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
|
|
}
|
|
/** An app's Play Console dashboard (verified 2026-10-12: where Create app lands). */
|
|
export const playAppDashboard = (playBase: string, developerId: string, appId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app/${encodeURIComponent(appId)}/app-dashboard`;
|
|
|
|
/** The developer account id in a Play Console URL (/developers/<digits>), or null. */
|
|
export function playDeveloperId(url: string): string | null {
|
|
try { return new URL(url).pathname.match(/\/developers\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
|
|
}
|
|
|
|
const SIGN_OUT = /\b(sign|log)\s*(out|off)\b|çıkış|oturumu kapat|hesap ekle|add account/iu;
|
|
/** Entries of an account menu that are not accounts (profile, settings, help, sign out). */
|
|
export const NOT_AN_ACCOUNT = /^(?:edit profile|profile|settings|account settings|help|users and access|switch team|teams?|profil(?:i düzenle)?|ayarlar|yardım|kullanıcılar ve erişim|ekip değiştir|ekipler?)$/iu;
|
|
|
|
/** The account-switch click gate: `name` must be one of `listed` (read from the chooser / team menu just before). */
|
|
export function switchAllowed(name: string, role: string, listed: string[]): boolean {
|
|
const n = name.trim().replace(/\s+/g, ' ');
|
|
return ['link', 'menuitem', 'menuitemradio', 'option', 'radio', 'row', 'button'].includes(role) && listed.includes(n)
|
|
&& !SIGN_OUT.test(n) && !NOT_AN_ACCOUNT.test(n) && navAllowed(n, role);
|
|
}
|
|
|
|
/** `id` as a whole token of `text` (com.example.game, not com.example.game2 or com.example.gamepro). */
|
|
export const hasToken = (text: string, id: string): boolean => new RegExp(`(?<![\\w.])${esc(id)}(?![\\w.])`).test(text);
|