// 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; /** 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[] } const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const yesNo = (yes: Name[] = ['Yes'], no: Name[] = ['No']): Record => ({ true: yes, false: no }); /** IARC topics -> candidate radiogroup names (modeled; see the header). */ const IARC_NAMES: Record = { 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], }; /** 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: '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: [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 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: [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']; 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(`(? FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase()); 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; } } /** * 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//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
, 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 ? { 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 }, { 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']; /** 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 }; /** Every button of every console form, classified (the console-autofill skill's table is written from this). */ export const FORM_BUTTONS: Record = { '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 ', class: 'navigation', where: 'Data Types section: opens that type\'s questions', auto: true }, { 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 }, { 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: '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 = /(? { 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 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 app list of one developer account. */ export const playAppList = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app-list`; /** The developer account id in a Play Console URL (/developers/), 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(`(?