desktop: Releases > Send for review (App Store submission state, builds by number, Submit / Resubmit / in-app purchases; Play versionCodes by number, Send to production, managed publishing, console plans for countries and Send changes for review), plan panel before every owner dialog with streamed progress; App info console steps for every console plan form with verify; Catalog price-change owner actions and the declined state; Data safety deletion answer; TestFlight create missing groups; Android SDK in Dependencies; upload progress in job toasts; boot check send-for-review cases on D1 and D3 state

This commit is contained in:
CRTkafa 2026-10-12 12:07:59 +08:00
parent 33eb24a3dc
commit 2b101f0fd8
22 changed files with 1527 additions and 57 deletions

View file

@ -20,6 +20,51 @@
- Console plan `play-production-release`: the "Do you want to restart your review?" dialog that "Submit N changes
for review" opens while a review is running is a known step; "Restart review" is the owner's button and in
neverPress (pitfall 33).
- Desktop: send for review from the window (owner decision, 2026-10-12). New owner actions (desktop only, never MCP
tools): `owner.apple_submit` (new `storebound apple submit --build <n> [--version x.y.z]`: the steps of `apple
resubmit` for a first submission: build n attached by number, the open review submission reused, the ready IAPs /
subscriptions with it, submitted with the "not ready yet" retry; plan table without `--yes`), `owner.apple_resubmit`
(`apple resubmit --build <n> [--resolve-iaps]`), `owner.apple_iap_submit` (`apple iap-submit [--with-version]`) and
`owner.play_production` (`play release --version-code <n> --production completed | inProgress | draft`). Each is a
dry run from the window; the real run needs the host's token minted after the native dialog, and only then serve
adds `--yes` (apple submit / resubmit) and answers the command's question. `OWNER_ALLOWED_ARGS` ties every
otherwise forbidden flag (`--yes`, `submit`, `--production`, `--csv`, `--allow-price-changes`) to its one owner
action, a dry run never carries an owner flag, and the MCP server still has no submit, resubmit or production
release (`tests/serve.test.ts`, `tests/desktop-d3.test.ts`, `src-tauri/tests/d3_writes.rs`). The submit dialog says
the app goes to review and may go live by itself once approved.
- Releases tab, "Send for review": the App Store submission state from `apple status` (waiting / in review,
UNRESOLVED_ISSUES with its items and which ones get marked resolved, empty drafts labelled harmless), the uploaded
builds to pick by number (processing ones disabled, nothing preselected), Submit for review, Resubmit (with
"also mark rejected IAP items resolved") and Submit in-app purchases (with the editable version); Google Play
versionCodes by number (one already live cannot be picked), Send to production as everyone, a staged share or a
draft (a never-published app), the managed-publishing warning from `play.managedPublishing`, and the console plans
of production countries and "Send changes for review" (no API) with a deep link. Every button shows the plan (dry
run, `--json` with the plan table under serve) in the window first, then the native dialog; the real run's
progress lines stream into the panel and Stop cancels it. The old "submit stays in the console" note is gone.
- App info, "Console steps without an API": every `console plan` form (the new Play store settings category, App
content and its cards, production countries and production release included) with a plan panel (start page and
deep link, steps, answer sheet, never-press buttons, the owner's final button, Copy plan for an AI agent) and the
read-only `console verify` (page text pasted, or the API for the App Store record and age rating). The Play forms
card is folded into it.
- Price gate: `owner.prices_import` passes `--allow-price-changes` itself after the dialog; new
`owner.prices_play_products`, `owner.prices_play_subscriptions`, `owner.prices_apple_subscriptions` (Catalog:
"Price changes") run that sync with the flag. A write that ends with exit 3 (declined: a held price change) shows "Waiting for your
decision", not a failure.
- Data safety card: the deletion question as the CSV answers it (`dataDeletion`, from `accounts.deletionUrl`,
`dataSafety.dataDeletion`, ...; `project.declarations` sends `dataDeletion`, `dataDeletionSource`,
`dataDeletionUrl`), older sidecars still shown from `partialDeletion`.
- TestFlight: "Create missing groups" (`apple testflight groups sync`, dry run first). Dependencies: the Android SDK
as `play build` finds it (`system.tools` `androidSdk`). `project.inspect` adds `playRelease` (managed publishing,
countries, category). Job toasts: upload lines ("312/890 MB (35%), part 4/12") drive the progress bar, and the
last 40 lines are in the toast's log.
- Covered by the existing views: `validate` findings ("production has 0 countries", the category reminder), `check`
warnings (stale web assets without `build.prebuild`, Play category, App content answers owed, privacy manifest
tracking mismatch ITMS-91064, the defaultConfig versionCode / versionName source warning) and `scan` findings
show in Checks; `play images` skipped languages and optional tablet sets show in the dry run of the native dialog.
- Boot check: four send-for-review cases (ready and UNRESOLVED_ISSUES, on the old D1 and the new D3 saved state)
with store answers from `src/dev/submit-fixtures.ts`: the Releases texts, the build and versionCode pickers, the
plan panels, a console plan from the real sidecar, every app tab and Dependencies, by DOM text; the dialog is
answered Cancel, nothing is sent.
- Safety (`apple testflight groups`): run bare, it created the configured beta groups right away, with no plan. Now it only lists the groups and prints `would create "Team", "Friends"`. Creating is
`groups sync` (or `groups --yes`). Flows treat bare `groups` as a read and `groups sync` as a write, and the MCP tool

View file

@ -15,7 +15,9 @@ export type {
export interface AppInfo { version: string; identifier: string; platform: string; debug: boolean; home: string | null }
export type { ToolStatus } from '../../../cli/lib/tools';
export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[] }
export type { AndroidSdkLookup } from '../../../cli/lib/tools';
/** system.tools: the local tools and (newer sidecars) the Android SDK as `play build` finds it. */
export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[]; androidSdk?: import('../../../cli/lib/tools').AndroidSdkLookup }
export interface SidecarStatus { running: boolean; version: string; protocol: string; methods: Pick<MethodDescriptor, 'method' | 'title' | 'description' | 'kind' | 'network' | 'destructive' | 'json'>[]; tools: LocalTools | null }
/** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean }

View file

@ -7,6 +7,7 @@ import { type Account, type AccountCheck, type ConsoleFillResult, type ConsoleFo
type PortfolioResult, type Project, type ProjectSummary, type Settings, type StoreApp, type StoreRelease, type Studio, type ToolResult } from '../api';
import { buildApps, type Overrides } from '../lib/identity';
import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model';
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures';
import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more';
export const FIXTURE_MARKER = 'storebound-dev-fixtures';
@ -113,6 +114,7 @@ const SUMMARY: Record<string, ProjectSummary> = {
products: 4, productIds: ['coins_small', 'coins_large', 'no_ads', 'starter_pack'], subscriptions: 1, subscriptionIds: ['vip_pass_monthly'],
catalog: { ios: ['coins_small', 'coins_large', 'no_ads', 'starter_pack', 'vip_pass_monthly'], android: ['coins_small', 'coins_large', 'no_ads', 'starter_pack', 'vip_pass_monthly'] },
shots: true, testflightGroups: 3, profile: 'game', icon: `${HOME}/apps/prism-puddle/store/icons/app-store-1024.png`,
playRelease: { managedPublishing: null, countries: null, category: 'Puzzle' },
},
'p-tidy': {
dir: `${HOME}/apps/tidy-lists`, config: `${HOME}/apps/tidy-lists/storebound.config.ts`, name: 'Tidy Lists', languages: ['en', 'tr'],
@ -120,6 +122,7 @@ const SUMMARY: Record<string, ProjectSummary> = {
products: 1, productIds: ['tip_jar'], subscriptions: 2, subscriptionIds: ['pro_monthly', 'pro_yearly'],
catalog: { ios: ['tip_jar', 'pro_monthly', 'pro_yearly'], android: ['tip_jar', 'pro_monthly', 'pro_yearly'] },
shots: true, testflightGroups: 2, profile: 'app', icon: `${HOME}/apps/tidy-lists/store/icons/app-store-1024.png`,
playRelease: { managedPublishing: true, countries: 12, category: 'Productivity' },
},
};
@ -440,7 +443,13 @@ function read(method: string, id: string, args: Record<string, unknown> = {}): T
case 'analytics.compare': return ANALYTICS[id] ? ok(method, ANALYTICS[id]) : { method, ok: false, exitCode: 1, dryRun: false, command: 'storebound analytics compare --json', output: `error: no snapshots in ${HOME}/apps/tidy-lists/.storebound/analytics (storebound analytics snapshot)` };
case 'aso.audit': return ASO[id] ? ok(method, ASO[id]) : { method, ok: false, exitCode: 1, dryRun: false, command: 'storebound aso audit --json', output: 'fixture: no data for aso.audit' };
case 'check': return { method, ok: true, exitCode: 0, dryRun: false, command: 'storebound check', output: '12:00:01 [check] config ok: storebound.config.ts\n12:00:01 [check] listing: 4 languages\n12:00:01 [check] 0 problems, 2 warnings' };
default: return { method, ok: false, exitCode: 1, dryRun: false, command: `storebound ${method}`, output: `fixture: no data for ${method}` };
// send for review: Prism Puddle is ready to submit, Tidy Lists is back from App Review (UNRESOLVED_ISSUES)
case 'apple.status': return ok(method, id === 'p-tidy' ? APPLE_STATUS_UNRESOLVED : APPLE_STATUS_READY);
case 'play.status': return ok(method, PLAY_STATUS);
case 'console.plan': return ok(method, consolePlan(String(args.form)));
case 'console.verify': return ok(method, consoleVerify(String(args.form)));
default:
if (method.startsWith('owner.')) return ownerDry(method, args); return { method, ok: false, exitCode: 1, dryRun: false, command: `storebound ${method}`, output: `fixture: no data for ${method}` };
}
}
@ -466,7 +475,7 @@ const CONSOLE_FILL: Record<ConsoleForm, ConsoleFillResult> = {
};
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 };
const NETWORK = new Set(['health', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status']);
const NETWORK = new Set(['health', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
async function progressLines(job: string | null, lines: string[]) {
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
@ -535,7 +544,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
case 'cache_clear': return null;
case 'sidecar_call': {
const method = a.method as string;
if (method === 'system.tools') return { tools: (await answer('sidecar_status', {}) as { tools: { tools: unknown[] } }).tools.tools };
if (method === 'system.tools') return { tools: (await answer('sidecar_status', {}) as { tools: { tools: unknown[] } }).tools.tools, androidSdk: { path: `${HOME}/Library/Android/sdk`, source: 'default', checked: ['ANDROID_HOME: not set', 'ANDROID_SDK_ROOT: not set', `Android Studio default folder: ${HOME}/Library/Android/sdk`] } };
const id = a.projectId as string;
if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]);
else await sleep(150);
@ -554,7 +563,9 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
const res: ToolResult = { method, ok: true, exitCode: 0, dryRun: false, command: `storebound console fill ${String(args.form)} --json`, output: '[console] browser closed', data: { ...r, filled: r.answers.length - r.uncertain, highlighted: r.uncertain, fallback: false, stoppedAt: null } };
return { confirmed: true, stage: 'done', dryRun: { ...res, dryRun: true, data: r }, result: res };
}
await progressLines(job, [`[${method}] dry run`, `[${method}] confirmed in the dialog`, `[${method}] applying`]);
await progressLines(job, method === 'owner.apple_submit' || method === 'owner.apple_resubmit'
? [`[${method}] dry run`, `[${method}] confirmed in the dialog`, '[submit] build attached', '[submit] review submission rs-new-1: not ready to be submitted yet, no item left REJECTED (try 1, next in 15s)', '[submit] accepted on try 2']
: [`[${method}] dry run`, `[${method}] confirmed in the dialog`, `[${method}] applying`]);
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
const d = DRAFTS[a.projectId as string]?.drafts.find(x => x.file.endsWith(`/${String(args.file)}`));
if (method === 'reviews.approve' && d) d.approved = !args.undo;

View file

@ -0,0 +1,105 @@
// Made-up store answers for the send-for-review cards (Releases tab), in the shapes `apple status --json` and
// `play status --json` print: one App Store app ready to submit (an editable version, VALID and processing builds, a
// harmless empty draft) and one back from App Review (UNRESOLVED_ISSUES with a REJECTED version item), plus Play
// tracks. Used by the dev fixture mode (fixtures.ts), tests/submit.test.ts and the boot check's live case
// (tools/desktop-boot-check.ts). Placeholder ids only; nothing here is real store data.
const version = (id: string, versionString: string, state: string, build: string | null) =>
({ id, type: 'appStoreVersions', attributes: { versionString, appVersionState: state, platform: 'IOS' }, relationships: { build: { data: build ? { type: 'builds', id: build } : null } } });
const build = (id: string, number: string, state: string, uploaded: string, pre: string) =>
({ id, type: 'builds', attributes: { version: number, processingState: state, uploadedDate: uploaded }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: pre } } } });
const BUILDS = [build('b-53', '53', 'PROCESSING', '2026-10-12T08:10:00Z', 'pre-240'), build('b-52', '52', 'VALID', '2026-10-11T16:40:00Z', 'pre-240'), build('b-48', '48', 'VALID', '2026-09-30T09:00:00Z', 'pre-230')];
export const APPLE_STATUS_READY = {
app: { id: '1000000001', attributes: { name: 'Prism Puddle', bundleId: 'com.example.prismpuddle' } },
versions: [version('ver-240', '2.4.0', 'PREPARE_FOR_SUBMISSION', null), version('ver-230', '2.3.0', 'READY_FOR_SALE', 'b-48')],
builds: BUILDS, attachedBuilds: [], buildVersions: { 'b-53': '2.4.0', 'b-52': '2.4.0', 'b-48': '2.3.0' },
iaps: [], phased: {},
subs: [{ id: 'rs-empty-1', attributes: { state: 'READY_FOR_REVIEW', submittedDate: null } }, { id: 'rs-old-1', attributes: { state: 'COMPLETE', submittedDate: '2026-09-30T12:00:00Z' } }],
unresolved: [], emptyDrafts: ['rs-empty-1'],
};
export const APPLE_STATUS_UNRESOLVED = {
...APPLE_STATUS_READY,
versions: [version('ver-240', '2.4.0', 'REJECTED', 'b-52'), version('ver-230', '2.3.0', 'READY_FOR_SALE', 'b-48')],
subs: [{ id: 'rs-back-1', attributes: { state: 'UNRESOLVED_ISSUES', submittedDate: '2026-10-11T18:00:00Z' } }],
unresolved: [{ id: 'rs-back-1', items: [
{ id: 'it-ver-1', kind: 'appStoreVersion', versionId: 'ver-240', state: 'REJECTED' },
{ id: 'it-coins-1', kind: 'inAppPurchaseVersion', versionId: 'iapv-coins', state: 'READY_FOR_REVIEW' },
] }],
emptyDrafts: [],
};
const res = (method: string, command: string, output: string, data?: unknown, ok = true) =>
({ method, ok, exitCode: ok ? 0 : 1, dryRun: true, command, output, ...(data === undefined ? {} : { data }) });
/** Dry runs of the owner actions, with the plan tables the CLI prints (cli/lib/review-submit.ts formatRows). */
export function ownerDry(method: string, args: Record<string, unknown>) {
const b = String(args.build ?? '');
if (method === 'owner.apple_submit' || method === 'owner.apple_resubmit') {
const resub = method === 'owner.apple_resubmit';
const table = resub ? [
'STEP WHAT NOW ACTION',
`build build ${b} attached: 52 attach build ${b} instead of 52`,
'submission rs-back-1 UNRESOLVED_ISSUES reuse (resubmit, no new draft)',
'item app version 2.4.0 REJECTED in it already; mark resolved',
'item coins_small READY_FOR_REVIEW in it already',
'submit PATCH submitted: true - owner step (--yes); retried while Apple says "not ready yet" and no item is left REJECTED',
] : [
'STEP WHAT NOW ACTION',
`build build ${b} none attached attach build ${b}`,
'submission new draft - create (no open submission)',
'item app version 2.4.0 - add',
'item coins_small (consumable, version 1) - add',
'submit PATCH submitted: true - owner step (--yes); retried while Apple says "not ready yet" and no item is left REJECTED',
];
const tag = resub ? 'resubmit' : 'submit';
return res(method, `storebound apple ${tag} --build ${b} --dry --json`, [...table, `12:00:02 [${tag}] dry run: nothing sent`].join('\n'),
{ dryRun: true, version: { versionString: '2.4.0', state: resub ? 'REJECTED' : 'PREPARE_FOR_SUBMISSION' }, build: { action: 'attach', number: b }, blockers: [] });
}
if (method === 'owner.play_production') {
return res(method, `storebound play release --version-code ${String(args.version_code)} --production ${String(args.status ?? 'completed')} --dry`,
[`12:00:01 [play] production: versionCode ${String(args.version_code)} as ${String(args.status ?? 'completed')}`, '12:00:01 [play] release notes: en-US, tr-TR', '12:00:01 [play] WARN managed publishing is off by default (play.managedPublishing not set): the release goes live automatically once Google approves it', '12:00:01 [play] dry run: edit not committed'].join('\n'));
}
if (method.startsWith('owner.prices_') && method !== 'owner.prices_import') {
return res(method, `storebound ${method.replace('owner.prices_', '').replace('_', ' ')} --dry`, ['ID REGION NOW WANT ACTION', 'coins_small US USD 0.99 USD 1.49 owner (price change: needs --allow-price-changes)', 'coins_small DE EUR 0.99 EUR 1.49 owner (price change: needs --allow-price-changes)', '12:00:01 [play] dry run: nothing written'].join('\n'));
}
return res(method, `storebound ${method.replace('owner.', '').replace('_', ' ')} --dry`, `12:00:01 [${method}] dry run (fixture)`);
}
/** `console plan <form> --json`, shortened (made up from the demo configs). */
export function consolePlan(form: string) {
const play = form.startsWith('play');
return {
form, store: play ? 'play' : 'apple', title: form === 'play-production-release' ? 'Play Console: send the first production release for review' : `Console plan: ${form}`,
start: { url: play ? 'https://play.google.com/console' : 'https://appstoreconnect.apple.com/apps', deepLink: false, reach: play ? ['the app in the app list', 'Test and release > Production'] : ['the app', 'General > App Information'] },
steps: [
{ n: 1, text: 'Open the page above (sign in yourself the first time).' },
{ n: 2, text: 'Press Edit release, then Next: the review page lists errors and warnings.', click: ['Edit release', 'Next'] },
{ n: 3, text: 'Press Save (nothing is sent yet).', click: ['Save'] },
{ n: 4, text: 'Stop. The owner presses "Send changes for review" in Publishing overview.' },
],
answers: [
{ id: 'managedPublishing', question: 'Managed publishing on?', text: 'No', status: 'owner-check', note: 'Managed publishing is off by default: the release goes live once Google approves it.' },
{ id: 'countries', question: 'Countries / regions', text: 'All countries / regions', status: 'certain' },
],
askOwner: ['Managed publishing on?: No (owner check)'],
buttons: { neverPress: ['Send changes for review', 'Publish', 'Submit', 'Create', 'Start rollout to Production'], final: 'Send changes for review' },
};
}
export function consoleVerify(form: string) {
return { form, source: 'page-text', rows: [{ item: 'Production release', expected: '2.4.0 (24)', seen: '2.4.0 (24)', status: 'match' }, { item: 'Countries', expected: 'all', seen: '177 countries / regions', status: 'match' }], mismatches: 0, matched: 2, ok: true, notes: [] };
}
export const PLAY_STATUS = {
details: { defaultLanguage: 'en-US' },
category: { config: 'Puzzle', source: 'play.category', api: false, plan: 'storebound console plan play-store-settings' },
tracks: [
{ track: 'production', releases: [{ name: '2.3.0 (23)', versionCodes: ['23'], status: 'completed' }] },
{ track: 'beta', releases: [{ name: '2.4.0 (24)', versionCodes: ['24'], status: 'completed' }] },
{ track: 'internal', releases: [{ name: '2.4.0 (24)', versionCodes: ['24'], status: 'completed' }, { name: '2.3.1 (22)', versionCodes: ['22'], status: 'completed' }] },
],
listings: [], products: [],
};

