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:
parent
33eb24a3dc
commit
2b101f0fd8
22 changed files with 1527 additions and 57 deletions
45
CHANGELOG.md
45
CHANGELOG.md
|
|
@ -20,6 +20,51 @@
|
|||
- Console plan `play-production-release`: the "Do you want to restart your review?" dialog that "Submit N changes
|
||||
for review" opens while a review is running is a known step; "Restart review" is the owner's button and in
|
||||
neverPress (pitfall 33).
|
||||
- Desktop: send for review from the window (owner decision, 2026-10-12). New owner actions (desktop only, never MCP
|
||||
tools): `owner.apple_submit` (new `storebound apple submit --build <n> [--version x.y.z]`: the steps of `apple
|
||||
resubmit` for a first submission: build n attached by number, the open review submission reused, the ready IAPs /
|
||||
subscriptions with it, submitted with the "not ready yet" retry; plan table without `--yes`), `owner.apple_resubmit`
|
||||
(`apple resubmit --build <n> [--resolve-iaps]`), `owner.apple_iap_submit` (`apple iap-submit [--with-version]`) and
|
||||
`owner.play_production` (`play release --version-code <n> --production completed | inProgress | draft`). Each is a
|
||||
dry run from the window; the real run needs the host's token minted after the native dialog, and only then serve
|
||||
adds `--yes` (apple submit / resubmit) and answers the command's question. `OWNER_ALLOWED_ARGS` ties every
|
||||
otherwise forbidden flag (`--yes`, `submit`, `--production`, `--csv`, `--allow-price-changes`) to its one owner
|
||||
action, a dry run never carries an owner flag, and the MCP server still has no submit, resubmit or production
|
||||
release (`tests/serve.test.ts`, `tests/desktop-d3.test.ts`, `src-tauri/tests/d3_writes.rs`). The submit dialog says
|
||||
the app goes to review and may go live by itself once approved.
|
||||
- Releases tab, "Send for review": the App Store submission state from `apple status` (waiting / in review,
|
||||
UNRESOLVED_ISSUES with its items and which ones get marked resolved, empty drafts labelled harmless), the uploaded
|
||||
builds to pick by number (processing ones disabled, nothing preselected), Submit for review, Resubmit (with
|
||||
"also mark rejected IAP items resolved") and Submit in-app purchases (with the editable version); Google Play
|
||||
versionCodes by number (one already live cannot be picked), Send to production as everyone, a staged share or a
|
||||
draft (a never-published app), the managed-publishing warning from `play.managedPublishing`, and the console plans
|
||||
of production countries and "Send changes for review" (no API) with a deep link. Every button shows the plan (dry
|
||||
run, `--json` with the plan table under serve) in the window first, then the native dialog; the real run's
|
||||
progress lines stream into the panel and Stop cancels it. The old "submit stays in the console" note is gone.
|
||||
- App info, "Console steps without an API": every `console plan` form (the new Play store settings category, App
|
||||
content and its cards, production countries and production release included) with a plan panel (start page and
|
||||
deep link, steps, answer sheet, never-press buttons, the owner's final button, Copy plan for an AI agent) and the
|
||||
read-only `console verify` (page text pasted, or the API for the App Store record and age rating). The Play forms
|
||||
card is folded into it.
|
||||
- Price gate: `owner.prices_import` passes `--allow-price-changes` itself after the dialog; new
|
||||
`owner.prices_play_products`, `owner.prices_play_subscriptions`, `owner.prices_apple_subscriptions` (Catalog:
|
||||
"Price changes") run that sync with the flag. A write that ends with exit 3 (declined: a held price change) shows "Waiting for your
|
||||
decision", not a failure.
|
||||
- Data safety card: the deletion question as the CSV answers it (`dataDeletion`, from `accounts.deletionUrl`,
|
||||
`dataSafety.dataDeletion`, ...; `project.declarations` sends `dataDeletion`, `dataDeletionSource`,
|
||||
`dataDeletionUrl`), older sidecars still shown from `partialDeletion`.
|
||||
- TestFlight: "Create missing groups" (`apple testflight groups sync`, dry run first). Dependencies: the Android SDK
|
||||
as `play build` finds it (`system.tools` `androidSdk`). `project.inspect` adds `playRelease` (managed publishing,
|
||||
countries, category). Job toasts: upload lines ("312/890 MB (35%), part 4/12") drive the progress bar, and the
|
||||
last 40 lines are in the toast's log.
|
||||
- Covered by the existing views: `validate` findings ("production has 0 countries", the category reminder), `check`
|
||||
warnings (stale web assets without `build.prebuild`, Play category, App content answers owed, privacy manifest
|
||||
tracking mismatch ITMS-91064, the defaultConfig versionCode / versionName source warning) and `scan` findings
|
||||
show in Checks; `play images` skipped languages and optional tablet sets show in the dry run of the native dialog.
|
||||
- Boot check: four send-for-review cases (ready and UNRESOLVED_ISSUES, on the old D1 and the new D3 saved state)
|
||||
with store answers from `src/dev/submit-fixtures.ts`: the Releases texts, the build and versionCode pickers, the
|
||||
plan panels, a console plan from the real sidecar, every app tab and Dependencies, by DOM text; the dialog is
|
||||
answered Cancel, nothing is sent.
|
||||
|
||||
- Safety (`apple testflight groups`): run bare, it created the configured beta groups right away, with no plan. Now it only lists the groups and prints `would create "Team", "Friends"`. Creating is
|
||||
`groups sync` (or `groups --yes`). Flows treat bare `groups` as a read and `groups sync` as a write, and the MCP tool
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
105
apps/desktop/src/dev/submit-fixtures.ts
Normal file
105
apps/desktop/src/dev/submit-fixtures.ts
Normal 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: [],
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
};
|
||||
|
|
|
|||
18
apps/desktop/src/lib/progress.ts
Normal file
18
apps/desktop/src/lib/progress.ts
Normal 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);
|
||||
102
apps/desktop/src/lib/submit.ts
Normal file
102
apps/desktop/src/lib/submit.ts
Normal 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);
|
||||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
197
apps/desktop/src/screens/tabs/Submit.tsx
Normal file
197
apps/desktop/src/screens/tabs/Submit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
174
apps/desktop/src/ui/ConsolePlan.tsx
Normal file
174
apps/desktop/src/ui/ConsolePlan.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
apps/desktop/src/ui/OwnerAction.tsx
Normal file
143
apps/desktop/src/ui/OwnerAction.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
80
apps/desktop/tests/submit.test.ts
Normal file
80
apps/desktop/tests/submit.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Releases > send for review: the submission state read from `apple status --json` / `play status --json`, builds and
|
||||
// versionCodes picked by number, and the progress the job toasts read from upload lines.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { appleSubmitState, planBlockers, playSubmitState } from '../src/lib/submit';
|
||||
import { keepLines, progressOf } from '../src/lib/progress';
|
||||
import { deletionView } from '../src/screens/tabs/AppInfo';
|
||||
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, PLAY_STATUS } from '../src/dev/submit-fixtures';
|
||||
|
||||
describe('App Store submission state', () => {
|
||||
test('ready: no open submission, the editable version, VALID builds pickable, processing ones not', () => {
|
||||
const s = appleSubmitState(APPLE_STATUS_READY);
|
||||
expect(s.unresolved).toBeNull();
|
||||
expect(s.busy).toBeNull();
|
||||
expect(s.editable).toMatchObject({ version: '2.4.0', state: 'PREPARE_FOR_SUBMISSION' });
|
||||
expect(s.builds.map(b => [b.number, b.version, b.valid])).toEqual([['53', '2.4.0', false], ['52', '2.4.0', true], ['48', '2.3.0', true]]);
|
||||
expect(s.builds.find(b => b.number === '48')!.attachedTo).toBe('2.3.0');
|
||||
// an empty draft is labelled, never a blocker
|
||||
expect(s.submissions.map(x => [x.id, x.kind])).toEqual([['rs-empty-1', 'emptyDraft'], ['rs-old-1', 'done']]);
|
||||
});
|
||||
test('UNRESOLVED_ISSUES: its items with REJECTED marked, the held version is the one to resubmit', () => {
|
||||
const s = appleSubmitState(APPLE_STATUS_UNRESOLVED);
|
||||
expect(s.unresolved).toMatchObject({ id: 'rs-back-1', kind: 'unresolved' });
|
||||
expect(s.unresolved!.items.map(i => [i.label, i.state, i.rejected])).toEqual([['app version 2.4.0', 'REJECTED', true], ['in-app purchase iapv-coins', 'READY_FOR_REVIEW', false]]);
|
||||
expect(s.editable).toMatchObject({ version: '2.4.0', state: 'REJECTED' });
|
||||
expect(s.busy).toBeNull();
|
||||
});
|
||||
test('waiting for review: busy, nothing can be sent next to it', () => {
|
||||
const raw = { ...APPLE_STATUS_READY, subs: [{ id: 'rs-w', attributes: { state: 'WAITING_FOR_REVIEW', submittedDate: '2026-10-11T10:00:00Z' } }], emptyDrafts: [] };
|
||||
expect(appleSubmitState(raw).busy).toMatchObject({ id: 'rs-w', kind: 'waiting', date: '2026-10-11T10:00:00Z' });
|
||||
});
|
||||
test('odd or old answers never throw', () => {
|
||||
for (const raw of [null, undefined, 'x', [], { subs: 'no' }, { builds: [null, 1, { id: 'b' }] }, { versions: [{ attributes: null }] }])
|
||||
expect(() => appleSubmitState(raw)).not.toThrow();
|
||||
expect(appleSubmitState({}).builds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Play production', () => {
|
||||
test('versionCodes by number from every track, newest first; one in production (not a draft) cannot be picked again', () => {
|
||||
const s = playSubmitState(PLAY_STATUS);
|
||||
expect(s.codes.map(c => [c.code, c.tracks.join(','), c.inProduction])).toEqual([['24', 'beta,internal', false], ['23', 'production', true], ['22', 'internal', false]]);
|
||||
expect(s.production).toEqual([{ status: 'completed', codes: ['23'], name: '2.3.0 (23)', fraction: null }]);
|
||||
expect(playSubmitState(null)).toEqual({ codes: [], production: [], category: null });
|
||||
});
|
||||
});
|
||||
|
||||
test('plan blockers from the --json result', () => {
|
||||
expect(planBlockers({ blockers: ['build 53 is PROCESSING; wait until VALID'] })).toEqual(['build 53 is PROCESSING; wait until VALID']);
|
||||
expect(planBlockers(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
describe('progress from upload and render lines', () => {
|
||||
test('MB share, percent, parts', () => {
|
||||
expect(progressOf('12:00:00 [upload] still uploading, 4m30s, 312/890 MB (35%), part 4/12')).toEqual({ done: 312 * 1048576, total: 890 * 1048576 });
|
||||
expect(progressOf('processing (40%)')).toEqual({ done: 40, total: 100 });
|
||||
expect(progressOf('[upload] part 3/12 done')).toEqual({ done: 3, total: 12 });
|
||||
expect(progressOf('[render] [7/48] en ios69 01')).toEqual({ done: 7, total: 48 });
|
||||
expect(progressOf('version 1.2/3 something')).toBeNull();
|
||||
expect(progressOf('12/5 MB')).toBeNull();
|
||||
});
|
||||
test('the toast log keeps the last lines', () => {
|
||||
let l: string[] | undefined;
|
||||
for (let i = 0; i < 50; i++) l = keepLines(l, `line ${i}`);
|
||||
expect(l!.length).toBe(40);
|
||||
expect(l!.at(-1)).toBe('line 49');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data safety: the deletion question', () => {
|
||||
const base = { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'], types: [], extra: 0, csv: '', rows: 0, uploaded: null };
|
||||
test('the effective answer from a newer sidecar, with where it comes from', () => {
|
||||
expect(deletionView({ ...base, partialDeletion: null, dataDeletion: 'yes', dataDeletionSource: 'accounts.deletionUrl', dataDeletionUrl: 'https://example.com/delete' }))
|
||||
.toEqual({ answer: 'yes', source: 'accounts.deletionUrl', url: 'https://example.com/delete' });
|
||||
expect(deletionView({ ...base, partialDeletion: null, dataDeletion: null })).toEqual({ answer: null, source: null, url: null });
|
||||
});
|
||||
test('an older sidecar (partialDeletion only)', () => {
|
||||
expect(deletionView({ ...base, partialDeletion: 'auto90' })).toMatchObject({ answer: 'auto90' });
|
||||
expect(deletionView({ ...base, partialDeletion: null })).toMatchObject({ answer: null });
|
||||
});
|
||||
});
|
||||
|
|
@ -25,15 +25,24 @@ Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli
|
|||
the sidecar's stdin. Write methods are dry runs unless the call carries a confirm token: HMAC-SHA256 over the
|
||||
method, the canonical `{ project, args }` and a 60 s expiry, single use. The host mints one only after the owner
|
||||
pressed Apply in a native dialog that shows the summary and the dry run (`write_with_confirm`).
|
||||
- No method submits for review, releases an approved version, publishes, cancels a submission, sends a build to
|
||||
external testers or removes testers. Under `serve`, the CLI's own terminal confirmations answer no, with one
|
||||
exception: the **owner actions** (`owner.play_rollout` share / halt / resume / complete, `owner.ios_phased`
|
||||
on / pause / resume / complete, `owner.reply_send` one approved draft, `owner.testflight_testers` adds the testers
|
||||
of `.storebound/testers.csv`). They are desktop-only (never MCP tools), dry runs from the window like every write,
|
||||
and their real run needs the host token. Only then serve sets `STOREBOUND_OWNER_CONFIRMED` for that one child,
|
||||
which answers the command's question. The native dialog names the action on its button ("Yes: Change the Play
|
||||
staged rollout"). `tests/desktop-d3.test.ts` and `src-tauri/tests/d3_writes.rs` pin the token binding of every
|
||||
write the window offers.
|
||||
- No method releases an approved version, publishes, cancels a submission, sends a build to external testers or
|
||||
removes testers. Under `serve`, the CLI's own terminal confirmations answer no, with one exception: the **owner
|
||||
actions** (`owner.play_rollout` share / halt / resume / complete, `owner.ios_phased` on / pause / resume /
|
||||
complete, `owner.reply_send` one approved draft, `owner.testflight_testers` adds the testers of
|
||||
`.storebound/testers.csv`, `owner.prices_import` and `owner.prices_play_products` / `_play_subscriptions` / `_apple_subscriptions` with `--allow-price-changes`,
|
||||
`owner.ppo_start` / `owner.ppo_stop`, and **sending for review**: `owner.apple_submit` (`apple submit --build n`),
|
||||
`owner.apple_resubmit` (`apple resubmit --build n [--resolve-iaps]`), `owner.apple_iap_submit` (`apple iap-submit
|
||||
[--with-version]`) and `owner.play_production` (`play release --version-code n --production completed | inProgress
|
||||
| draft`)). They are desktop-only (never MCP tools), dry runs from the window like every write, and their real run
|
||||
needs the host token. Only then serve sets `STOREBOUND_OWNER_CONFIRMED` for that one child, which answers the
|
||||
command's question, and adds the owner-only flags (`--yes` of apple submit / resubmit, `--allow-price-changes`);
|
||||
`OWNER_ALLOWED_ARGS` names the one action each such flag belongs to and a dry run never carries one. Builds and
|
||||
versionCodes are picked by number in the window, never "the latest". The native dialog names the action on its
|
||||
button ("Yes: Submit for App Review"); for the submit actions it says that the app goes to review and may go live
|
||||
by itself once approved. Play's "Send changes for review" (Publishing overview) has no API: the Releases tab shows
|
||||
its console plan and a deep link instead. `tests/desktop-d3.test.ts`, `tests/serve.test.ts` and
|
||||
`src-tauri/tests/d3_writes.rs` pin the token binding of every write the window offers and that the MCP server has
|
||||
none of these.
|
||||
- The webview gets no plugin API. It may call the app commands (each re-checks the window label and the page origin
|
||||
in Rust) and listen to two events. Strict CSP, no remote content. `src-tauri/tests/security.rs` pins all of it.
|
||||
- Store keys (Play service account JSON, App Store Connect key id, issuer id and .p8) are picked in native dialogs,
|
||||
|
|
@ -158,14 +167,31 @@ Each write is a dry run, then the native dialog, then the real run, with one pro
|
|||
|
||||
- **Listings tab:** Play listing text, App Store version and text, Play graphics, App Store screenshots and
|
||||
previews; local render jobs (screenshots, previews, icons, gallery).
|
||||
- **Catalog tab:** Play products and subscriptions, App Store IAPs and subscriptions, price export.
|
||||
- **Catalog tab:** Play products and subscriptions, App Store IAPs and subscriptions, price export; price changes of
|
||||
live products (`owner.prices_play_products`, `owner.prices_play_subscriptions`, `owner.prices_apple_subscriptions`, owner actions: a normal sync holds them back and exits 3, shown as "Waiting
|
||||
for your decision").
|
||||
- **Prices tab:** the price sheet compared with the stores and applied (`owner.prices_import`, an owner action).
|
||||
- **App info tab:** Play Data safety upload, App Store app info, export compliance, App Review details.
|
||||
- **App info tab:** Play Data safety upload (the deletion question as the CSV answers it and where the answer comes
|
||||
from), App Store app info, export compliance, App Review details; "Console steps without an API": every form of
|
||||
`console plan` (App Store app record, age rating, App Privacy; Play Create app, store settings category, every App
|
||||
content card, content rating, target audience, production countries, production release) with its plan panel
|
||||
(start page, steps, answer sheet, never-press buttons, Copy plan for an AI agent) and the read-only check
|
||||
(`console.verify`, page text pasted or the API), plus "Fill in console" where the scripted path exists.
|
||||
- **Growth tab:** analytics snapshot and report request, PPO create and treatment upload; PPO start / stop
|
||||
(`owner.ppo_start`, `owner.ppo_stop`, owner actions).
|
||||
- **Store art tab:** render, review page.
|
||||
- **Releases tab:** rollout and phased release owner actions.
|
||||
- **TestFlight tab:** What to Test, internal groups, testers.
|
||||
- **Releases tab:** rollout and phased release owner actions; **Send for review**: App Store submission state from
|
||||
`apple status` (waiting / in review, UNRESOLVED_ISSUES with its items, empty drafts labelled harmless), the uploaded
|
||||
builds by number, Submit for review / Resubmit (with the rejected items to mark resolved) / in-app purchases; Google
|
||||
Play versionCodes by number, Send to production (everyone, staged, or a draft for a never-published app), the
|
||||
managed-publishing warning and the console plans for production countries and "Send changes for review". Each
|
||||
button shows the plan (dry run) in the window first (`ui/OwnerAction.tsx`), then the native dialog; progress lines
|
||||
of the real run stream into the panel.
|
||||
- **TestFlight tab:** What to Test, internal groups, testers, "Create missing groups" (`apple testflight groups
|
||||
sync`, dry run first).
|
||||
- **Dependencies screen:** the Android SDK as `play build` finds it (`system.tools` `androidSdk`).
|
||||
- **Job toasts:** the command's progress lines; upload lines ("312/890 MB (35%), part 4/12") drive the bar, and the
|
||||
last 40 lines are in the toast's log.
|
||||
- **Reviews tab:** reply editor (`reviews.reply`), approve, send (owner action).
|
||||
|
||||
### The same model in the terminal
|
||||
|
|
@ -224,6 +250,13 @@ and account names only), the same file the desktop reads. A workspace account's
|
|||
| `apple appinfo`, `apple encryption`, `apple review-details` | App info: Push buttons (confirm flow) |
|
||||
| `console fill <form> --dry --json` / `console fill <form> --json` (desktop-only `console.fill`) | App info: "Fill in console" (answers panel, native dialog, visible browser the owner saves in) |
|
||||
| `apple ppo status / create / upload / start / stop` | Growth: tests table, create, upload, Start / Stop owner actions |
|
||||
| `apple status --json` (review submissions, UNRESOLVED_ISSUES items, empty drafts, builds) | Releases: Send for review, App Store card |
|
||||
| `apple submit --build n`, `apple resubmit --build n [--resolve-iaps]`, `apple iap-submit [--with-version]` | Releases: Submit for review / Resubmit / Submit in-app purchases (owner actions) |
|
||||
| `play status --json`, `play release --version-code n --production ...` | Releases: Google Play card, Send to production (owner action) |
|
||||
| `console plan <form> --json`, `console verify <form> --json` | App info: Console steps (every form); Releases: production countries, Send changes for review |
|
||||
| `apple testflight groups sync` | TestFlight: Create missing groups |
|
||||
| `play products` / `play subscriptions` / `apple subscriptions --allow-price-changes` | Catalog: Price changes (owner action) |
|
||||
| `doctor` Android SDK | Dependencies: Android SDK card |
|
||||
| Play store listing experiments | no API: Growth links Play Console and the variant renders |
|
||||
| `analytics compare --json`, `snapshot`, `setup` | Growth: chart (reach, installs, conversion, listing changes as markers), before / after table, snapshot and setup buttons |
|
||||
| `shots gallery --json`, `inspect --render` (new) | Store art: grid per variant / language / size, issues, lightbox; images load lazily one at a time as data: URLs (never cached) |
|
||||
|
|
|
|||
|
|
@ -7,12 +7,18 @@
|
|||
// the sidecar's real error. For each fixture it boots the window, opens every screen and every app in the sidebar,
|
||||
// and fails on an uncaught error, a console error, an error panel (boundary or public/boot.js) or an empty #root.
|
||||
// A last case loads a page whose bundle is missing and expects the boot.js error panel (the guard itself works).
|
||||
// The send-for-review cases (submit-ready, submit-unresolved; old D1 and new D3 saved state) answer the store reads
|
||||
// apple.status / play.status from apps/desktop/src/dev/submit-fixtures.ts and the owner actions' dry runs from the
|
||||
// same file (the dialog is answered "Cancel": nothing is ever sent), open Releases, check the submission state, the
|
||||
// build picker, the plan panel and a console plan (the real sidecar) by DOM text, then every tab of the app and the
|
||||
// Dependencies screen.
|
||||
// bun tools/desktop-boot-check.ts [--dist apps/desktop/dist] [--only d1-missing-folders] [--json]
|
||||
// Uses playwright: Google Chrome when installed (channel "chrome"), else playwright's Chromium
|
||||
// (bunx playwright install chromium). Part of `bun run ci` and of `tools/desktop-release.ts mac`.
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
import type { Browser, Page } from 'playwright';
|
||||
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, ownerDry, PLAY_STATUS } from '../apps/desktop/src/dev/submit-fixtures';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const APP = join(ROOT, 'apps/desktop');
|
||||
|
|
@ -90,7 +96,12 @@ export function loadFixture(name: string): HostFixture {
|
|||
return JSON.parse(text) as HostFixture;
|
||||
}
|
||||
|
||||
function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged: string[]) {
|
||||
/** Store answers of a send-for-review case: the reads come from the fixtures, owner dry runs too, dialogs say Cancel. */
|
||||
interface LiveStores { apple: unknown; play: unknown; calls: string[] }
|
||||
const STORE_READS: Record<string, keyof Omit<LiveStores, 'calls'>> = { 'apple.status': 'apple', 'play.status': 'play' };
|
||||
const toolResult = (method: string, data: unknown) => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data });
|
||||
|
||||
function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged: string[], live?: LiveStores) {
|
||||
const projects = () => fx.projects_list.map(p => ({ ...p, missing: !isDir(p.path) }));
|
||||
const fail = (message: string) => { throw { code: -1, message }; };
|
||||
return async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
|
||||
|
|
@ -102,7 +113,16 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
|
|||
case 'accounts_list': return fx.accounts_list;
|
||||
case 'workspace_get': return fx.workspace_get;
|
||||
case 'store_apps_cached': return fx.accounts_list.map(x => ({ account: x.id, entry: null }));
|
||||
case 'cache_get': return null;
|
||||
case 'cache_get': {
|
||||
const st = live && STORE_READS[String(a.method)];
|
||||
return st ? { value: toolResult(String(a.method), live![st]), at: new Date(Date.now() - 5 * 60_000).toISOString() } : null;
|
||||
}
|
||||
case 'write_with_confirm': {
|
||||
if (!live) return fail('boot check: write_with_confirm is not answered here');
|
||||
// the owner pressed Cancel in the native dialog: nothing is sent, ever
|
||||
live.calls.push(`write_with_confirm ${String(a.method)} ${JSON.stringify(a.args)}`);
|
||||
return { confirmed: false, stage: 'dialog', dryRun: ownerDry(String(a.method), (a.args ?? {}) as Record<string, unknown>) };
|
||||
}
|
||||
case 'log_webview': logged.push(`${String(a.level)}: ${String(a.message).slice(0, 300)}`); return null;
|
||||
case 'sidecar_status': {
|
||||
const h = sidecar.hello!;
|
||||
|
|
@ -120,6 +140,13 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
|
|||
const method = String(a.method);
|
||||
const kind = sidecar.kind(method);
|
||||
const params = { ...((a.params as Record<string, unknown> | null) ?? {}) };
|
||||
if (live && STORE_READS[method]) { live.calls.push(method); return toolResult(method, live[STORE_READS[method]!]); }
|
||||
if (live && kind === 'owner') {
|
||||
// the Rust host forces dryRun for every write and owner action from the window (policy::sanitize_js_call)
|
||||
if ('dryRun' in params || 'confirmToken' in params) fail(`boot check: ${method}: the window sent host params`);
|
||||
live.calls.push(`dry ${method} ${JSON.stringify(params.args)}`);
|
||||
return ownerDry(method, (params.args ?? {}) as Record<string, unknown>);
|
||||
}
|
||||
if (kind === 'read' || kind === 'write' || kind === 'owner') {
|
||||
if (kind !== 'read') fail(`boot check: ${method} writes`);
|
||||
const p = projects().find(x => x.id === a.projectId);
|
||||
|
|
@ -257,6 +284,134 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
|
|||
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
|
||||
}
|
||||
|
||||
/** Saved states for the send-for-review cases: the new D3 state, and the old D1 state with its folders present. */
|
||||
function liveFixture(base: 'd3-full' | 'd1-missing-folders'): HostFixture {
|
||||
const fx = loadFixture(base);
|
||||
if (base === 'd1-missing-folders') for (const p of fx.projects_list) p.path = p.path.replace(/^\/nonexistent\/storebound\/worktrees\/[^/]+\/examples/, EXAMPLES);
|
||||
return fx;
|
||||
}
|
||||
|
||||
/** Texts the Releases tab must show for each submission state (DOM text, not pixels). */
|
||||
const SUBMIT_EXPECT: Record<'ready' | 'unresolved', string[]> = {
|
||||
ready: ['Send for review', 'App Store: submit for review', 'Empty draft', 'harmless', 'Pick the build you uploaded', 'Submit for review', 'Google Play: production release', 'Send to production', 'Managed publishing', 'No API for these Play steps', 'Send changes for review'],
|
||||
unresolved: ['Send for review', 'Back from App Review (unresolved issues)', 'UNRESOLVED_ISSUES', 'app version 2.4.0', 'REJECTED', 'marked resolved when you resubmit', 'Resubmit', 'Also mark rejected in-app purchase items resolved', 'Send to production'],
|
||||
};
|
||||
|
||||
async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' | 'd1-missing-folders', variant: 'ready' | 'unresolved'): Promise<CaseResult> {
|
||||
const name = `submit-${variant} (${base === 'd3-full' ? 'D3 state' : 'D1 state'})`;
|
||||
const freeze = conf.app.security.freezePrototype === true;
|
||||
const server = serve(DIST, cspHeader(conf.app.security.csp), false);
|
||||
const ctx = await browser.newContext({ viewport: { width: 1360, height: 900 }, reducedMotion: 'reduce' });
|
||||
const page = await ctx.newPage();
|
||||
const problems: string[] = [];
|
||||
const notes: string[] = [];
|
||||
const unmocked = new Set<string>();
|
||||
const logged: string[] = [];
|
||||
const fx = liveFixture(base);
|
||||
const live: LiveStores = { apple: variant === 'ready' ? APPLE_STATUS_READY : APPLE_STATUS_UNRESOLVED, play: PLAY_STATUS, calls: [] };
|
||||
const answer = host(fx, sidecar, unmocked, logged, live);
|
||||
await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record<string, unknown>) => {
|
||||
try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; }
|
||||
});
|
||||
page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | ')));
|
||||
page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); });
|
||||
await page.addInitScript(initScript(freeze));
|
||||
let visited = 0;
|
||||
const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
|
||||
const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); };
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: 15000 }).catch(() => problems.push('React never drew the window (no data-booted)'));
|
||||
await page.waitForTimeout(1200);
|
||||
const wizard = page.locator('.wizard[role="dialog"]');
|
||||
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await page.waitForTimeout(300); }
|
||||
const app = fx.projects_list.find(p => isDir(p.path))!;
|
||||
// the sidebar names the app by its config name once the offline portfolio is read
|
||||
const item = page.locator('nav.side .nav-app').filter({ hasText: base === 'd3-full' ? /Prism Puddle|demo-game/ : new RegExp(app.name) }).first();
|
||||
await item.click({ timeout: 8000 }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`));
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('[role="tab"]#tab-releases').click({ timeout: 5000 }).catch(() => {});
|
||||
await page.waitForSelector('[data-section="submit"]', { timeout: 8000 }).catch(() => problems.push('Releases: no send-for-review section'));
|
||||
await page.waitForTimeout(600);
|
||||
const rel = await text('[data-section="submit"]');
|
||||
for (const want of SUBMIT_EXPECT[variant]) if (!rel.includes(want)) problems.push(`Releases (${variant}): missing "${want}"`);
|
||||
if (rel.includes('storebound never submits for review')) problems.push('Releases: the old "submit stays in the console" note is still there');
|
||||
// builds by number: the processing one cannot be picked, nothing is preselected
|
||||
const opts = await page.locator('[data-section="submit"] select').first().locator('option').evaluateAll(os => os.map(o => ({ v: (o as HTMLOptionElement).value, d: (o as HTMLOptionElement).disabled, t: o.textContent ?? '' })));
|
||||
const sel = await page.locator('[data-section="submit"] select').first().inputValue().catch(() => 'x');
|
||||
if (sel !== '') problems.push(`build picker: preselected "${sel}" (the owner picks; never the latest)`);
|
||||
if (!opts.some(o => o.v === '52' && !o.d) || !opts.some(o => o.v === '53' && o.d)) problems.push(`build picker: want 52 pickable and 53 (PROCESSING) disabled, got ${JSON.stringify(opts)}`);
|
||||
notes.push(`build picker: ${opts.filter(o => o.v).map(o => `${o.v}${o.d ? ' (disabled)' : ''}`).join(', ')}`);
|
||||
// the owner action: disabled until a build is picked, then the plan panel, then the dialog (answered Cancel here)
|
||||
const action = page.locator('[data-section="submit"] button', { hasText: variant === 'ready' ? 'Submit for review' : 'Resubmit' }).first();
|
||||
if (!(await action.isDisabled())) problems.push('the submit button is enabled before a build is picked');
|
||||
await page.locator('[data-section="submit"] select').first().selectOption('52');
|
||||
await action.click({ timeout: 5000 });
|
||||
await page.waitForSelector('.oa-panel', { timeout: 8000 }).catch(() => problems.push('no plan panel after the submit button'));
|
||||
const panel = await text('.oa-panel');
|
||||
for (const want of ['Plan (dry run, nothing sent yet)', 'STEP', 'attach build 52', 'a system dialog shows this plan again']) if (!panel.includes(want)) problems.push(`plan panel: missing "${want}"`);
|
||||
await page.locator('.oa-panel .lb-foot button', { hasText: 'Continue' }).click({ timeout: 5000 }).catch(e => problems.push(`Continue: ${String(e).split('\n')[0]}`));
|
||||
await page.waitForTimeout(500);
|
||||
if (!(await text('.oa-panel')).includes('nothing was sent')) problems.push('plan panel: no "nothing was sent" after the dialog said Cancel');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
if (await page.locator('.oa-panel').count()) problems.push('plan panel does not close with Escape');
|
||||
const want = variant === 'ready' ? 'owner.apple_submit' : 'owner.apple_resubmit';
|
||||
if (!live.calls.some(c => c.startsWith(`dry ${want} {"build":"52","version":"2.4.0"}`))) problems.push(`no dry run of ${want} with build 52 and version 2.4.0: ${live.calls.join(' | ')}`);
|
||||
if (!live.calls.some(c => c.startsWith(`write_with_confirm ${want}`))) problems.push(`${want} did not go through write_with_confirm`);
|
||||
// Google Play: versionCodes by number (one already in production cannot be picked), then the production plan
|
||||
const codes = page.locator('[data-section="submit"] select[aria-describedby="sub-code-hint"]');
|
||||
const codeOpts = await codes.locator('option').evaluateAll(os => os.map(o => `${(o as HTMLOptionElement).value}${(o as HTMLOptionElement).disabled ? '!' : ''}`));
|
||||
if (JSON.stringify(codeOpts) !== JSON.stringify(['', '24', '23!', '22'])) problems.push(`versionCode picker: ${JSON.stringify(codeOpts)}`);
|
||||
await codes.selectOption('24');
|
||||
await page.locator('[data-section="submit"] button', { hasText: 'Send to production' }).first().click({ timeout: 5000 });
|
||||
await page.waitForSelector('.oa-panel', { timeout: 8000 }).catch(() => problems.push('no plan panel after Send to production'));
|
||||
const pp = await text('.oa-panel');
|
||||
for (const w of ['--version-code 24 --production completed', 'managed publishing is off']) if (!pp.includes(w)) problems.push(`production plan: missing "${w}"`);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
if (!live.calls.some(c => c.startsWith('dry owner.play_production {"version_code":"24","status":"completed"}'))) problems.push(`no dry run of owner.play_production 24: ${live.calls.join(' | ')}`);
|
||||
// a console plan from the real sidecar (offline): Play's Send changes for review
|
||||
await page.locator('[data-section="submit"] button', { hasText: 'Send changes for review' }).first().click({ timeout: 5000 });
|
||||
await page.waitForSelector('.cp-panel', { timeout: 10000 }).catch(() => problems.push('no console plan panel'));
|
||||
const cp = await text('.cp-panel');
|
||||
for (const w of ['send the first production release for review', 'Send changes for review', 'Check the result']) if (!cp.includes(w)) problems.push(`console plan: missing "${w}"`);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await crashCheck('Releases');
|
||||
visited++;
|
||||
// every tab of the app, then App info's console steps and the Dependencies screen
|
||||
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
|
||||
for (const id of tabs) {
|
||||
await page.locator(`[role="tab"]#${id}`).click({ timeout: 5000 }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`));
|
||||
await page.waitForTimeout(500);
|
||||
await crashCheck(id);
|
||||
visited++;
|
||||
if (id === 'tab-appinfo') {
|
||||
const ai = await text();
|
||||
for (const w of ['Console steps without an API', 'Play store settings (App category)', 'Production countries', 'Play: send the production release for review']) if (!ai.includes(w)) problems.push(`App info: missing "${w}"`);
|
||||
}
|
||||
if (id === 'tab-testflight' && !(await text()).length) problems.push('TestFlight: empty');
|
||||
}
|
||||
notes.push(`tabs: ${tabs.join(', ')}`);
|
||||
const deps = page.locator('nav.side button.nav', { hasText: 'Dependencies' }).first();
|
||||
await deps.click({ timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(800);
|
||||
if (!(await text()).includes('Android SDK')) problems.push('Dependencies: no Android SDK card');
|
||||
await crashCheck('Dependencies');
|
||||
visited++;
|
||||
notes.push(`store calls: ${live.calls.length} (${[...new Set(live.calls.map(c => c.split(' ').slice(0, 2).join(' ')))].join(', ')})`);
|
||||
} catch (e) {
|
||||
problems.push(`case stopped: ${String(e).split('\n')[0]}`);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
server.stop(true);
|
||||
}
|
||||
for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`);
|
||||
if (unmocked.size) notes.push(`not answered (the window handled the rejection): ${[...unmocked].join(', ')}`);
|
||||
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
|
||||
}
|
||||
|
||||
async function launch(): Promise<Browser> {
|
||||
const { chromium } = await import('playwright');
|
||||
try { return await chromium.launch({ channel: 'chrome', headless: true }); } catch { /* no Google Chrome */ }
|
||||
|
|
@ -274,6 +429,8 @@ export async function bootCheck(): Promise<CaseResult[]> {
|
|||
try {
|
||||
for (const n of names) if (!ONLY.length || ONLY.includes(n)) results.push(await runCase(browser, n, sidecar));
|
||||
if (!ONLY.length || ONLY.includes('missing-bundle')) results.push(await runCase(browser, 'missing-bundle', sidecar, { breakBundle: true }));
|
||||
for (const base of ['d3-full', 'd1-missing-folders'] as const) for (const v of ['ready', 'unresolved'] as const)
|
||||
if (!ONLY.length || ONLY.includes('submit') || ONLY.includes(`submit-${v}`)) results.push(await runSubmitCase(browser, sidecar, base, v));
|
||||
} finally {
|
||||
await browser.close();
|
||||
sidecar.stop();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue