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

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