View file

@ -227,7 +227,6 @@ export const en = {
'releases.noVersionsText': 'When the first build is uploaded it shows here.',
'releases.history': 'Versions on the stores',
'releases.historySub': 'Read {when}',
'releases.consoleNote': 'storebound never submits for review and never releases an approved version: do those in the store console. Rollout and phased release changes are your decision, each confirmed in a system dialog.',
'cat.products': 'One-time products',
'cat.productsSub': 'In the config',
@ -1316,11 +1315,165 @@ export const en = {
'cf.cancelRun': 'Cancel: close the browser',
'cf.open': 'Open the browser',
'cf.waiting': 'Waiting until you close the browser',
'cf.playTitle': 'Play Console forms',
'cf.playSub': 'No API: storebound fills them in the console, you press the final button',
'cf.playContentRatingText': 'The IARC questionnaire that gives the app its age ratings.',
'cf.playTargetAudienceText': 'Target age groups and whether the app appeals to children.',
'cf.playAppRecordText': 'The Play Developer API cannot create apps: storebound fills Create app (name, language, app or game, free or paid). You tick the declarations and press Create app; the first AAB upload sets the package name.',
'cf.playAppRecordText': 'The Play Developer API cannot create apps: storebound fills Create app (name, package name, language, app or game, free or paid). You tick the declarations and press Create app, then add the app to the service account.',
'cf.noRecordTitle': 'No App Store app record yet',
'cf.noRecordText': 'App Store Connect has no API to create the app record. storebound fills the New App form from your config; you press Create yourself, then put the Apple ID into apple.appId.',
// send for review, console plans, owner actions (desktop parity)
'write.declined': 'Waiting for your decision',
'toast.log': 'Log ({count} lines)',
'releases.ownerNote': 'Submitting, resubmitting and the production release are your decision: each shows its plan first, then a system dialog, and runs only after you press its button there. Releasing an approved App Store version (manual release) stays in App Store Connect.',
'oa.planFailed': 'Could not prepare: {what}',
'oa.notSent': 'You chose Cancel in the dialog: nothing was sent.',
'oa.dryChanged': 'The dry run did not pass this time (the store changed in between?): nothing was sent. Check the plan below.',
'oa.cancelled': 'Stopped. What the store already took stays; read the state again before you go on.',
'oa.dryBad': 'The dry run stopped',
'oa.plan': 'Plan (dry run, nothing sent yet)',
'oa.noOutput': '(the dry run printed nothing)',
'oa.running': 'Running. Apple can answer "not ready yet" for a few minutes after a build is attached; storebound retries with a heartbeat line, up to 10 minutes.',
'oa.progress': 'Progress',
'oa.foot': 'Next: a system dialog shows this plan again. Nothing is sent before you press its button.',
'oa.fixFirst': 'Fix what the plan names first, then read the state again.',
'oa.stop': 'Stop',
'oa.continue': 'Continue: {what}',
'oa.working': 'Working',
'cp.title': 'Console steps without an API',
'cp.sub': 'Each form as a plan your AI agent (or you) follows in the console, then a check of the result. The final button stays yours.',
'cp.button': 'Plan',
'cp.hint': 'The steps, answers and buttons for {form}, and a check of the result',
'cp.failed': 'Could not make the plan for {form}',
'cp.agentFirst': 'Agent-first: your AI agent drives the page with this plan; storebound never clicks the final button',
'cp.openPage': 'Open the page',
'cp.openFailed': 'Could not open the page',
'cp.steps': '{count} steps',
'cp.steps_one': '1 step',
'cp.answers': '{count} answers from your config',
'cp.answers_one': '1 answer from your config',
'cp.sCertain': 'From the config',
'cp.sAsk': 'You answer',
'cp.sCheck': 'You check',
'cp.sAgreement': 'Agreement: yours',
'cp.final': 'The final button ({button}) is yours. Never pressed by anyone else:',
'cp.verify': 'Check the result',
'cp.verifyApi': 'storebound reads the result from the App Store Connect API (read-only).',
'cp.verifyText': 'Paste the page text (or the capture JSON the plan names) after the form is saved; storebound compares it with the config (read-only).',
'cp.pasteLabel': 'Page text of the finished form',
'cp.pastePlaceholder': 'Paste the page text here',
'cp.verified': '{matched} match, {mismatches} differ',
'cp.vMatch': 'Match',
'cp.vMismatch': 'Differs',
'cp.vNotFound': 'Not found',
'cp.foot': 'Copy plan hands the whole plan (JSON) to your AI agent.',
'cp.copy': 'Copy plan',
'cp.copied': 'Plan copied',
'cp.verifyRun': 'Check',
'cp.appleAppRecordText': 'App Store Connect has no API to create the app record: the New App form, Create is yours.',
'cp.appleAgeRatingText': 'The age rating has an API (apple appinfo): the plan only shows where to look and how to check it.',
'cp.appleAppPrivacyText': 'No API: every data type with its purposes, linked and tracking answers; Publish is yours.',
'cp.playStoreSettings': 'Play store settings (App category)',
'cp.playStoreSettingsText': 'The Play API cannot set the App category: Edit > App or game > Category > Save.',
'cp.playAppContent': 'Play App content (every card)',
'cp.playAppContentText': 'Every App content card in order with its page, and the owner questions in one batch.',
'cp.playPrivacyPolicy': 'Play App content: privacy policy',
'cp.playAds': 'Play App content: ads',
'cp.playSignIn': 'Play App content: sign-in details',
'cp.playSignInText': 'The demo account for Google\'s reviewers: you type it (storebound never prints it).',
'cp.playAdId': 'Play App content: advertising ID',
'cp.playGovernment': 'Play App content: government apps',
'cp.playFinancial': 'Play App content: financial features',
'cp.playHealth': 'Play App content: health apps',
'cp.cardText': 'One App content card: its page, the answer from your config and the final Save.',
'cp.playCountries': 'Production countries',
'cp.playCountriesText': 'The API cannot set production countries (countryAvailability is read-only): pick them in the console.',
'cp.playProductionRelease': 'Play: send the production release for review',
'cp.playProductionReleaseText': 'Edit release > Next > Save, then Publishing overview. "Send changes for review" has no API: that click is yours.',
'sub.section': 'Send for review',
'sub.appleTitle': 'App Store: submit for review',
'sub.appleSub': 'Not read yet: read the submission state',
'sub.appleRead': 'App Store submission state',
'sub.read': 'Read state',
'sub.notRead': 'Submission state not read yet',
'sub.notReadText': 'Read the review submissions and the uploaded builds from App Store Connect (read-only) before you submit.',
'sub.submissions': 'Review submissions',
'sub.kWaiting': 'Waiting for review',
'sub.kInReview': 'In review',
'sub.kUnresolved': 'Back from App Review (unresolved issues)',
'sub.kDraft': 'Draft submission',
'sub.kEmptyDraft': 'Empty draft',
'sub.kDone': 'Done',
'sub.kOther': 'Submission',
'sub.harmless': 'harmless: App Store Connect keeps it and cannot cancel it; a later run reuses it',
'sub.markVersion': 'marked resolved when you resubmit with a new build',
'sub.markIap': 'marked resolved only with the box below, after you fixed what App Review asked for',
'sub.noOpen': 'No open review submission: a submit makes one.',
'sub.busy': 'A submission is {state}: nothing can be sent next to it until App Review is done.',
'sub.build': 'Build (by number)',
'sub.pickBuild': 'Pick the build you uploaded',
'sub.attachedTo': 'attached to {version}',
'sub.version': 'Version',
'sub.noVersion': 'No editable version',
'sub.buildHint': 'Only VALID builds can go in. Pick the number you uploaded: storebound never takes "the latest".',
'sub.noBuilds': 'No builds on App Store Connect yet: upload one first (storebound apple upload).',
'sub.unresolvedText': 'App Review sent this submission back. Fix what it asked for, upload a fixed build, pick it here and resubmit: the rejected version item is marked resolved and the same submission goes back.',
'sub.resolveIaps': 'Also mark rejected in-app purchase items resolved',
'sub.resolveIapsNeeded': '(an in-app purchase item is rejected: Apple takes no resubmit until it is resolved)',
'sub.resubmit': 'Resubmit',
'sub.resubmitted': 'Resubmitted to App Review',
'sub.resubmitLead': 'Attaches the build you picked, reuses the submission back from App Review (no second draft), marks the rejected items resolved and resubmits it.',
'sub.submit': 'Submit for review',
'sub.submitted': 'Sent to App Review',
'sub.submitLead': 'Attaches the build you picked to the version, reuses the open submission (a new draft only when none is open), adds the in-app purchases that are ready and sends it to App Review.',
'sub.pickFirst': 'Pick a build first',
'sub.iapTitle': 'In-app purchases ({count} in the config)',
'sub.iapTitle_one': 'In-app purchases (1 in the config)',
'sub.iapText': 'Sends the products and subscriptions that are ready to App Review. The first of a type needs the app version in the same submission.',
'sub.withVersion': 'With the editable app version (submits the app too)',
'sub.iapSubmit': 'Submit in-app purchases',
'sub.iapLead': 'Puts the ready products and subscriptions into the open review submission (reused, never a second draft) and submits it.',
'sub.playTitle': 'Google Play: production release',
'sub.playSub': 'Not read yet: read the tracks',
'sub.playRead': 'Google Play tracks',
'sub.playNotReadText': 'Read the tracks and their versionCodes from Google Play (read-only) before you send one to production.',
'sub.managedOff': 'Managed publishing is off: once Google approves the release it goes live by itself. To choose the moment, turn on Publishing overview > Managed publishing first.',
'sub.managedUnset': 'Managed publishing is off by default (play.managedPublishing is not set): once Google approves the release it goes live by itself.',
'sub.managedOn': 'Managed publishing is on: after approval you publish the release in Publishing overview.',
'sub.prodNow': 'Production now',
'sub.versionCode': 'versionCode (by number)',
'sub.pickCode': 'Pick the versionCode',
'sub.inProduction': 'in production',
'sub.as': 'Send as',
'sub.sCompleted': 'Everyone',
'sub.sStaged': 'Staged rollout',
'sub.sDraft': 'Draft',
'sub.codeHint': 'Only versionCodes already on Play (a testing track). Pick the one you uploaded: storebound never takes "the latest".',
'sub.noCodes': 'No versionCode on Play yet: upload the bundle to a testing track first.',
'sub.playSend': 'Send to production',
'sub.playDraft': 'Prepare a production draft',
'sub.pickCodeFirst': 'Pick a versionCode first',
'sub.playSent': 'Production release sent',
'sub.playDrafted': 'Production draft saved',
'sub.playLead': 'Puts the versionCode on the production track with the What\'s new of every language. Google reviews it; with managed publishing off it then goes live by itself.',
'sub.playDraftLead': 'A never-published app: Play takes only a draft through the API. The rest (countries, Send changes for review) is in the console steps below.',
'sub.sendChanges': 'Send changes for review',
'sub.consoleStepTitle': 'No API for these Play steps',
'sub.consoleStepText': 'Production countries ({countries}) and Publishing overview > "Send changes for review" have no API. Each has a plan: open the page, follow the steps, and press the final button yourself.',
'sub.countriesAll': 'config: all countries / regions',
'sub.countriesN': 'config: {count} countries',
'sub.countriesN_one': 'config: 1 country',
'tf.groupsSync': 'Create missing groups',
'tf.groupsSynced': 'TestFlight groups created',
'tf.groupsSyncHint': 'Creates the groups of apple.testflight.groups that are missing (existing ones are never changed); the dry run lists them first',
'act.priceProducts': 'Price changes (products)',
'act.priceSubs': 'Price changes (subscriptions)',
'act.priceDone': 'Price changes applied',
'act.priceLead': 'A normal sync never changes the price of a live product: it holds those back for you. This runs the same sync with the price changes, after the plan and your OK in the system dialog.',
'act.priceHint': 'Price changes of existing products are your decision: plan first, then a system dialog',
'deps.sdkTitle': 'Android SDK',
'deps.sdkSub': 'Where play build finds it: ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir in local.properties, then the Android Studio folder',
'deps.sdkPath': 'Folder',
'deps.sdkFrom': 'Found through',
'deps.sdkNone': 'Not found. play build needs it (install Android Studio, or set ANDROID_HOME). Checked:',
'ai.dsFrom': 'from {source}',
} as const;

View file

@ -229,7 +229,6 @@ export const tr: Record<keyof typeof en, string> = {
'releases.noVersionsText': 'İlk derleme yüklendiğinde burada görünür.',
'releases.history': 'Mağazalardaki sürümler',
'releases.historySub': '{when} okundu',
'releases.consoleNote': 'storebound hiçbir zaman incelemeye göndermez ve onaylanmış bir sürümü yayınlamaz: bunları mağaza konsolunda yapın. Dağıtım ve aşamalı yayın değişiklikleri sizin kararınızdır, her biri bir sistem penceresinde onaylanır.',
'cat.products': 'Tek seferlik ürünler',
'cat.productsSub': 'Ayar dosyasında',
@ -1318,11 +1317,165 @@ export const tr: Record<keyof typeof en, string> = {
'cf.cancelRun': 'Vazgeç: tarayıcıyı kapat',
'cf.open': 'Tarayıcıyı aç',
'cf.waiting': 'Tarayıcıyı kapatmanız bekleniyor',
'cf.playTitle': 'Play Console formları',
'cf.playSub': 'API yok: storebound bunları konsolda doldurur, son düğmeye siz basarsınız',
'cf.playContentRatingText': 'Uygulamanın yaş derecelendirmelerini belirleyen IARC anketi.',
'cf.playTargetAudienceText': 'Hedef yaş grupları ve uygulamanın çocuklara hitap edip etmediği.',
'cf.playAppRecordText': 'Play Developer API uygulama oluşturamaz: storebound Uygulama oluştur formunu doldurur (ad, dil, uygulama veya oyun, ücretsiz veya ücretli). Beyanları siz işaretler ve Uygulama oluştur\'a siz basarsınız; paket adını ilk AAB yüklemesi belirler.',
'cf.playAppRecordText': 'Play Developer API uygulama oluşturamaz: storebound Uygulama oluştur formunu doldurur (ad, paket adı, dil, uygulama veya oyun, ücretsiz veya ücretli). Beyanları siz işaretler ve Uygulama oluştur\'a siz basarsınız, ardından uygulamayı hizmet hesabına eklersiniz.',
'cf.noRecordTitle': 'Henüz App Store uygulama kaydı yok',
'cf.noRecordText': 'App Store Connect\'te uygulama kaydı oluşturmak için bir API yok. storebound Yeni Uygulama formunu yapılandırmanızdan doldurur; Oluştur\'a kendiniz basar, ardından Apple kimliğini apple.appId alanına yazarsınız.',
// send for review, console plans, owner actions (desktop parity)
'write.declined': 'Kararınız bekleniyor',
'toast.log': 'Günlük ({count} satır)',
'releases.ownerNote': 'İncelemeye gönderme, yeniden gönderme ve üretim sürümü sizin kararınızdır: her biri önce planını gösterir, sonra bir sistem penceresi açılır ve yalnızca oradaki düğmeye bastığınızda çalışır. Onaylanmış bir App Store sürümünü yayınlamak (elle yayın) App Store Connect\'te kalır.',
'oa.planFailed': 'Hazırlanamadı: {what}',
'oa.notSent': 'Pencerede İptal\'i seçtiniz: hiçbir şey gönderilmedi.',
'oa.dryChanged': 'Deneme çalıştırması bu kez geçmedi (mağaza arada değişti mi?): hiçbir şey gönderilmedi. Aşağıdaki planı kontrol edin.',
'oa.cancelled': 'Durduruldu. Mağazanın aldığı kısım kalır; devam etmeden önce durumu yeniden okuyun.',
'oa.dryBad': 'Deneme çalıştırması durdu',
'oa.plan': 'Plan (deneme çalıştırması, henüz bir şey gönderilmedi)',
'oa.noOutput': '(deneme çalıştırması bir şey yazmadı)',
'oa.running': 'Çalışıyor. Bir derleme eklendikten sonra Apple birkaç dakika "henüz hazır değil" diyebilir; storebound bir nabız satırıyla 10 dakikaya kadar yeniden dener.',
'oa.progress': 'İlerleme',
'oa.foot': 'Sonraki adım: bir sistem penceresi bu planı yeniden gösterir. Onun düğmesine basmadan hiçbir şey gönderilmez.',
'oa.fixFirst': 'Önce planın belirttiğini düzeltin, sonra durumu yeniden okuyun.',
'oa.stop': 'Durdur',
'oa.continue': 'Devam: {what}',
'oa.working': 'Çalışıyor',
'cp.title': 'API\'si olmayan konsol adımları',
'cp.sub': 'Her form, yapay zekâ aracınızın (veya sizin) konsolda izlediği bir plan ve sonucun kontrolü olarak. Son düğme sizindir.',
'cp.button': 'Plan',
'cp.hint': '{form} için adımlar, yanıtlar ve düğmeler, ve sonucun kontrolü',
'cp.failed': '{form} için plan oluşturulamadı',
'cp.agentFirst': 'Önce ajan: yapay zekâ aracınız sayfayı bu planla yönetir; storebound son düğmeye asla basmaz',
'cp.openPage': 'Sayfayı aç',
'cp.openFailed': 'Sayfa açılamadı',
'cp.steps': '{count} adım',
'cp.steps_one': '1 adım',
'cp.answers': 'Yapılandırmanızdan {count} yanıt',
'cp.answers_one': 'Yapılandırmanızdan 1 yanıt',
'cp.sCertain': 'Yapılandırmadan',
'cp.sAsk': 'Siz yanıtlarsınız',
'cp.sCheck': 'Siz kontrol edersiniz',
'cp.sAgreement': 'Sözleşme: sizin',
'cp.final': 'Son düğme ({button}) sizindir. Başka kimsenin asla basmadığı düğmeler:',
'cp.verify': 'Sonucu kontrol et',
'cp.verifyApi': 'storebound sonucu App Store Connect API\'sinden okur (salt okunur).',
'cp.verifyText': 'Form kaydedildikten sonra sayfa metnini (veya planın belirttiği yakalama JSON\'unu) yapıştırın; storebound yapılandırmayla karşılaştırır (salt okunur).',
'cp.pasteLabel': 'Tamamlanan formun sayfa metni',
'cp.pastePlaceholder': 'Sayfa metnini buraya yapıştırın',
'cp.verified': '{matched} eşleşiyor, {mismatches} farklı',
'cp.vMatch': 'Eşleşiyor',
'cp.vMismatch': 'Farklı',
'cp.vNotFound': 'Bulunamadı',
'cp.foot': 'Planı kopyala, tüm planı (JSON) yapay zekâ aracınıza verir.',
'cp.copy': 'Planı kopyala',
'cp.copied': 'Plan kopyalandı',
'cp.verifyRun': 'Kontrol et',
'cp.appleAppRecordText': 'App Store Connect\'te uygulama kaydı oluşturacak API yok: Yeni Uygulama formu, Oluştur sizin.',
'cp.appleAgeRatingText': 'Yaş derecelendirmesinin API\'si var (apple appinfo): plan yalnızca nereye bakılacağını ve nasıl kontrol edileceğini gösterir.',
'cp.appleAppPrivacyText': 'API yok: her veri türü amaçları, bağlantı ve izleme yanıtlarıyla; Yayınla sizin.',
'cp.playStoreSettings': 'Play mağaza ayarları (Uygulama kategorisi)',
'cp.playStoreSettingsText': 'Play API\'si Uygulama kategorisini ayarlayamaz: Düzenle > Uygulama veya oyun > Kategori > Kaydet.',
'cp.playAppContent': 'Play Uygulama içeriği (tüm kartlar)',
'cp.playAppContentText': 'Her Uygulama içeriği kartı sayfasıyla sırayla, sahibin soruları tek seferde.',
'cp.playPrivacyPolicy': 'Play Uygulama içeriği: gizlilik politikası',
'cp.playAds': 'Play Uygulama içeriği: reklamlar',
'cp.playSignIn': 'Play Uygulama içeriği: oturum açma bilgileri',
'cp.playSignInText': 'Google incelemecileri için demo hesap: siz yazarsınız (storebound onu asla yazdırmaz).',
'cp.playAdId': 'Play Uygulama içeriği: reklam kimliği',
'cp.playGovernment': 'Play Uygulama içeriği: devlet uygulamaları',
'cp.playFinancial': 'Play Uygulama içeriği: finansal özellikler',
'cp.playHealth': 'Play Uygulama içeriği: sağlık uygulamaları',
'cp.cardText': 'Tek bir Uygulama içeriği kartı: sayfası, yapılandırmanızdaki yanıt ve son Kaydet.',
'cp.playCountries': 'Üretim ülkeleri',
'cp.playCountriesText': 'API üretim ülkelerini ayarlayamaz (countryAvailability salt okunur): konsolda seçin.',
'cp.playProductionRelease': 'Play: üretim sürümünü incelemeye gönder',
'cp.playProductionReleaseText': 'Sürümü düzenle > Sonraki > Kaydet, ardından Yayınlama özeti. "Değişiklikleri incelemeye gönder"in API\'si yok: o tıklama sizin.',
'sub.section': 'İncelemeye gönder',
'sub.appleTitle': 'App Store: incelemeye gönder',
'sub.appleSub': 'Henüz okunmadı: gönderim durumunu okuyun',
'sub.appleRead': 'App Store gönderim durumu',
'sub.read': 'Durumu oku',
'sub.notRead': 'Gönderim durumu henüz okunmadı',
'sub.notReadText': 'Göndermeden önce inceleme gönderimlerini ve yüklenen derlemeleri App Store Connect\'ten okuyun (salt okunur).',
'sub.submissions': 'İnceleme gönderimleri',
'sub.kWaiting': 'İnceleme bekliyor',
'sub.kInReview': 'İncelemede',
'sub.kUnresolved': 'App Review\'dan geri döndü (çözülmemiş sorunlar)',
'sub.kDraft': 'Taslak gönderim',
'sub.kEmptyDraft': 'Boş taslak',
'sub.kDone': 'Tamamlandı',
'sub.kOther': 'Gönderim',
'sub.harmless': 'zararsız: App Store Connect onu tutar ve iptal edemez; sonraki bir çalıştırma onu yeniden kullanır',
'sub.markVersion': 'yeni bir derlemeyle yeniden gönderdiğinizde çözüldü olarak işaretlenir',
'sub.markIap': 'App Review\'un istediğini düzelttikten sonra yalnızca aşağıdaki kutuyla çözüldü olarak işaretlenir',
'sub.noOpen': 'Açık inceleme gönderimi yok: gönderme bir tane oluşturur.',
'sub.busy': 'Bir gönderim {state} durumunda: App Review bitene kadar yanına bir şey gönderilemez.',
'sub.build': 'Derleme (numarayla)',
'sub.pickBuild': 'Yüklediğiniz derlemeyi seçin',
'sub.attachedTo': '{version} sürümüne ekli',
'sub.version': 'Sürüm',
'sub.noVersion': 'Düzenlenebilir sürüm yok',
'sub.buildHint': 'Yalnızca VALID derlemeler girebilir. Yüklediğiniz numarayı seçin: storebound asla "en son"u almaz.',
'sub.noBuilds': 'App Store Connect\'te henüz derleme yok: önce bir tane yükleyin (storebound apple upload).',
'sub.unresolvedText': 'App Review bu gönderimi geri gönderdi. İstediğini düzeltin, düzeltilmiş bir derleme yükleyin, burada seçin ve yeniden gönderin: reddedilen sürüm öğesi çözüldü olarak işaretlenir ve aynı gönderim geri gider.',
'sub.resolveIaps': 'Reddedilen uygulama içi satın alma öğelerini de çözüldü olarak işaretle',
'sub.resolveIapsNeeded': '(bir uygulama içi satın alma öğesi reddedildi: çözülene kadar Apple yeniden gönderimi kabul etmez)',
'sub.resubmit': 'Yeniden gönder',
'sub.resubmitted': 'App Review\'a yeniden gönderildi',
'sub.resubmitLead': 'Seçtiğiniz derlemeyi ekler, App Review\'dan dönen gönderimi yeniden kullanır (ikinci taslak yok), reddedilen öğeleri çözüldü olarak işaretler ve yeniden gönderir.',
'sub.submit': 'İncelemeye gönder',
'sub.submitted': 'App Review\'a gönderildi',
'sub.submitLead': 'Seçtiğiniz derlemeyi sürüme ekler, açık gönderimi yeniden kullanır (yalnızca açık gönderim yoksa yeni taslak), hazır uygulama içi satın almaları ekler ve App Review\'a gönderir.',
'sub.pickFirst': 'Önce bir derleme seçin',
'sub.iapTitle': 'Uygulama içi satın almalar (yapılandırmada {count})',
'sub.iapTitle_one': 'Uygulama içi satın almalar (yapılandırmada 1)',
'sub.iapText': 'Hazır ürünleri ve abonelikleri App Review\'a gönderir. Bir türün ilki, aynı gönderimde uygulama sürümüne ihtiyaç duyar.',
'sub.withVersion': 'Düzenlenebilir uygulama sürümüyle (uygulamayı da gönderir)',
'sub.iapSubmit': 'Uygulama içi satın almaları gönder',
'sub.iapLead': 'Hazır ürünleri ve abonelikleri açık inceleme gönderimine koyar (yeniden kullanılır, asla ikinci taslak yok) ve gönderir.',
'sub.playTitle': 'Google Play: üretim sürümü',
'sub.playSub': 'Henüz okunmadı: kanalları okuyun',
'sub.playRead': 'Google Play kanalları',
'sub.playNotReadText': 'Birini üretime göndermeden önce kanalları ve versionCode\'larını Google Play\'den okuyun (salt okunur).',
'sub.managedOff': 'Yönetilen yayınlama kapalı: Google sürümü onayladığında kendiliğinden yayına girer. Anı seçmek için önce Yayınlama özeti > Yönetilen yayınlama\'yı açın.',
'sub.managedUnset': 'Yönetilen yayınlama varsayılan olarak kapalı (play.managedPublishing ayarlı değil): Google sürümü onayladığında kendiliğinden yayına girer.',
'sub.managedOn': 'Yönetilen yayınlama açık: onaydan sonra sürümü Yayınlama özeti\'nde siz yayınlarsınız.',
'sub.prodNow': 'Şu an üretimde',
'sub.versionCode': 'versionCode (numarayla)',
'sub.pickCode': 'versionCode seçin',
'sub.inProduction': 'üretimde',
'sub.as': 'Şöyle gönder',
'sub.sCompleted': 'Herkese',
'sub.sStaged': 'Aşamalı dağıtım',
'sub.sDraft': 'Taslak',
'sub.codeHint': 'Yalnızca Play\'de (bir test kanalında) olan versionCode\'lar. Yüklediğinizi seçin: storebound asla "en son"u almaz.',
'sub.noCodes': 'Play\'de henüz versionCode yok: önce paketi bir test kanalına yükleyin.',
'sub.playSend': 'Üretime gönder',
'sub.playDraft': 'Üretim taslağı hazırla',
'sub.pickCodeFirst': 'Önce bir versionCode seçin',
'sub.playSent': 'Üretim sürümü gönderildi',
'sub.playDrafted': 'Üretim taslağı kaydedildi',
'sub.playLead': 'versionCode\'u her dilin Yenilikler metniyle üretim kanalına koyar. Google inceler; yönetilen yayınlama kapalıysa ardından kendiliğinden yayına girer.',
'sub.playDraftLead': 'Hiç yayınlanmamış bir uygulama: Play API üzerinden yalnızca taslak kabul eder. Gerisi (ülkeler, Değişiklikleri incelemeye gönder) aşağıdaki konsol adımlarındadır.',
'sub.sendChanges': 'Değişiklikleri incelemeye gönder',
'sub.consoleStepTitle': 'Bu Play adımlarının API\'si yok',
'sub.consoleStepText': 'Üretim ülkeleri ({countries}) ve Yayınlama özeti > "Değişiklikleri incelemeye gönder"in API\'si yok. Her birinin bir planı var: sayfayı açın, adımları izleyin ve son düğmeye kendiniz basın.',
'sub.countriesAll': 'yapılandırma: tüm ülkeler / bölgeler',
'sub.countriesN': 'yapılandırma: {count} ülke',
'sub.countriesN_one': 'yapılandırma: 1 ülke',
'tf.groupsSync': 'Eksik grupları oluştur',
'tf.groupsSynced': 'TestFlight grupları oluşturuldu',
'tf.groupsSyncHint': 'apple.testflight.groups içindeki eksik grupları oluşturur (var olanlar asla değiştirilmez); deneme çalıştırması önce onları listeler',
'act.priceProducts': 'Fiyat değişiklikleri (ürünler)',
'act.priceSubs': 'Fiyat değişiklikleri (abonelikler)',
'act.priceDone': 'Fiyat değişiklikleri uygulandı',
'act.priceLead': 'Normal bir eşitleme yayındaki bir ürünün fiyatını asla değiştirmez: bunları sizin için bekletir. Bu, aynı eşitlemeyi fiyat değişiklikleriyle, plandan ve sistem penceresindeki onayınızdan sonra çalıştırır.',
'act.priceHint': 'Mevcut ürünlerin fiyat değişiklikleri sizin kararınızdır: önce plan, sonra bir sistem penceresi',
'deps.sdkTitle': 'Android SDK',
'deps.sdkSub': 'play build onu şuralarda bulur: ANDROID_HOME, ANDROID_SDK_ROOT, local.properties içindeki sdk.dir, sonra Android Studio klasörü',
'deps.sdkPath': 'Klasör',
'deps.sdkFrom': 'Bulunduğu yer',
'deps.sdkNone': 'Bulunamadı. play build buna ihtiyaç duyar (Android Studio\'yu kurun veya ANDROID_HOME ayarlayın). Bakılan yerler:',
'ai.dsFrom': 'kaynak: {source}',
};

View file

@ -0,0 +1,18 @@
// Progress from the command's own lines, so a job toast shows a real bar while an upload runs: `apple upload` prints
// "still uploading, 4m30s, 312/890 MB (35%), part 4/12" (cli/lib/progress.ts share()), part lines "part 3/12", and
// render / capture lines "[12/48]" or "12/48". Pure; null when a line says nothing about progress.
export interface LineProgress { done: number; total: number }
export function progressOf(line: string): LineProgress | null {
const mb = /(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*MB\b/.exec(line);
if (mb) { const done = Number(mb[1]), total = Number(mb[2]); if (total > 0 && done <= total) return { done: Math.round(done * 1048576), total: Math.round(total * 1048576) }; }
const pct = /\((\d{1,3})%\)/.exec(line);
if (pct && Number(pct[1]) <= 100) return { done: Number(pct[1]), total: 100 };
const part = /\b(?:part|item|image|shot|frame)s?\s+(\d+)\s*\/\s*(\d+)\b/i.exec(line) ?? /\[(\d+)\s*\/\s*(\d+)\]/.exec(line);
if (part) { const done = Number(part[1]), total = Number(part[2]); if (total > 0 && done <= total) return { done, total }; }
return null;
}
/** The last lines of a job, kept for its toast's log (newest last, at most `max`). */
export const keepLines = (lines: string[] | undefined, line: string, max = 40): string[] => [...(lines ?? []), line].slice(-max);

View file

@ -0,0 +1,102 @@
// Submission state for the Releases tab, from `apple status --json` (apple.status) and `play status --json`
// (play.status): open review submissions with their items (UNRESOLVED_ISSUES, waiting, empty drafts that are
// harmless), the uploaded builds / versionCodes the owner picks by number (never "the latest"), and the editable
// version. Pure, so tests/submit.test.ts pins it; every field is read defensively (older sidecars, odd answers).
type J = Record<string, unknown>;
const obj = (x: unknown): J => (x && typeof x === 'object' && !Array.isArray(x) ? x as J : {});
const arr = (x: unknown): unknown[] => (Array.isArray(x) ? x : []);
const str = (x: unknown): string => (typeof x === 'string' ? x : typeof x === 'number' ? String(x) : '');
/** `apple status --json`, the parts used here. */
export interface AppleStatusData {
versions?: unknown[]; builds?: unknown[]; attachedBuilds?: unknown[]; buildVersions?: Record<string, string>;
subs?: unknown[]; unresolved?: { id: string; items: { id?: string; kind: string; versionId: string; state: string }[] }[]; emptyDrafts?: string[];
}
export type SubmissionKind = 'waiting' | 'inReview' | 'unresolved' | 'draft' | 'emptyDraft' | 'done' | 'other';
export interface SubmissionItem { id: string; kind: string; label: string; state: string; rejected: boolean }
export interface SubmissionView { id: string; state: string; kind: SubmissionKind; date: string | null; items: SubmissionItem[] }
export interface BuildChoice { id: string; number: string; version: string | null; state: string; uploaded: string | null; valid: boolean; attachedTo: string | null }
export interface AppleVersion { id: string; version: string; state: string; build: string | null }
export interface AppleSubmitState {
submissions: SubmissionView[];
/** The first submission back from App Review (UNRESOLVED_ISSUES), with its items. */
unresolved: SubmissionView | null;
/** A submission waiting for or in review: nothing can be sent next to it. */
busy: SubmissionView | null;
builds: BuildChoice[];
/** The version a submit sends: the one an open submission holds, else the editable one. */
editable: AppleVersion | null;
versions: AppleVersion[];
}
/** App Store version states a first submission may send (cli/apple/resubmit.ts SUBMITTABLE_APP). */
export const SUBMITTABLE_APP = ['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'DEVELOPER_REJECTED'];
const KIND: Record<string, SubmissionKind> = { WAITING_FOR_REVIEW: 'waiting', IN_REVIEW: 'inReview', UNRESOLVED_ISSUES: 'unresolved', READY_FOR_REVIEW: 'draft', COMPLETE: 'done', CANCELING: 'other' };
const ITEM_NAME: Record<string, string> = { inAppPurchaseVersion: 'in-app purchase', subscriptionVersion: 'subscription', subscriptionGroupVersion: 'subscription group', appStoreVersion: 'app version' };
export function appleSubmitState(raw: unknown): AppleSubmitState {
const d = obj(raw) as AppleStatusData & J;
const versions: AppleVersion[] = arr(d.versions).map(v => {
const o = obj(v), a = obj(o.attributes);
return { id: str(o.id), version: str(a.versionString), state: str(a.appVersionState) || str(a.appStoreState), build: str(obj(obj(obj(o.relationships).build).data).id) || null };
}).filter(v => v.id && v.version);
const label = (kind: string, versionId: string): string => {
const v = versions.find(x => x.id === versionId);
return v && kind === 'appStoreVersion' ? `app version ${v.version}` : `${ITEM_NAME[kind] ?? kind} ${versionId}`;
};
const empty = new Set(arr(d.emptyDrafts).map(str));
const unresolvedItems = new Map(arr(d.unresolved).map(u => [str(obj(u).id), arr(obj(u).items)] as const));
const submissions: SubmissionView[] = arr(d.subs).map(s => {
const o = obj(s), a = obj(o.attributes), id = str(o.id), state = str(a.state);
const items = (unresolvedItems.get(id) ?? []).map(it => {
const i = obj(it), kind = str(i.kind), st = str(i.state);
return { id: str(i.id), kind, label: label(kind, str(i.versionId)), state: st, rejected: st === 'REJECTED' };
});
const kind: SubmissionKind = state === 'READY_FOR_REVIEW' && empty.has(id) ? 'emptyDraft' : KIND[state] ?? 'other';
return { id, state, kind, date: str(a.submittedDate) || null, items };
}).filter(s => s.id);
const pre = obj(d.buildVersions);
const attached = new Map(versions.filter(v => v.build).map(v => [v.build!, v.version] as const));
const seen = new Set<string>();
const builds: BuildChoice[] = [...arr(d.builds), ...arr(d.attachedBuilds)].map(b => {
const o = obj(b), a = obj(o.attributes), id = str(o.id);
return { id, number: str(a.version), version: str(pre[id]) || null, state: str(a.processingState), uploaded: str(a.uploadedDate) || null, valid: str(a.processingState) === 'VALID', attachedTo: attached.get(id) ?? null };
}).filter(b => b.id && b.number && !seen.has(b.id) && seen.add(b.id));
const unresolved = submissions.find(s => s.kind === 'unresolved') ?? null;
const busy = submissions.find(s => s.kind === 'waiting' || s.kind === 'inReview') ?? null;
const held = new Set(unresolved?.items.filter(i => i.kind === 'appStoreVersion').map(i => i.label) ?? []);
const editable = versions.find(v => held.has(`app version ${v.version}`)) ?? versions.find(v => SUBMITTABLE_APP.includes(v.state)) ?? null;
return { submissions, unresolved, busy, builds, editable, versions };
}
/** `play status --json`, the parts used here. */
export interface PlayTrackData { track: string; releases?: { name?: string; versionCodes?: (string | number)[]; status?: string; userFraction?: number }[] }
export interface VersionCodeChoice { code: string; tracks: string[]; name: string | null; inProduction: boolean }
export interface PlaySubmitState { codes: VersionCodeChoice[]; production: { status: string; codes: string[]; name: string | null; fraction: number | null }[]; category: string | null }
export function playSubmitState(raw: unknown): PlaySubmitState {
const d = obj(raw);
const byCode = new Map<string, VersionCodeChoice>();
const production: PlaySubmitState['production'] = [];
for (const tr of arr(d.tracks)) {
const t = obj(tr), track = str(t.track);
for (const r of arr(t.releases)) {
const o = obj(r), codes = arr(o.versionCodes).map(str).filter(c => /^\d+$/.test(c));
if (track === 'production') production.push({ status: str(o.status), codes, name: str(o.name) || null, fraction: typeof o.userFraction === 'number' ? o.userFraction : null });
for (const c of codes) {
const e = byCode.get(c) ?? { code: c, tracks: [], name: str(o.name) || null, inProduction: false };
if (!e.tracks.includes(track)) e.tracks.push(track);
if (track === 'production' && str(o.status) !== 'draft') e.inProduction = true;
byCode.set(c, e);
}
}
}
const codes = [...byCode.values()].sort((a, b) => Number(b.code) - Number(a.code));
return { codes, production, category: str(obj(d.category).config) || null };
}
/** The --json result of apple submit / resubmit (dry run): blockers and what it would do. */
export interface SubmitPlanData { dryRun?: boolean; version?: { versionString?: string; state?: string }; build?: { action?: string; reason?: string }; blockers?: string[]; rows?: { step: string; what: string; now: string; action: string }[] }
export const planBlockers = (data: unknown): string[] => arr(obj(data).blockers).map(str).filter(Boolean);

View file

@ -2,7 +2,7 @@
// into the app data folder in one click (checksum or signature verified, progress, cancel); the rest link out.
// Store work needs none of them. Sources and licenses: docs/desktop.md.
import { useCallback, useEffect, useState } from 'react';
import { api, type DownloadInfo, errorText, newJob, onDownload, type ToolStatus } from '../api';
import { type AndroidSdkLookup, api, type DownloadInfo, errorText, type LocalTools, newJob, onDownload, type ToolStatus } from '../api';
import { t } from '../i18n';
import { bytes } from '../lib/format';
import { shortPath } from '../paths';
@ -23,6 +23,7 @@ interface Dl { job: string; stage: string; received: number; total: number | nul
export function Dependencies() {
const d = useData();
const [tools, setTools] = useState<ToolStatus[] | null>(d.sidecar?.tools?.tools ?? null);
const [sdk, setSdk] = useState<AndroidSdkLookup | null>(d.sidecar?.tools?.androidSdk ?? null);
const [info, setInfo] = useState<DownloadInfo[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@ -31,8 +32,8 @@ export function Dependencies() {
const refresh = useCallback(async () => {
setBusy(true); setError(null);
try {
const [s, i] = await Promise.all([api.call<{ tools: ToolStatus[] }>('system.tools'), api.toolsInfo()]);
setTools(s.tools); setInfo(i);
const [s, i] = await Promise.all([api.call<LocalTools>('system.tools'), api.toolsInfo()]);
setTools(s.tools); setInfo(i); setSdk(s.androidSdk ?? null);
} catch (e) { setError(errorText(e)); } finally { setBusy(false); }
}, []);
useEffect(() => { refresh(); }, [refresh]);
@ -113,6 +114,22 @@ export function Dependencies() {
</div>
)}
</Card>
{sdk && (
<Card title={t('deps.sdkTitle')} icon="phone" sub={t('deps.sdkSub')} id="android-sdk"
actions={sdk.path ? <Chip tone="ok">{t('deps.installed')}</Chip> : <><Chip tone="warn">{t('deps.missing')}</Chip><button className="btn btn-sm" onClick={() => open('https://developer.android.com/studio')}><Icon name="external" />{t('deps.getIt')}</button></>}>
{sdk.path ? (
<dl className="kv">
<dt>{t('deps.sdkPath')}</dt><dd className="mono-id selectable" title={sdk.path}>{shortPath(sdk.path, d.info?.home)}</dd>
<dt>{t('deps.sdkFrom')}</dt><dd className="small">{sdk.source}</dd>
</dl>
) : (
<div className="stack" style={{ gap: 6 }}>
<span className="small">{t('deps.sdkNone')}</span>
<ul className="note-list small">{sdk.checked.map((c, i) => <li key={i} className="selectable">{d.info?.home ? c.split(d.info.home).join("~") : c}</li>)}</ul>
</div>
)}
</Card>
)}
<div className="grid g2">
{info.map(m => (
<Card key={m.tool} title={m.tool === 'chrome' ? 'Chrome for Testing' : `ffmpeg ${m.version ?? ''}`} icon={m.tool === 'chrome' ? 'globe' : 'image'} sub={m.installed ? t('deps.inAppData', { folder: m.folder }) : t('deps.notDownloaded')}>

View file

@ -5,14 +5,15 @@
// The forms without an API (App Privacy, age rating, the Play questionnaires, a missing App Store app record) have a
// "Fill in console" button (ui/ConsoleFill.tsx): storebound fills them in a visible browser, the owner saves.
import { useState } from 'react';
import { api, type DeclarationsResult, type DeclAsk, type Project } from '../../api';
import { api, type ConsoleForm, type DeclarationsResult, type DeclAsk, type Project } from '../../api';
import { type Key, t } from '../../i18n';
import { declarationsMd, exportName } from '../../lib/exports';
import { ago, num } from '../../lib/format';
import { useData, useRead, useWrite } from '../../store';
import { useExports, useRunExport } from '../../ui/Exports';
import { Icon, StoreGlyph } from '../../ui/icons';
import { ConsoleFillButton } from '../../ui/ConsoleFill';
import { CONSOLE_FORM_NAME, ConsoleFillButton } from '../../ui/ConsoleFill';
import { ConsolePlanButton, PLAN_FORMS } from '../../ui/ConsolePlan';
import { Bar, Card, Chip, Empty, ErrorNotice, Notice, Skeleton } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
import { ascUrl } from '../AppView';
@ -45,6 +46,25 @@ function AskCard({ ask }: { ask: DeclAsk[] }) {
);
}
/** "Can users request that their data is deleted?" (PSL_SUPPORT_DATA_DELETION_BY_USER) as the CSV answers it: a
* delete-account page means yes; else dataSafety.dataDeletion. An older sidecar sends only partialDeletion. */
export function deletionView(ds: NonNullable<DeclarationsResult['dataSafety']>): { answer: 'yes' | 'no' | 'auto90' | null; source: string | null; url: string | null } {
if (ds.dataDeletion !== undefined) return { answer: ds.dataDeletion ?? null, source: ds.dataDeletionSource ?? null, url: ds.dataDeletionUrl ?? null };
return { answer: ds.partialDeletion === 'auto90' ? 'auto90' : ds.partialDeletion === true ? 'yes' : ds.partialDeletion === false ? 'no' : null, source: ds.partialDeletion === null ? null : 'dataSafety.partialDeletion', url: null };
}
function DeletionAnswer({ ds }: { ds: NonNullable<DeclarationsResult['dataSafety']> }) {
const v = deletionView(ds);
if (!v.answer) return <Chip tone="warn" icon="question">{t('ai.notAnswered')}</Chip>;
return (
<span className="stack" style={{ gap: 2 }}>
<span>{v.answer === 'auto90' ? t('ai.dsAuto90') : v.answer === 'yes' ? t('common.yes') : t('common.no')}</span>
{v.source && <span className="small muted">{t('ai.dsFrom', { source: v.source })}</span>}
{v.url && <span className="small muted selectable mono-id">{v.url}</span>}
</span>
);
}
function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsResult; onDone: () => void }) {
const w = useWrite(project.id);
const run = useRunExport();
@ -64,7 +84,7 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR
<dl className="kv">
<dt>{t('ai.dsCollects')}</dt><dd>{yes(ds.collectsData)}</dd>
<dt>{t('ai.dsEncrypted')}</dt><dd>{yes(ds.encryptedInTransit)}</dd>
{!ds.accountCreation.includes('PSL_ACM_NONE') && <><dt>{t('ai.dsDeletion')}</dt><dd>{ds.partialDeletion === 'auto90' ? t('ai.dsAuto90') : yes(ds.partialDeletion)}</dd></>}
{!ds.accountCreation.includes('PSL_ACM_NONE') && <><dt>{t('ai.dsDeletion')}</dt><dd><DeletionAnswer ds={ds} /></dd></>}
<dt>{t('ai.dsAccounts')}</dt><dd className="small">{ds.accountCreation.map(x => typeLabel(x.replace('ACM_', ''))).join(', ')}</dd>
</dl>
{up === null ? <Notice tone="info" icon="info">{t('ai.dsNever')}</Notice>
@ -125,6 +145,29 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe
);
}
/** Every console form without an API, per store: its plan (agent-first: steps, answers, never-press, verify) and,
* where storebound has the scripted fast path, "Fill in console". */
function ConsoleSteps({ project, stores }: { project: Project; stores: DeclarationsResult['stores'] }) {
const forms = PLAN_FORMS.filter(f => (f.store === 'apple' ? stores.apple : stores.play));
if (!forms.length) return null;
return (
<Card title={t('cp.title')} icon="clipboard" sub={t('cp.sub')} pad={false} id="console-steps">
<div className="list">
{forms.map(f => (
<div className="li" key={f.form} data-form={f.form}>
<span className="li-icon tone-bg-info"><StoreGlyph store={f.store === 'apple' ? 'ios' : 'android'} /></span>
<div className="li-main"><span className="li-title">{t(f.name)}</span><span className="li-sub">{t(f.text)}</span></div>
<div className="actions" style={{ flexWrap: 'nowrap' }}>
<ConsolePlanButton project={project} form={f.form} />
{f.form in CONSOLE_FORM_NAME && <ConsoleFillButton project={project} form={f.form as ConsoleForm} />}
</div>
</div>
))}
</div>
</Card>
);
}
export function AppInfoTab({ project }: { project: Project }) {
const data = useData();
const e = data.statusOf(project);
@ -192,19 +235,8 @@ export function AppInfoTab({ project }: { project: Project }) {
</Card>
</div>
)}
{d.stores.play && (
<Card title={t('cf.playTitle')} icon="clipboard" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('cf.playSub')}</span>} pad={false}>
<div className="list">
{([['play-app-record', 'cf.playAppRecord', 'cf.playAppRecordText'], ['play-content-rating', 'cf.playContentRating', 'cf.playContentRatingText'], ['play-target-audience', 'cf.playTargetAudience', 'cf.playTargetAudienceText']] as const).map(([form, title, text]) => (
<div className="li" key={form}>
<span className="li-icon tone-bg-info"><Icon name="clipboard" /></span>
<div className="li-main"><span className="li-title">{t(title)}</span><span className="li-sub">{t(text)}</span></div>
<ConsoleFillButton project={project} form={form} />
</div>
))}
</div>
</Card>
)}
<ConsoleSteps project={project} stores={d.stores} />
<p className="small muted">{t('ai.foot', { when: ago(r.at) })}</p>
</>
);

View file

@ -6,6 +6,7 @@ import { ago, num } from '../../lib/format';
import { type CatalogDiff, type CellState, parityMatrix } from '../../lib/model';
import { useData, useRead, useWrite } from '../../store';
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
import { OwnerAction } from '../../ui/OwnerAction';
import { WriteButton } from '../../ui/WriteButton';
import { Card, Chip, Empty, ErrorNotice, Kpi, Skeleton } from '../../ui/kit';
import { catalogMd, exportName } from '../../lib/exports';
@ -39,6 +40,8 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
<WriteButton run={w} icon="tag" disabled={!products} w={{ method: 'play.products', args: {}, title: t('act.playProducts'), done: t('act.synced'), after: refresh }}>{t('act.playProducts')}</WriteButton>
<WriteButton run={w} icon="refresh" disabled={!subs} w={{ method: 'play.subscriptions', args: {}, title: t('act.playSubs'), done: t('act.synced'), after: refresh }}>{t('act.playSubs')}</WriteButton>
{products > 0 && <OwnerAction project={project} method="owner.prices_play_products" args={{}} label={t('act.priceProducts')} icon="coins" done={t('act.priceDone')} lead={t('act.priceLead')} hint={t('act.priceHint')} after={refresh} />}
{subs > 0 && <OwnerAction project={project} method="owner.prices_play_subscriptions" args={{}} label={t('act.priceSubs')} icon="coins" done={t('act.priceDone')} lead={t('act.priceLead')} hint={t('act.priceHint')} after={refresh} />}
</div>
)}
{ios && (
@ -46,6 +49,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
<WriteButton run={w} icon="tag" disabled={!products} w={{ method: 'apple.iap', args: {}, title: t('act.appleIap'), done: t('act.synced'), after: refresh }}>{t('act.appleIap')}</WriteButton>
<WriteButton run={w} icon="refresh" disabled={!subs} w={{ method: 'apple.subscriptions', args: {}, title: t('act.appleSubs'), done: t('act.synced'), after: refresh }}>{t('act.appleSubs')}</WriteButton>
{subs > 0 && <OwnerAction project={project} method="owner.prices_apple_subscriptions" args={{}} label={t('act.priceSubs')} icon="coins" done={t('act.priceDone')} lead={t('act.priceLead')} hint={t('act.priceHint')} after={refresh} />}
</div>
)}
<div className="act-col">

View file

@ -1,7 +1,9 @@
// Releases: the release timeline (one version on both stores on one track: build -> testing -> review -> live ->
// rollout %), then every version read from the stores. Submit and release stay in the consoles.
// rollout %), then every version read from the stores, then sending for review (Submit.tsx): App Store submit /
// resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
import { useState } from 'react';
import { api, type ReleaseTrain, type StoreRelease } from '../../api';
import type { ReleaseTrain, StoreRelease } from '../../api';
import type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton';
import { type Key, t } from '../../i18n';
@ -11,7 +13,7 @@ import { useData, useRead, useWrite } from '../../store';
import { Icon, StoreGlyph } from '../../ui/icons';
import { RolloutBar } from '../../ui/charts';
import { Card, Chip, Empty, Notice, PhaseChip, storeName } from '../../ui/kit';
import { ascUrl, PLAY_CONSOLE } from '../AppView';
import { AppleSubmitCard, PlayProductionCard } from './Submit';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
@ -121,16 +123,29 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
const l = d.liveFor(app);
const project = app.project;
const tf = useRead<TestFlightStatus>(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight', skip: !app.ios && !d.statusFor(app)?.app?.platforms.ios });
const open = (url: string) => { api.openExternal(url).catch(() => {}); };
const sum = project ? d.statusOf(project)?.summary : null;
const submit = project && (sum?.apple || sum?.play) ? (
<section className="stack" aria-labelledby="sub-section" data-section="submit">
<h2 id="sub-section" className="section-h row" style={{ gap: 8 }}><Icon name="send" />{t('sub.section')}</h2>
<div className="grid g2" style={{ alignItems: 'start' }}>
{sum?.apple && <AppleSubmitCard app={app} project={project} />}
{sum?.play && <PlayProductionCard app={app} project={project} />}
</div>
<p className="small muted">{t('releases.ownerNote')}</p>
</section>
) : null;
if (!l) {
return (
<>
<Card pad={false}>
<Empty illo="cloud" title={t('releases.notReadTitle')} actions={<>
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
{project && !project.playAccount && !project.appleAccount && <span className="small muted">{t('releases.noAccounts')}</span>}
</>}>{t('releases.notReadText')}</Empty>
</Card>
{submit}
</>
);
}
const history = versionHistory(l.release.all ?? []);
@ -164,10 +179,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
) : <Empty compact illo="unknown" title={t('releases.noVersions')} />}
</Card>
<Notice tone="info" icon="shield" action={<div className="actions">
{l.app?.platforms.ios && <button className="btn btn-sm" onClick={() => open(ascUrl(l.app?.platforms.ios?.ascAppId))}><Icon name="external" />App Store Connect</button>}
{l.app?.platforms.android && <button className="btn btn-sm" onClick={() => open(PLAY_CONSOLE)}><Icon name="external" />Play Console</button>}
</div>}>{t('releases.consoleNote')}</Notice>
{submit}
</>
);
}

