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

@ -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>}
</>
)}