View file

@ -0,0 +1,197 @@
// Releases > send for review: the owner's buttons, each a plan first (ui/OwnerAction.tsx), then a native dialog, then
// the host-minted token. App Store: the submission state from `apple status` (waiting for / in review, back from App
// Review with UNRESOLVED_ISSUES and its items, empty drafts labelled harmless), the uploaded builds to pick by number
// (never "the latest"), Submit for review (`apple submit --build n`), Resubmit after a rejection (`apple resubmit
// --build n [--resolve-iaps]`) and in-app purchases to review (`apple iap-submit [--with-version]`). Google Play: the
// versionCodes on Play to pick by number and Send to production (`play release --version-code n --production ...`),
// the managed-publishing warning, and the console steps Play has no API for (production countries, "Send changes for
// review" in Publishing overview) as console plans with a deep link.
import { useState } from 'react';
import { api, type Project } from '../../api';
import { type Key, t } from '../../i18n';
import { ago, date } from '../../lib/format';
import { type AppleStatusData, appleSubmitState, type PlayTrackData, playSubmitState, type SubmissionKind } from '../../lib/submit';
import type { UnifiedApp } from '../../lib/identity';
import { useData, useRead } from '../../store';
import { ConsolePlanButton } from '../../ui/ConsolePlan';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, Notice, Seg, Skeleton } from '../../ui/kit';
import { OwnerAction } from '../../ui/OwnerAction';
import { ascUrl, PLAY_CONSOLE } from '../AppView';
const SUB_KIND: Record<SubmissionKind, ['ok' | 'warn' | 'bad' | 'info' | 'neutral', Key]> = {
waiting: ['info', 'sub.kWaiting'], inReview: ['info', 'sub.kInReview'], unresolved: ['bad', 'sub.kUnresolved'], draft: ['neutral', 'sub.kDraft'],
emptyDraft: ['neutral', 'sub.kEmptyDraft'], done: ['ok', 'sub.kDone'], other: ['neutral', 'sub.kOther'],
};
/** The App Store card: submission state, build picker, Submit / Resubmit / in-app purchases. */
export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Project }) {
const d = useData();
const r = useRead<AppleStatusData>(project.id, 'apple.status', { network: true, title: t('sub.appleRead'), appName: app.name });
const s = r.data ? appleSubmitState(r.data) : null;
const [build, setBuild] = useState('');
const [resolveIaps, setResolveIaps] = useState(false);
const [withVersion, setWithVersion] = useState(false);
const summary = d.statusOf(project)?.summary;
const iaps = summary?.catalog.ios.length ?? 0;
const picked = s?.builds.find(b => b.number === build && b.valid) ?? null;
const version = s?.unresolved ? s.versions.find(v => s.unresolved!.items.some(i => i.label === `app version ${v.version}`)) ?? s.editable : s?.editable ?? null;
const rejectedIap = s?.unresolved?.items.some(i => i.rejected && i.kind !== 'appStoreVersion') ?? false;
const args = { build, ...(version ? { version: version.version } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) };
const refresh = () => { r.refresh(); };
return (
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.appleSub')}</span>}
actions={<>
<button className="btn btn-sm" onClick={refresh} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(ascUrl(app.ios?.app.id ?? summary?.apple?.appId)).catch(() => {})}><Icon name="external" />App Store Connect</button>
</>}>
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
{!s ? (r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <Empty compact illo="cloud" title={t('sub.notRead')}>{t('sub.notReadText')}</Empty>) : (
<div className="stack">
{s.submissions.length > 0 && (
<div className="list sub-list" aria-label={t('sub.submissions')}>
{s.submissions.filter(x => x.kind !== 'done').slice(0, 6).map(x => {
const [tone, key] = SUB_KIND[x.kind];
return (
<div className="li" key={x.id} data-sub-kind={x.kind}>
<span className={`li-icon tone-bg-${tone === 'neutral' ? 'info' : tone}`}><Icon name={x.kind === 'unresolved' ? 'alertCircle' : x.kind === 'emptyDraft' ? 'info' : 'clock'} /></span>
<div className="li-main">
<span className="li-title">{t(key)}</span>
<span className="li-sub"><code className="selectable">{x.id}</code> · {x.state}{x.date ? ` · ${date(x.date)}` : ''}{x.kind === 'emptyDraft' ? ` · ${t('sub.harmless')}` : ''}</span>
{x.items.length > 0 && (
<ul className="note-list">
{x.items.map(i => (
<li key={i.id || i.label}><b>{i.label}</b>: {i.state}{i.rejected ? ` · ${i.kind === 'appStoreVersion' ? t('sub.markVersion') : t('sub.markIap')}` : ''}</li>
))}
</ul>
)}
</div>
<Chip tone={tone}>{x.state}</Chip>
</div>
);
})}
</div>
)}
{!s.submissions.some(x => x.kind !== 'done') && <span className="small muted">{t('sub.noOpen')}</span>}
{s.busy && <Notice tone="info" icon="clock">{t('sub.busy', { state: s.busy.state })}</Notice>}
{!s.busy && (
<>
<div className="row" style={{ gap: 10, flexWrap: 'wrap', alignItems: 'end' }}>
<label className="stack" style={{ gap: 4 }}>
<span className="small strong">{t('sub.build')}</span>
<select className="input input-sm" value={build} onChange={e => setBuild(e.target.value)} aria-describedby="sub-build-hint">
<option value="">{t('sub.pickBuild')}</option>
{s.builds.map(b => (
<option key={b.id} value={b.number} disabled={!b.valid}>
{`${b.number}${b.version ? ` · ${b.version}` : ''} · ${b.state}${b.uploaded ? ` · ${date(b.uploaded)}` : ''}${b.attachedTo ? ` · ${t('sub.attachedTo', { version: b.attachedTo })}` : ''}`}
</option>
))}
</select>
</label>
<div className="stack small" style={{ gap: 4 }}>
<span className="strong">{t('sub.version')}</span>
<span>{version ? `${version.version} · ${version.state}` : <Chip tone="warn" icon="question">{t('sub.noVersion')}</Chip>}</span>
</div>
</div>
<span id="sub-build-hint" className="small muted">{s.builds.length ? t('sub.buildHint') : t('sub.noBuilds')}</span>
{s.unresolved ? (
<>
<Notice tone="bad" icon="alertCircle">{t('sub.unresolvedText')}</Notice>
<label className="row small" style={{ gap: 8 }}><input type="checkbox" checked={resolveIaps} onChange={e => setResolveIaps(e.target.checked)} />{t('sub.resolveIaps')}{rejectedIap ? ` ${t('sub.resolveIapsNeeded')}` : ''}</label>
<div className="actions">
<OwnerAction project={project} method="owner.apple_resubmit" args={args} label={t('sub.resubmit')} icon="send" primary disabled={!picked || !version}
hint={!picked ? t('sub.pickFirst') : undefined} done={t('sub.resubmitted')} lead={t('sub.resubmitLead')} after={refresh} />
</div>
</>
) : (
<div className="actions">
<OwnerAction project={project} method="owner.apple_submit" args={args} label={t('sub.submit')} icon="send" primary disabled={!picked || !version}
hint={!picked ? t('sub.pickFirst') : undefined} done={t('sub.submitted')} lead={t('sub.submitLead')} after={refresh} />
</div>
)}
{iaps > 0 && (
<div className="sub-iaps stack" style={{ gap: 6 }}>
<span className="small strong">{t('sub.iapTitle', { count: iaps })}</span>
<span className="small muted">{t('sub.iapText')}</span>
<div className="row" style={{ gap: 10, flexWrap: 'wrap' }}>
<label className="row small" style={{ gap: 8 }}><input type="checkbox" checked={withVersion} onChange={e => setWithVersion(e.target.checked)} />{t('sub.withVersion')}</label>
<OwnerAction project={project} method="owner.apple_iap_submit" args={withVersion ? { with_version: true } : {}} label={t('sub.iapSubmit')} icon="tag"
done={t('sub.submitted')} lead={t('sub.iapLead')} after={refresh} />
</div>
</div>
)}
</>
)}
</div>
)}
</Card>
);
}
const SHARES = [1, 2, 5, 10, 20, 50];
/** The Google Play card: versionCode picker, Send to production, managed publishing, the console steps without an API. */
export function PlayProductionCard({ app, project }: { app: UnifiedApp; project: Project }) {
const d = useData();
const r = useRead<{ tracks?: PlayTrackData[] }>(project.id, 'play.status', { network: true, title: t('sub.playRead'), appName: app.name });
const s = r.data ? playSubmitState(r.data) : null;
const pr = d.statusOf(project)?.summary?.playRelease ?? null;
const [code, setCode] = useState('');
const [status, setStatus] = useState<'completed' | 'inProgress' | 'draft'>('completed');
const [share, setShare] = useState('10');
const picked = s?.codes.find(c => c.code === code) ?? null;
const args = { version_code: code, status, ...(status === 'inProgress' ? { percent: Number(share) } : {}) };
return (
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.playSub')}</span>}
actions={<>
<button className="btn btn-sm" onClick={() => { r.refresh(); }} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>
</>}>
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
<div className="stack">
{pr && pr.managedPublishing !== true && <Notice tone="warn" icon="alert">{pr.managedPublishing === false ? t('sub.managedOff') : t('sub.managedUnset')}</Notice>}
{pr && pr.managedPublishing === true && <Notice tone="info" icon="info">{t('sub.managedOn')}</Notice>}
{!s ? (r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <Empty compact illo="cloud" title={t('sub.notRead')}>{t('sub.playNotReadText')}</Empty>) : (
<>
{s.production.length > 0 && (
<span className="small">{t('sub.prodNow')}: {s.production.map(p => `${p.name ?? p.codes.join(', ')} · ${p.status}${p.fraction !== null ? ` · ${Math.round(p.fraction * 100)} %` : ''}`).join('; ')}</span>
)}
<div className="row" style={{ gap: 10, flexWrap: 'wrap', alignItems: 'end' }}>
<label className="stack" style={{ gap: 4 }}>
<span className="small strong">{t('sub.versionCode')}</span>
<select className="input input-sm" value={code} onChange={e => setCode(e.target.value)} aria-describedby="sub-code-hint">
<option value="">{t('sub.pickCode')}</option>
{s.codes.map(c => <option key={c.code} value={c.code} disabled={c.inProduction}>{`${c.code}${c.name ? ` · ${c.name}` : ''} · ${c.tracks.join(', ')}${c.inProduction ? ` · ${t('sub.inProduction')}` : ''}`}</option>)}
</select>
</label>
<div className="stack" style={{ gap: 4 }}>
<span className="small strong">{t('sub.as')}</span>
<Seg value={status} onChange={setStatus} label={t('sub.as')} options={[['completed', t('sub.sCompleted')], ['inProgress', t('sub.sStaged')], ['draft', t('sub.sDraft')]]} />
</div>
{status === 'inProgress' && (
<label className="stack" style={{ gap: 4 }}>
<span className="small strong">{t('roll.share')}</span>
<select className="input input-sm" value={share} onChange={e => setShare(e.target.value)}>{SHARES.map(x => <option key={x} value={x}>{x} %</option>)}</select>
</label>
)}
</div>
<span id="sub-code-hint" className="small muted">{s.codes.length ? t('sub.codeHint') : t('sub.noCodes')}</span>
<div className="actions">
<OwnerAction project={project} method="owner.play_production" args={args} label={status === 'draft' ? t('sub.playDraft') : t('sub.playSend')} icon="send" primary={status !== 'draft'}
disabled={!picked} hint={!picked ? t('sub.pickCodeFirst') : undefined} done={status === 'draft' ? t('sub.playDrafted') : t('sub.playSent')} lead={status === 'draft' ? t('sub.playDraftLead') : t('sub.playLead')} after={() => { r.refresh(); }} />
</div>
</>
)}
<Notice tone="info" icon="clipboard" action={<div className="actions">
<ConsolePlanButton project={project} form="play-production-countries" label={t('cp.playCountries')} />
<ConsolePlanButton project={project} form="play-production-release" label={t('sub.sendChanges')} />
</div>}>
<div className="stack" style={{ gap: 2 }}>
<b>{t('sub.consoleStepTitle')}</b>
<span className="small">{t('sub.consoleStepText', { countries: pr?.countries ? t('sub.countriesN', { count: pr.countries }) : t('sub.countriesAll') })}</span>
</div>
</Notice>
</div>
</Card>
);
}

View file

@ -100,7 +100,11 @@ export function TestFlightTab({ app }: { app: UnifiedApp }) {
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" />{t('action.refresh')}</button>
</div>
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
{s.missingGroups.length > 0 && <Notice tone="warn">{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}</Notice>}
{s.missingGroups.length > 0 && (
<Notice tone="warn" action={writable ? <WriteButton run={w} icon="plus" w={{ method: 'apple.testflight_groups', args: {}, title: t('tf.groupsSync'), done: t('tf.groupsSynced'), after: r.refresh }} hint={t('tf.groupsSyncHint')}>{t('tf.groupsSync')}</WriteButton> : undefined}>
{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}
</Notice>
)}
<div className="grid-cards">
{s.groups.map(g => (
<div key={g.id} className="tf-group">

View file

@ -12,6 +12,7 @@ import { isError, type HealthSnapshot, type Review } from './lib/model';
import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify';
import { setCrashContext } from './lib/crash';
import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize';
import { keepLines, progressOf } from './lib/progress';
import { fixHint } from './lib/views';
export type ToastKind = 'job' | 'ok' | 'bad' | 'info';
@ -24,6 +25,8 @@ export interface Toast {
progress?: { done: number; total: number } | null;
/** Cancellable job id. */
job?: string;
/** The job's last progress lines (the toast's log), newest last. */
lines?: string[];
}
export interface AccountHealth { check?: AccountCheck; error?: string; at: string }
@ -268,7 +271,11 @@ export function DataProvider({ children }: { children: ReactNode }) {
setToasts(ts => ts.map(x => {
if (x.job !== p.job) return x;
if (p.total) return { ...x, progress: { done: p.n ?? 0, total: p.total } };
if (p.line) return { ...x, detail: lastLine(p.line) || x.detail };
if (p.line) {
// upload and render lines carry their own progress ("312/890 MB (35%), part 4/12"): the bar follows them
const lp = progressOf(p.line);
return { ...x, detail: lastLine(p.line) || x.detail, lines: keepLines(x.lines, lastLine(p.line) || p.line), ...(lp ? { progress: lp } : {}) };
}
return x;
}));
});
@ -332,6 +339,9 @@ export function useWrite(projectId: string | null) {
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(lastLine(o.dryRun.output) || undefined), progress: undefined, job: undefined });
else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined });
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
// exit 3: the command stopped for an owner decision it did not get (a price change of a live product: the
// Catalog tab's owner action applies it); everything else it could do was done
else if (o.result?.exitCode === 3) { d.updateToast(tid, { kind: 'info', title: t('write.declined'), detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
else d.updateToast(tid, { kind: 'bad', title: t('write.failed'), detail: withHint(lastLine(o.result?.output ?? '') || undefined), progress: undefined, job: undefined });
return o;
} catch (e) {

View file

@ -626,6 +626,8 @@ select.input { padding-right: 8px; }
:root[data-motion="reduce"] .toast .bar.indet > span { animation: none; width: 100% !important; opacity: .5; }
@media (prefers-reduced-motion: reduce) { .toast .bar.indet > span { animation: none; width: 100% !important; opacity: .5; } }
.toast-actions { display: flex; justify-content: flex-end; gap: 6px; }
.toast-log summary { cursor: pointer; }
.toast-log .log { max-height: 160px; margin-top: 6px; font-size: 11px; }
/* ---------- misc ---------- */
.grid-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
@ -885,6 +887,16 @@ textarea.input { height: auto; padding: 8px 10px; line-height: 1.45; resize: ver
.cf-table td:first-child { padding-left: 0; }
.cf-table td:last-child { padding-right: 0; text-align: right; }
.cf-guidance { font-size: var(--t-meta); }
/* owner action plan (ui/OwnerAction.tsx) and console plan (ui/ConsolePlan.tsx) panels */
.oa-panel, .cp-panel { width: min(820px, 100%); max-height: 86vh; }
.scrim.lightbox:has(.oa-panel), .scrim.lightbox:has(.cp-panel) { padding-top: 6vh; }
.plan-table { white-space: pre; overflow: auto; max-height: 300px; }
.cp-steps { margin: 0; padding-left: 20px; display: grid; gap: 6px; font-size: var(--t-meta); }
.cp-steps .chips { margin-left: 6px; display: inline-flex; }
/* Releases > send for review */
.section-h { font-size: var(--t-section); font-weight: 660; margin-top: 8px; }
.sub-list .note-list { margin: 4px 0 0; }
.sub-iaps { border-top: 1px solid var(--line); padding-top: 12px; }
/* growth chart */
.lchart { display: grid; gap: 8px; }

View file

@ -0,0 +1,174 @@
// Console steps without an API, agent-first (`storebound console plan <form> --json`, offline): the start page (a
// deep link when the ids are known), the steps in order with what to click, the answer sheet from the config (certain,
// ask the owner, owner check), the buttons never to press and the final one that stays the owner's, then a read-only
// check of the result (`console verify <form>`: the page text pasted here, or the App Store Connect API for the app
// record and the age rating). Nothing here clicks in a console: the owner's AI agent (or the owner) drives the page
// with this plan, `Copy plan` hands it over as JSON; "Fill in console" (ui/ConsoleFill.tsx) is the scripted fast path
// where it exists. Every form of `console plan`, including the Play pages added for the first production release.
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, type Project, type ToolResult } from '../api';
import { type Key, t } from '../i18n';
import { useData } from '../store';
import { Icon, StoreGlyph } from './icons';
import { Chip, Notice } from './kit';
/** The forms of `console plan` (cli/mcp-tools.ts CONSOLE_PLAN_FORMS). */
export type PlanForm = 'apple-app-record' | 'apple-age-rating' | 'apple-app-privacy' | 'play-app-record' | 'play-content-rating' | 'play-target-audience'
| 'play-store-settings' | 'play-app-content' | 'play-privacy-policy' | 'play-ads' | 'play-sign-in-details' | 'play-advertising-id' | 'play-government-apps'
| 'play-financial-features' | 'play-health-apps' | 'play-production-countries' | 'play-production-release';
/** `console plan <form> --json` (cli/lib/console-plan.ts ConsolePlan), the parts shown here. */
export interface Plan {
form: PlanForm; store: 'apple' | 'play'; title: string;
start: { url: string; deepLink: boolean; reach: string[] };
steps: { n: number; text: string; click?: string[] }[];
answers: { id: string; question: string; text: string; status: string; label?: string; note?: string }[];
askOwner: string[];
buttons: { neverPress: string[]; final: string };
}
/** `console verify <form> --json` (cli/lib/console-verify.ts VerifyResult). */
export interface VerifyResult { form: PlanForm; rows: { item: string; expected: string; seen: string; status: string }[]; mismatches: number; matched: number; ok: boolean; notes: string[] }
/** Every form `console plan` knows, with its name and one line (cli/mcp-tools.ts CONSOLE_PLAN_FORMS). */
export const PLAN_FORMS: { form: PlanForm; store: 'apple' | 'play'; name: Key; text: Key }[] = [
{ form: 'apple-app-record', store: 'apple', name: 'cf.appleAppRecord', text: 'cp.appleAppRecordText' },
{ form: 'apple-age-rating', store: 'apple', name: 'cf.appleAgeRating', text: 'cp.appleAgeRatingText' },
{ form: 'apple-app-privacy', store: 'apple', name: 'cf.appleAppPrivacy', text: 'cp.appleAppPrivacyText' },
{ form: 'play-app-record', store: 'play', name: 'cf.playAppRecord', text: 'cf.playAppRecordText' },
{ form: 'play-store-settings', store: 'play', name: 'cp.playStoreSettings', text: 'cp.playStoreSettingsText' },
{ form: 'play-app-content', store: 'play', name: 'cp.playAppContent', text: 'cp.playAppContentText' },
{ form: 'play-privacy-policy', store: 'play', name: 'cp.playPrivacyPolicy', text: 'cp.cardText' },
{ form: 'play-ads', store: 'play', name: 'cp.playAds', text: 'cp.cardText' },
{ form: 'play-sign-in-details', store: 'play', name: 'cp.playSignIn', text: 'cp.playSignInText' },
{ form: 'play-advertising-id', store: 'play', name: 'cp.playAdId', text: 'cp.cardText' },
{ form: 'play-government-apps', store: 'play', name: 'cp.playGovernment', text: 'cp.cardText' },
{ form: 'play-financial-features', store: 'play', name: 'cp.playFinancial', text: 'cp.cardText' },
{ form: 'play-health-apps', store: 'play', name: 'cp.playHealth', text: 'cp.cardText' },
{ form: 'play-content-rating', store: 'play', name: 'cf.playContentRating', text: 'cf.playContentRatingText' },
{ form: 'play-target-audience', store: 'play', name: 'cf.playTargetAudience', text: 'cf.playTargetAudienceText' },
{ form: 'play-production-countries', store: 'play', name: 'cp.playCountries', text: 'cp.playCountriesText' },
{ form: 'play-production-release', store: 'play', name: 'cp.playProductionRelease', text: 'cp.playProductionReleaseText' },
];
const nameOf = (form: PlanForm): string => t(PLAN_FORMS.find(f => f.form === form)?.name ?? 'cp.title');
/** Forms whose result `console verify` reads from the App Store Connect API (no page text needed). */
const API_VERIFY = new Set<PlanForm>(['apple-app-record', 'apple-age-rating']);
const STATUS: Record<string, ['ok' | 'warn' | 'info' | 'bad', Key]> = { certain: ['ok', 'cp.sCertain'], 'ask-owner': ['warn', 'cp.sAsk'], 'owner-check': ['info', 'cp.sCheck'], agreement: ['bad', 'cp.sAgreement'] };
const ROW: Record<string, ['ok' | 'warn' | 'bad', Key]> = { match: ['ok', 'cp.vMatch'], mismatch: ['bad', 'cp.vMismatch'], 'not-found': ['warn', 'cp.vNotFound'], 'ask-owner': ['warn', 'cp.sAsk'] };
export function ConsolePlanButton({ project, form, primary, label }: { project: Project; form: PlanForm; primary?: boolean; label?: string }) {
const d = useData();
const [plan, setPlan] = useState<Plan | null>(null);
const [busy, setBusy] = useState(false);
const open = async () => {
setBusy(true);
try {
const r = await api.call<ToolResult & { data?: Plan }>('console.plan', project.id, { args: { form } }, newJob('console.plan'));
if (!r.ok || !r.data) d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: r.output.trim().split('\n').at(-1) });
else setPlan(r.data);
} catch (e) { d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: errorText(e) }); } finally { setBusy(false); }
};
return (
<>
<button className={`btn btn-sm${primary ? ' btn-primary' : ''}`} onClick={() => { open(); }} disabled={busy} aria-busy={busy || undefined} title={t('cp.hint', { form: nameOf(form) })}>
<Icon name={busy ? 'refresh' : 'clipboard'} className={busy ? 'spin' : undefined} />{label ?? t('cp.button')}
</button>
{plan && <PlanPanel project={project} plan={plan} onClose={() => setPlan(null)} />}
</>
);
}
function PlanPanel({ project, plan, onClose }: { project: Project; plan: Plan; onClose: () => void }) {
const d = useData();
const root = useRef<HTMLDivElement>(null);
const [text, setText] = useState('');
const [verify, setVerify] = useState<{ busy: boolean; result?: VerifyResult; error?: string }>({ busy: false });
useEffect(() => { root.current?.focus(); }, []);
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } };
const apiVerify = API_VERIFY.has(plan.form);
const check = async () => {
setVerify({ busy: true });
try {
const r = await api.call<ToolResult & { data?: VerifyResult }>('console.verify', project.id, { args: { form: plan.form, ...(text.trim() ? { text: text.trim() } : {}) } }, newJob('console.verify'));
if (r.data) setVerify({ busy: false, result: r.data });
else setVerify({ busy: false, error: r.output.trim().split('\n').at(-1)?.replace(/^\S+ \[[^\]]+\] /, '') ?? t('common.exit', { code: r.exitCode }) });
} catch (e) { setVerify({ busy: false, error: errorText(e) }); }
};
const copy = () => { navigator.clipboard?.writeText(JSON.stringify(plan, null, 2)).then(() => d.toast({ kind: 'ok', title: t('cp.copied') }), () => {}); };
const asks = plan.answers.filter(a => a.status !== 'certain');
return (
<div className="scrim lightbox" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div className="lb cf-panel cp-panel" role="dialog" aria-modal="true" aria-labelledby="cp-title" tabIndex={-1} ref={root} onKeyDown={onKey}>
<div className="cf-body stack">
<div className="stack" style={{ gap: 4 }}>
<h2 id="cp-title" className="row" style={{ gap: 8 }}><Icon name="clipboard" />{plan.title}</h2>
<span className="small muted row" style={{ gap: 6 }}><StoreGlyph store={plan.store === 'apple' ? 'ios' : 'android'} className="ic13" />{t('cp.agentFirst')}</span>
</div>
<Notice tone="info" icon="globe" action={<button className="btn btn-sm" onClick={() => api.openExternal(plan.start.url).catch(e => d.toast({ kind: 'bad', title: t('cp.openFailed'), detail: errorText(e) }))}><Icon name="external" />{t('cp.openPage')}</button>}>
<div className="stack" style={{ gap: 2 }}>
<span className="small selectable mono-id">{plan.start.url}</span>
{plan.start.reach.length > 0 && <span className="small">{plan.start.reach.join(' > ')}</span>}
</div>
</Notice>
<section className="stack" style={{ gap: 6 }} aria-labelledby="cp-steps">
<b id="cp-steps" className="small">{t('cp.steps', { count: plan.steps.length })}</b>
<ol className="cp-steps">
{plan.steps.map(s => (
<li key={s.n}><span className="selectable">{s.text}</span>{s.click && s.click.length > 0 && <span className="chips">{s.click.map((c, i) => <Chip key={i} tone="outline" dot={false}>{c}</Chip>)}</span>}</li>
))}
</ol>
</section>
{plan.answers.length > 0 && (
<section className="stack" style={{ gap: 6 }}>
<div className="row between small"><b>{t('cp.answers', { count: plan.answers.length })}</b>{asks.length > 0 && <Chip tone="warn" icon="question">{t('cf.uncertain', { count: asks.length })}</Chip>}</div>
<table className="table cf-table">
<tbody>
{plan.answers.map(a => {
const [tone, key] = STATUS[a.status] ?? STATUS['ask-owner']!;
return (
<tr key={a.id}>
<td><div className="strong">{a.label ?? a.question}</div>{a.note && <div className="small muted">{a.note}</div>}</td>
<td>{a.status === 'certain' ? <span className="selectable">{a.text}</span> : <span className="small">{a.text}</span>}</td>
<td><Chip tone={tone}>{t(key)}</Chip></td>
</tr>
);
})}
</tbody>
</table>
</section>
)}
<Notice tone="warn" icon="shield">
<div className="stack" style={{ gap: 4 }}>
<span>{t('cp.final', { button: plan.buttons.final || '—' })}</span>
<span className="chips">{plan.buttons.neverPress.slice(0, 14).map(b => <Chip key={b} tone="bad" dot={false}>{b}</Chip>)}</span>
</div>
</Notice>
<section className="stack" style={{ gap: 6 }} aria-labelledby="cp-verify">
<b id="cp-verify" className="small">{t('cp.verify')}</b>
<span className="small muted">{apiVerify ? t('cp.verifyApi') : t('cp.verifyText')}</span>
{!apiVerify && <textarea className="input" rows={4} value={text} onChange={e => setText(e.target.value)} aria-label={t('cp.pasteLabel')} placeholder={t('cp.pastePlaceholder')} />}
{verify.error && <Notice tone="bad">{verify.error}</Notice>}
{verify.result && (
<>
<Notice tone={verify.result.ok ? 'ok' : 'warn'}>{t('cp.verified', { matched: verify.result.matched, mismatches: verify.result.mismatches })}</Notice>
<table className="table cf-table">
<tbody>
{verify.result.rows.map((r, i) => {
const [tone, key] = ROW[r.status] ?? ROW['not-found']!;
return <tr key={i}><td className="strong">{r.item}</td><td className="small">{r.expected}</td><td className="small muted">{r.seen}</td><td><Chip tone={tone}>{t(key)}</Chip></td></tr>;
})}
</tbody>
</table>
</>
)}
</section>
</div>
<div className="lb-foot">
<div className="grow small muted">{t('cp.foot')}</div>
<button className="btn btn-sm btn-ghost" onClick={copy}><Icon name="copy" />{t('cp.copy')}</button>
<button className="btn btn-sm" onClick={() => { check(); }} disabled={verify.busy || (!apiVerify && !text.trim())} aria-busy={verify.busy || undefined}>
<Icon name={verify.busy ? 'refresh' : 'check'} className={verify.busy ? 'spin' : undefined} />{t('cp.verifyRun')}
</button>
<button className="btn btn-sm btn-primary" onClick={onClose}>{t('common.close')}</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
// An owner action with its plan in the window first: the button runs the dry run (the host forces dryRun for every
// call from here), this panel shows the plan table and what blocks it, then "Continue" goes through
// write_with_confirm: the host runs the dry run again, shows it in a native dialog and mints the confirm token only on
// the dialog's own button. While the real run goes (a submit retries up to 10 minutes while Apple says "not ready
// yet"), its progress lines stream into the panel; Cancel stops the command. Used for submit for review, resubmit,
// in-app purchases to review, the Play production release and price changes of existing products.
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, onProgress, type Project, type ToolResult } from '../api';
import { t } from '../i18n';
import { planBlockers } from '../lib/submit';
import { useData } from '../store';
import { Icon, type IconName } from './icons';
import { Notice } from './kit';
type Stage =
| { s: 'idle' }
| { s: 'dry' }
| { s: 'plan'; dry: ToolResult; note?: string }
| { s: 'running'; dry: ToolResult; job: string; lines: string[] }
| { s: 'done'; dry: ToolResult; ok: boolean; declined: boolean; lines: string[]; detail: string };
/** Exit code of a command that stopped because the owner's answer was missing (lib/ctx declined()). */
export const DECLINED_EXIT = 3;
const clean = (out: string): string => out.split('\n').filter(l => l.trim()).map(l => l.replace(/^\d\d:\d\d:\d\d \[[^\]]+\] /, '')).join('\n');
const tail = (out: string): string => clean(out).split('\n').at(-1) ?? '';
export interface OwnerActionProps {
project: Project;
method: string;
args: Record<string, unknown>;
/** Button and panel title. */
label: string;
icon: IconName;
/** Toast and panel line when it went through. */
done: string;
/** What happens, in plain words, above the plan. */
lead?: ReactNode;
primary?: boolean;
danger?: boolean;
disabled?: boolean;
hint?: string;
after?: () => void;
}
export function OwnerAction(p: OwnerActionProps) {
const d = useData();
const [st, setSt] = useState<Stage>({ s: 'idle' });
const plan = async () => {
setSt({ s: 'dry' });
try {
const r = await api.call<ToolResult>(p.method, p.project.id, { args: p.args }, newJob(`${p.method}-plan`));
setSt({ s: 'plan', dry: r });
} catch (e) {
setSt({ s: 'idle' });
d.toast({ kind: 'bad', title: t('oa.planFailed', { what: p.label }), detail: errorText(e) });
}
};
const go = async (dry: ToolResult) => {
const job = newJob(p.method);
setSt({ s: 'running', dry, job, lines: [] });
const lines: string[] = [];
const un = onProgress(e => {
if (e.job !== job || !e.line?.trim()) return;
lines.push(e.line);
setSt(x => (x.s === 'running' && x.job === job ? { ...x, lines: lines.slice(-200) } : x));
});
try {
const o = await api.writeWithConfirm(p.method, p.project.id, p.args, job);
if (o.stage === 'dialog') setSt({ s: 'plan', dry: o.dryRun, note: t('oa.notSent') });
else if (o.stage === 'dry-run') setSt({ s: 'plan', dry: o.dryRun, note: t('oa.dryChanged') });
else {
const r = o.result!;
const declined = r.exitCode === DECLINED_EXIT;
setSt({ s: 'done', dry, ok: r.ok, declined, lines: lines.slice(-200), detail: tail(r.output) });
d.toast({ kind: r.ok ? 'ok' : declined ? 'info' : 'bad', title: r.ok ? p.done : declined ? t('write.declined') : t('write.failed'), detail: tail(r.output) || undefined });
if (r.ok) p.after?.();
}
} catch (e) {
const msg = errorText(e);
setSt({ s: 'done', dry, ok: false, declined: false, lines: lines.slice(-200), detail: /cancel/i.test(msg) ? t('oa.cancelled') : msg });
} finally { un.then(f => f()); }
};
const busy = st.s === 'dry';
return (
<>
<button className={`btn btn-sm${p.primary ? ' btn-primary' : ''}${p.danger ? ' btn-danger' : ''}`} disabled={p.disabled || busy || st.s === 'running'} aria-busy={busy || undefined} title={p.hint}
onClick={() => { plan(); }}>
<Icon name={busy ? 'refresh' : p.icon} className={busy ? 'spin' : undefined} />{p.label}
</button>
{st.s !== 'idle' && st.s !== 'dry' && (
<PlanPanel st={st} p={p} onGo={() => { go(st.dry); }} onCancel={() => { if (st.s === 'running') api.cancel(st.job).catch(() => {}); }} onClose={() => setSt({ s: 'idle' })} />
)}
</>
);
}
function PlanPanel({ st, p, onGo, onCancel, onClose }: {
st: Extract<Stage, { s: 'plan' | 'running' | 'done' }>; p: OwnerActionProps; onGo: () => void; onCancel: () => void; onClose: () => void;
}) {
const root = useRef<HTMLDivElement>(null);
const logRef = useRef<HTMLPreElement>(null);
const running = st.s === 'running';
// focus the panel when it opens and whenever a stage ends (the button that had focus may be gone), so Escape and Tab
// keep working inside it
useEffect(() => { if (!running && !root.current?.contains(document.activeElement)) root.current?.focus(); }, [st.s, running]);
const lines = st.s === 'running' || st.s === 'done' ? st.lines : [];
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [lines.length]);
const blockers = planBlockers(st.dry.data);
const dryOk = st.dry.ok && !blockers.length;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !running) { e.preventDefault(); onClose(); } };
return (
<div className="scrim lightbox" onMouseDown={e => { if (e.target === e.currentTarget && !running) onClose(); }}>
<div className="lb cf-panel oa-panel" role="dialog" aria-modal="true" aria-labelledby="oa-title" tabIndex={-1} ref={root} onKeyDown={onKey}>
<div className="cf-body stack">
<div className="stack" style={{ gap: 4 }}>
<h2 id="oa-title" className="row" style={{ gap: 8 }}><Icon name={p.icon} />{p.label}</h2>
<code className="small muted selectable">{st.dry.command}</code>
</div>
{p.lead && <Notice tone="info" icon="shield">{p.lead}</Notice>}
{st.s === 'plan' && st.note && <Notice tone="info">{st.note}</Notice>}
{!st.dry.ok && <Notice tone="bad" icon="alertCircle">{t('oa.dryBad')}{tail(st.dry.output) ? `: ${tail(st.dry.output)}` : ''}</Notice>}
{blockers.map((b, i) => <Notice key={i} tone="bad" icon="alertCircle">{b}</Notice>)}
<div className="stack" style={{ gap: 6 }}>
<b className="small">{t('oa.plan')}</b>
<pre className="log selectable plan-table" aria-label={t('oa.plan')}>{clean(st.dry.output) || t('oa.noOutput')}</pre>
</div>
{running && <Notice tone="info" icon="clock">{t('oa.running')}</Notice>}
{st.s === 'done' && (st.ok ? <Notice tone="ok" icon="check">{p.done}{st.detail ? `: ${st.detail}` : ''}</Notice>
: st.declined ? <Notice tone="warn" icon="alert">{t('write.declined')}{st.detail ? `: ${st.detail}` : ''}</Notice>
: <Notice tone="bad" icon="alertCircle">{t('write.failed')}{st.detail ? `: ${st.detail}` : ''}</Notice>)}
{lines.length > 0 && <pre className="log" ref={logRef} aria-live="polite" aria-label={t('oa.progress')}>{lines.map(l => l.replace(/^\d\d:\d\d:\d\d /, '')).join('\n')}</pre>}
</div>
<div className="lb-foot">
<div className="grow small muted">{st.s === 'plan' ? (dryOk ? t('oa.foot') : t('oa.fixFirst')) : null}</div>
{running ? <button className="btn btn-sm btn-danger" onClick={onCancel}><Icon name="x" />{t('oa.stop')}</button>
: <button className="btn btn-sm btn-ghost" onClick={onClose}>{st.s === 'done' ? t('common.close') : t('common.cancel')}</button>}
{st.s === 'plan' && <button className={`btn btn-sm ${p.danger ? 'btn-danger' : 'btn-primary'}`} disabled={!dryOk} onClick={onGo}><Icon name={p.icon} />{t('oa.continue', { what: p.label })}</button>}
{running && <span className="small muted row" style={{ gap: 6 }}><Icon name="refresh" className="spin ic13" />{t('oa.working')}</span>}
</div>
</div>
</div>
);
}

View file

@ -29,6 +29,12 @@ export function Toasts() {
<div className="c"><span>{big ? `${bytes(p.done)} / ${bytes(p.total)}` : `${num(p.done)} / ${num(p.total)}`}</span><span>{Math.round((p.done / Math.max(1, p.total)) * 100)}%</span></div>
</div>
) : <div className="bar indet"><span /></div>}
{x.lines && x.lines.length > 1 && (
<details className="toast-log">
<summary className="small muted">{t('toast.log', { count: x.lines.length })}</summary>
<pre className="log">{x.lines.join('\n')}</pre>
</details>
)}
{x.job && <div className="toast-actions"><button className="btn btn-sm" onClick={() => d.cancelJob(x.job!)}>{t('common.cancel')}</button></div>}
</>
)}

View file

@ -0,0 +1,80 @@
// Releases > send for review: the submission state read from `apple status --json` / `play status --json`, builds and
// versionCodes picked by number, and the progress the job toasts read from upload lines.
import { describe, expect, test } from 'bun:test';
import { appleSubmitState, planBlockers, playSubmitState } from '../src/lib/submit';
import { keepLines, progressOf } from '../src/lib/progress';
import { deletionView } from '../src/screens/tabs/AppInfo';
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, PLAY_STATUS } from '../src/dev/submit-fixtures';
describe('App Store submission state', () => {
test('ready: no open submission, the editable version, VALID builds pickable, processing ones not', () => {
const s = appleSubmitState(APPLE_STATUS_READY);
expect(s.unresolved).toBeNull();
expect(s.busy).toBeNull();
expect(s.editable).toMatchObject({ version: '2.4.0', state: 'PREPARE_FOR_SUBMISSION' });
expect(s.builds.map(b => [b.number, b.version, b.valid])).toEqual([['53', '2.4.0', false], ['52', '2.4.0', true], ['48', '2.3.0', true]]);
expect(s.builds.find(b => b.number === '48')!.attachedTo).toBe('2.3.0');
// an empty draft is labelled, never a blocker
expect(s.submissions.map(x => [x.id, x.kind])).toEqual([['rs-empty-1', 'emptyDraft'], ['rs-old-1', 'done']]);
});
test('UNRESOLVED_ISSUES: its items with REJECTED marked, the held version is the one to resubmit', () => {
const s = appleSubmitState(APPLE_STATUS_UNRESOLVED);
expect(s.unresolved).toMatchObject({ id: 'rs-back-1', kind: 'unresolved' });
expect(s.unresolved!.items.map(i => [i.label, i.state, i.rejected])).toEqual([['app version 2.4.0', 'REJECTED', true], ['in-app purchase iapv-coins', 'READY_FOR_REVIEW', false]]);
expect(s.editable).toMatchObject({ version: '2.4.0', state: 'REJECTED' });
expect(s.busy).toBeNull();
});
test('waiting for review: busy, nothing can be sent next to it', () => {
const raw = { ...APPLE_STATUS_READY, subs: [{ id: 'rs-w', attributes: { state: 'WAITING_FOR_REVIEW', submittedDate: '2026-10-11T10:00:00Z' } }], emptyDrafts: [] };
expect(appleSubmitState(raw).busy).toMatchObject({ id: 'rs-w', kind: 'waiting', date: '2026-10-11T10:00:00Z' });
});
test('odd or old answers never throw', () => {
for (const raw of [null, undefined, 'x', [], { subs: 'no' }, { builds: [null, 1, { id: 'b' }] }, { versions: [{ attributes: null }] }])
expect(() => appleSubmitState(raw)).not.toThrow();
expect(appleSubmitState({}).builds).toEqual([]);
});
});
describe('Google Play production', () => {
test('versionCodes by number from every track, newest first; one in production (not a draft) cannot be picked again', () => {
const s = playSubmitState(PLAY_STATUS);
expect(s.codes.map(c => [c.code, c.tracks.join(','), c.inProduction])).toEqual([['24', 'beta,internal', false], ['23', 'production', true], ['22', 'internal', false]]);
expect(s.production).toEqual([{ status: 'completed', codes: ['23'], name: '2.3.0 (23)', fraction: null }]);
expect(playSubmitState(null)).toEqual({ codes: [], production: [], category: null });
});
});
test('plan blockers from the --json result', () => {
expect(planBlockers({ blockers: ['build 53 is PROCESSING; wait until VALID'] })).toEqual(['build 53 is PROCESSING; wait until VALID']);
expect(planBlockers(undefined)).toEqual([]);
});
describe('progress from upload and render lines', () => {
test('MB share, percent, parts', () => {
expect(progressOf('12:00:00 [upload] still uploading, 4m30s, 312/890 MB (35%), part 4/12')).toEqual({ done: 312 * 1048576, total: 890 * 1048576 });
expect(progressOf('processing (40%)')).toEqual({ done: 40, total: 100 });
expect(progressOf('[upload] part 3/12 done')).toEqual({ done: 3, total: 12 });
expect(progressOf('[render] [7/48] en ios69 01')).toEqual({ done: 7, total: 48 });
expect(progressOf('version 1.2/3 something')).toBeNull();
expect(progressOf('12/5 MB')).toBeNull();
});
test('the toast log keeps the last lines', () => {
let l: string[] | undefined;
for (let i = 0; i < 50; i++) l = keepLines(l, `line ${i}`);
expect(l!.length).toBe(40);
expect(l!.at(-1)).toBe('line 49');
});
});
describe('Data safety: the deletion question', () => {
const base = { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'], types: [], extra: 0, csv: '', rows: 0, uploaded: null };
test('the effective answer from a newer sidecar, with where it comes from', () => {
expect(deletionView({ ...base, partialDeletion: null, dataDeletion: 'yes', dataDeletionSource: 'accounts.deletionUrl', dataDeletionUrl: 'https://example.com/delete' }))
.toEqual({ answer: 'yes', source: 'accounts.deletionUrl', url: 'https://example.com/delete' });
expect(deletionView({ ...base, partialDeletion: null, dataDeletion: null })).toEqual({ answer: null, source: null, url: null });
});
test('an older sidecar (partialDeletion only)', () => {
expect(deletionView({ ...base, partialDeletion: 'auto90' })).toMatchObject({ answer: 'auto90' });
expect(deletionView({ ...base, partialDeletion: null })).toMatchObject({ answer: null });
});
});

View file

@ -25,15 +25,24 @@ Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli
the sidecar's stdin. Write methods are dry runs unless the call carries a confirm token: HMAC-SHA256 over the
method, the canonical `{ project, args }` and a 60 s expiry, single use. The host mints one only after the owner
pressed Apply in a native dialog that shows the summary and the dry run (`write_with_confirm`).
- No method submits for review, releases an approved version, publishes, cancels a submission, sends a build to
external testers or removes testers. Under `serve`, the CLI's own terminal confirmations answer no, with one
exception: the **owner actions** (`owner.play_rollout` share / halt / resume / complete, `owner.ios_phased`
on / pause / resume / complete, `owner.reply_send` one approved draft, `owner.testflight_testers` adds the testers
of `.storebound/testers.csv`). They are desktop-only (never MCP tools), dry runs from the window like every write,
and their real run needs the host token. Only then serve sets `STOREBOUND_OWNER_CONFIRMED` for that one child,
which answers the command's question. The native dialog names the action on its button ("Yes: Change the Play
staged rollout"). `tests/desktop-d3.test.ts` and `src-tauri/tests/d3_writes.rs` pin the token binding of every
write the window offers.
- No method releases an approved version, publishes, cancels a submission, sends a build to external testers or
removes testers. Under `serve`, the CLI's own terminal confirmations answer no, with one exception: the **owner
actions** (`owner.play_rollout` share / halt / resume / complete, `owner.ios_phased` on / pause / resume /
complete, `owner.reply_send` one approved draft, `owner.testflight_testers` adds the testers of
`.storebound/testers.csv`, `owner.prices_import` and `owner.prices_play_products` / `_play_subscriptions` / `_apple_subscriptions` with `--allow-price-changes`,
`owner.ppo_start` / `owner.ppo_stop`, and **sending for review**: `owner.apple_submit` (`apple submit --build n`),
`owner.apple_resubmit` (`apple resubmit --build n [--resolve-iaps]`), `owner.apple_iap_submit` (`apple iap-submit
[--with-version]`) and `owner.play_production` (`play release --version-code n --production completed | inProgress
| draft`)). They are desktop-only (never MCP tools), dry runs from the window like every write, and their real run
needs the host token. Only then serve sets `STOREBOUND_OWNER_CONFIRMED` for that one child, which answers the
command's question, and adds the owner-only flags (`--yes` of apple submit / resubmit, `--allow-price-changes`);
`OWNER_ALLOWED_ARGS` names the one action each such flag belongs to and a dry run never carries one. Builds and
versionCodes are picked by number in the window, never "the latest". The native dialog names the action on its
button ("Yes: Submit for App Review"); for the submit actions it says that the app goes to review and may go live
by itself once approved. Play's "Send changes for review" (Publishing overview) has no API: the Releases tab shows
its console plan and a deep link instead. `tests/desktop-d3.test.ts`, `tests/serve.test.ts` and
`src-tauri/tests/d3_writes.rs` pin the token binding of every write the window offers and that the MCP server has
none of these.
- The webview gets no plugin API. It may call the app commands (each re-checks the window label and the page origin
in Rust) and listen to two events. Strict CSP, no remote content. `src-tauri/tests/security.rs` pins all of it.
- Store keys (Play service account JSON, App Store Connect key id, issuer id and .p8) are picked in native dialogs,
@ -158,14 +167,31 @@ Each write is a dry run, then the native dialog, then the real run, with one pro
- **Listings tab:** Play listing text, App Store version and text, Play graphics, App Store screenshots and
previews; local render jobs (screenshots, previews, icons, gallery).
- **Catalog tab:** Play products and subscriptions, App Store IAPs and subscriptions, price export.
- **Catalog tab:** Play products and subscriptions, App Store IAPs and subscriptions, price export; price changes of
live products (`owner.prices_play_products`, `owner.prices_play_subscriptions`, `owner.prices_apple_subscriptions`, owner actions: a normal sync holds them back and exits 3, shown as "Waiting
for your decision").
- **Prices tab:** the price sheet compared with the stores and applied (`owner.prices_import`, an owner action).
- **App info tab:** Play Data safety upload, App Store app info, export compliance, App Review details.
- **App info tab:** Play Data safety upload (the deletion question as the CSV answers it and where the answer comes
from), App Store app info, export compliance, App Review details; "Console steps without an API": every form of
`console plan` (App Store app record, age rating, App Privacy; Play Create app, store settings category, every App
content card, content rating, target audience, production countries, production release) with its plan panel
(start page, steps, answer sheet, never-press buttons, Copy plan for an AI agent) and the read-only check
(`console.verify`, page text pasted or the API), plus "Fill in console" where the scripted path exists.
- **Growth tab:** analytics snapshot and report request, PPO create and treatment upload; PPO start / stop
(`owner.ppo_start`, `owner.ppo_stop`, owner actions).
- **Store art tab:** render, review page.
- **Releases tab:** rollout and phased release owner actions.
- **TestFlight tab:** What to Test, internal groups, testers.
- **Releases tab:** rollout and phased release owner actions; **Send for review**: App Store submission state from
`apple status` (waiting / in review, UNRESOLVED_ISSUES with its items, empty drafts labelled harmless), the uploaded
builds by number, Submit for review / Resubmit (with the rejected items to mark resolved) / in-app purchases; Google
Play versionCodes by number, Send to production (everyone, staged, or a draft for a never-published app), the
managed-publishing warning and the console plans for production countries and "Send changes for review". Each
button shows the plan (dry run) in the window first (`ui/OwnerAction.tsx`), then the native dialog; progress lines
of the real run stream into the panel.
- **TestFlight tab:** What to Test, internal groups, testers, "Create missing groups" (`apple testflight groups
sync`, dry run first).
- **Dependencies screen:** the Android SDK as `play build` finds it (`system.tools` `androidSdk`).
- **Job toasts:** the command's progress lines; upload lines ("312/890 MB (35%), part 4/12") drive the bar, and the
last 40 lines are in the toast's log.
- **Reviews tab:** reply editor (`reviews.reply`), approve, send (owner action).
### The same model in the terminal
@ -224,6 +250,13 @@ and account names only), the same file the desktop reads. A workspace account's
| `apple appinfo`, `apple encryption`, `apple review-details` | App info: Push buttons (confirm flow) |
| `console fill <form> --dry --json` / `console fill <form> --json` (desktop-only `console.fill`) | App info: "Fill in console" (answers panel, native dialog, visible browser the owner saves in) |
| `apple ppo status / create / upload / start / stop` | Growth: tests table, create, upload, Start / Stop owner actions |
| `apple status --json` (review submissions, UNRESOLVED_ISSUES items, empty drafts, builds) | Releases: Send for review, App Store card |
| `apple submit --build n`, `apple resubmit --build n [--resolve-iaps]`, `apple iap-submit [--with-version]` | Releases: Submit for review / Resubmit / Submit in-app purchases (owner actions) |
| `play status --json`, `play release --version-code n --production ...` | Releases: Google Play card, Send to production (owner action) |
| `console plan <form> --json`, `console verify <form> --json` | App info: Console steps (every form); Releases: production countries, Send changes for review |
| `apple testflight groups sync` | TestFlight: Create missing groups |
| `play products` / `play subscriptions` / `apple subscriptions --allow-price-changes` | Catalog: Price changes (owner action) |
| `doctor` Android SDK | Dependencies: Android SDK card |
| Play store listing experiments | no API: Growth links Play Console and the variant renders |
| `analytics compare --json`, `snapshot`, `setup` | Growth: chart (reach, installs, conversion, listing changes as markers), before / after table, snapshot and setup buttons |
| `shots gallery --json`, `inspect --render` (new) | Store art: grid per variant / language / size, issues, lightbox; images load lazily one at a time as data: URLs (never cached) |

View file

@ -7,12 +7,18 @@
// the sidecar's real error. For each fixture it boots the window, opens every screen and every app in the sidebar,
// and fails on an uncaught error, a console error, an error panel (boundary or public/boot.js) or an empty #root.
// A last case loads a page whose bundle is missing and expects the boot.js error panel (the guard itself works).
// The send-for-review cases (submit-ready, submit-unresolved; old D1 and new D3 saved state) answer the store reads
// apple.status / play.status from apps/desktop/src/dev/submit-fixtures.ts and the owner actions' dry runs from the
// same file (the dialog is answered "Cancel": nothing is ever sent), open Releases, check the submission state, the
// build picker, the plan panel and a console plan (the real sidecar) by DOM text, then every tab of the app and the
// Dependencies screen.
// bun tools/desktop-boot-check.ts [--dist apps/desktop/dist] [--only d1-missing-folders] [--json]
// Uses playwright: Google Chrome when installed (channel "chrome"), else playwright's Chromium
// (bunx playwright install chromium). Part of `bun run ci` and of `tools/desktop-release.ts mac`.
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { extname, join, resolve } from 'node:path';
import type { Browser, Page } from 'playwright';
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, ownerDry, PLAY_STATUS } from '../apps/desktop/src/dev/submit-fixtures';
const ROOT = resolve(import.meta.dir, '..');
const APP = join(ROOT, 'apps/desktop');
@ -90,7 +96,12 @@ export function loadFixture(name: string): HostFixture {
return JSON.parse(text) as HostFixture;
}
function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged: string[]) {
/** Store answers of a send-for-review case: the reads come from the fixtures, owner dry runs too, dialogs say Cancel. */
interface LiveStores { apple: unknown; play: unknown; calls: string[] }
const STORE_READS: Record<string, keyof Omit<LiveStores, 'calls'>> = { 'apple.status': 'apple', 'play.status': 'play' };
const toolResult = (method: string, data: unknown) => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data });
function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged: string[], live?: LiveStores) {
const projects = () => fx.projects_list.map(p => ({ ...p, missing: !isDir(p.path) }));
const fail = (message: string) => { throw { code: -1, message }; };
return async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
@ -102,7 +113,16 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
case 'accounts_list': return fx.accounts_list;
case 'workspace_get': return fx.workspace_get;
case 'store_apps_cached': return fx.accounts_list.map(x => ({ account: x.id, entry: null }));
case 'cache_get': return null;
case 'cache_get': {
const st = live && STORE_READS[String(a.method)];
return st ? { value: toolResult(String(a.method), live![st]), at: new Date(Date.now() - 5 * 60_000).toISOString() } : null;
}
case 'write_with_confirm': {
if (!live) return fail('boot check: write_with_confirm is not answered here');
// the owner pressed Cancel in the native dialog: nothing is sent, ever
live.calls.push(`write_with_confirm ${String(a.method)} ${JSON.stringify(a.args)}`);
return { confirmed: false, stage: 'dialog', dryRun: ownerDry(String(a.method), (a.args ?? {}) as Record<string, unknown>) };
}
case 'log_webview': logged.push(`${String(a.level)}: ${String(a.message).slice(0, 300)}`); return null;
case 'sidecar_status': {
const h = sidecar.hello!;
@ -120,6 +140,13 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
const method = String(a.method);
const kind = sidecar.kind(method);
const params = { ...((a.params as Record<string, unknown> | null) ?? {}) };
if (live && STORE_READS[method]) { live.calls.push(method); return toolResult(method, live[STORE_READS[method]!]); }
if (live && kind === 'owner') {
// the Rust host forces dryRun for every write and owner action from the window (policy::sanitize_js_call)
if ('dryRun' in params || 'confirmToken' in params) fail(`boot check: ${method}: the window sent host params`);
live.calls.push(`dry ${method} ${JSON.stringify(params.args)}`);
return ownerDry(method, (params.args ?? {}) as Record<string, unknown>);
}
if (kind === 'read' || kind === 'write' || kind === 'owner') {
if (kind !== 'read') fail(`boot check: ${method} writes`);
const p = projects().find(x => x.id === a.projectId);
@ -257,6 +284,134 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
/** Saved states for the send-for-review cases: the new D3 state, and the old D1 state with its folders present. */
function liveFixture(base: 'd3-full' | 'd1-missing-folders'): HostFixture {
const fx = loadFixture(base);
if (base === 'd1-missing-folders') for (const p of fx.projects_list) p.path = p.path.replace(/^\/nonexistent\/storebound\/worktrees\/[^/]+\/examples/, EXAMPLES);
return fx;
}
/** Texts the Releases tab must show for each submission state (DOM text, not pixels). */
const SUBMIT_EXPECT: Record<'ready' | 'unresolved', string[]> = {
ready: ['Send for review', 'App Store: submit for review', 'Empty draft', 'harmless', 'Pick the build you uploaded', 'Submit for review', 'Google Play: production release', 'Send to production', 'Managed publishing', 'No API for these Play steps', 'Send changes for review'],
unresolved: ['Send for review', 'Back from App Review (unresolved issues)', 'UNRESOLVED_ISSUES', 'app version 2.4.0', 'REJECTED', 'marked resolved when you resubmit', 'Resubmit', 'Also mark rejected in-app purchase items resolved', 'Send to production'],
};
async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' | 'd1-missing-folders', variant: 'ready' | 'unresolved'): Promise<CaseResult> {
const name = `submit-${variant} (${base === 'd3-full' ? 'D3 state' : 'D1 state'})`;
const freeze = conf.app.security.freezePrototype === true;
const server = serve(DIST, cspHeader(conf.app.security.csp), false);
const ctx = await browser.newContext({ viewport: { width: 1360, height: 900 }, reducedMotion: 'reduce' });
const page = await ctx.newPage();
const problems: string[] = [];
const notes: string[] = [];
const unmocked = new Set<string>();
const logged: string[] = [];
const fx = liveFixture(base);
const live: LiveStores = { apple: variant === 'ready' ? APPLE_STATUS_READY : APPLE_STATUS_UNRESOLVED, play: PLAY_STATUS, calls: [] };
const answer = host(fx, sidecar, unmocked, logged, live);
await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record<string, unknown>) => {
try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; }
});
page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | ')));
page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); });
await page.addInitScript(initScript(freeze));
let visited = 0;
const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); };
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: 15000 }).catch(() => problems.push('React never drew the window (no data-booted)'));
await page.waitForTimeout(1200);
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await page.waitForTimeout(300); }
const app = fx.projects_list.find(p => isDir(p.path))!;
// the sidebar names the app by its config name once the offline portfolio is read
const item = page.locator('nav.side .nav-app').filter({ hasText: base === 'd3-full' ? /Prism Puddle|demo-game/ : new RegExp(app.name) }).first();
await item.click({ timeout: 8000 }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(800);
await page.locator('[role="tab"]#tab-releases').click({ timeout: 5000 }).catch(() => {});
await page.waitForSelector('[data-section="submit"]', { timeout: 8000 }).catch(() => problems.push('Releases: no send-for-review section'));
await page.waitForTimeout(600);
const rel = await text('[data-section="submit"]');
for (const want of SUBMIT_EXPECT[variant]) if (!rel.includes(want)) problems.push(`Releases (${variant}): missing "${want}"`);
if (rel.includes('storebound never submits for review')) problems.push('Releases: the old "submit stays in the console" note is still there');
// builds by number: the processing one cannot be picked, nothing is preselected
const opts = await page.locator('[data-section="submit"] select').first().locator('option').evaluateAll(os => os.map(o => ({ v: (o as HTMLOptionElement).value, d: (o as HTMLOptionElement).disabled, t: o.textContent ?? '' })));
const sel = await page.locator('[data-section="submit"] select').first().inputValue().catch(() => 'x');
if (sel !== '') problems.push(`build picker: preselected "${sel}" (the owner picks; never the latest)`);
if (!opts.some(o => o.v === '52' && !o.d) || !opts.some(o => o.v === '53' && o.d)) problems.push(`build picker: want 52 pickable and 53 (PROCESSING) disabled, got ${JSON.stringify(opts)}`);
notes.push(`build picker: ${opts.filter(o => o.v).map(o => `${o.v}${o.d ? ' (disabled)' : ''}`).join(', ')}`);
// the owner action: disabled until a build is picked, then the plan panel, then the dialog (answered Cancel here)
const action = page.locator('[data-section="submit"] button', { hasText: variant === 'ready' ? 'Submit for review' : 'Resubmit' }).first();
if (!(await action.isDisabled())) problems.push('the submit button is enabled before a build is picked');
await page.locator('[data-section="submit"] select').first().selectOption('52');
await action.click({ timeout: 5000 });
await page.waitForSelector('.oa-panel', { timeout: 8000 }).catch(() => problems.push('no plan panel after the submit button'));
const panel = await text('.oa-panel');
for (const want of ['Plan (dry run, nothing sent yet)', 'STEP', 'attach build 52', 'a system dialog shows this plan again']) if (!panel.includes(want)) problems.push(`plan panel: missing "${want}"`);
await page.locator('.oa-panel .lb-foot button', { hasText: 'Continue' }).click({ timeout: 5000 }).catch(e => problems.push(`Continue: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(500);
if (!(await text('.oa-panel')).includes('nothing was sent')) problems.push('plan panel: no "nothing was sent" after the dialog said Cancel');
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
if (await page.locator('.oa-panel').count()) problems.push('plan panel does not close with Escape');
const want = variant === 'ready' ? 'owner.apple_submit' : 'owner.apple_resubmit';
if (!live.calls.some(c => c.startsWith(`dry ${want} {"build":"52","version":"2.4.0"}`))) problems.push(`no dry run of ${want} with build 52 and version 2.4.0: ${live.calls.join(' | ')}`);
if (!live.calls.some(c => c.startsWith(`write_with_confirm ${want}`))) problems.push(`${want} did not go through write_with_confirm`);
// Google Play: versionCodes by number (one already in production cannot be picked), then the production plan
const codes = page.locator('[data-section="submit"] select[aria-describedby="sub-code-hint"]');
const codeOpts = await codes.locator('option').evaluateAll(os => os.map(o => `${(o as HTMLOptionElement).value}${(o as HTMLOptionElement).disabled ? '!' : ''}`));
if (JSON.stringify(codeOpts) !== JSON.stringify(['', '24', '23!', '22'])) problems.push(`versionCode picker: ${JSON.stringify(codeOpts)}`);
await codes.selectOption('24');
await page.locator('[data-section="submit"] button', { hasText: 'Send to production' }).first().click({ timeout: 5000 });
await page.waitForSelector('.oa-panel', { timeout: 8000 }).catch(() => problems.push('no plan panel after Send to production'));
const pp = await text('.oa-panel');
for (const w of ['--version-code 24 --production completed', 'managed publishing is off']) if (!pp.includes(w)) problems.push(`production plan: missing "${w}"`);
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
if (!live.calls.some(c => c.startsWith('dry owner.play_production {"version_code":"24","status":"completed"}'))) problems.push(`no dry run of owner.play_production 24: ${live.calls.join(' | ')}`);
// a console plan from the real sidecar (offline): Play's Send changes for review
await page.locator('[data-section="submit"] button', { hasText: 'Send changes for review' }).first().click({ timeout: 5000 });
await page.waitForSelector('.cp-panel', { timeout: 10000 }).catch(() => problems.push('no console plan panel'));
const cp = await text('.cp-panel');
for (const w of ['send the first production release for review', 'Send changes for review', 'Check the result']) if (!cp.includes(w)) problems.push(`console plan: missing "${w}"`);
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await crashCheck('Releases');
visited++;
// every tab of the app, then App info's console steps and the Dependencies screen
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
for (const id of tabs) {
await page.locator(`[role="tab"]#${id}`).click({ timeout: 5000 }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(500);
await crashCheck(id);
visited++;
if (id === 'tab-appinfo') {
const ai = await text();
for (const w of ['Console steps without an API', 'Play store settings (App category)', 'Production countries', 'Play: send the production release for review']) if (!ai.includes(w)) problems.push(`App info: missing "${w}"`);
}
if (id === 'tab-testflight' && !(await text()).length) problems.push('TestFlight: empty');
}
notes.push(`tabs: ${tabs.join(', ')}`);
const deps = page.locator('nav.side button.nav', { hasText: 'Dependencies' }).first();
await deps.click({ timeout: 5000 }).catch(() => {});
await page.waitForTimeout(800);
if (!(await text()).includes('Android SDK')) problems.push('Dependencies: no Android SDK card');
await crashCheck('Dependencies');
visited++;
notes.push(`store calls: ${live.calls.length} (${[...new Set(live.calls.map(c => c.split(' ').slice(0, 2).join(' ')))].join(', ')})`);
} catch (e) {
problems.push(`case stopped: ${String(e).split('\n')[0]}`);
} finally {
await ctx.close();
server.stop(true);
}
for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`);
if (unmocked.size) notes.push(`not answered (the window handled the rejection): ${[...unmocked].join(', ')}`);
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
async function launch(): Promise<Browser> {
const { chromium } = await import('playwright');
try { return await chromium.launch({ channel: 'chrome', headless: true }); } catch { /* no Google Chrome */ }
@ -274,6 +429,8 @@ export async function bootCheck(): Promise<CaseResult[]> {
try {
for (const n of names) if (!ONLY.length || ONLY.includes(n)) results.push(await runCase(browser, n, sidecar));
if (!ONLY.length || ONLY.includes('missing-bundle')) results.push(await runCase(browser, 'missing-bundle', sidecar, { breakBundle: true }));
for (const base of ['d3-full', 'd1-missing-folders'] as const) for (const v of ['ready', 'unresolved'] as const)
if (!ONLY.length || ONLY.includes('submit') || ONLY.includes(`submit-${v}`)) results.push(await runSubmitCase(browser, sidecar, base, v));
} finally {
await browser.close();
sidecar.stop();