diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6400b..0f6f40b 100644 --- a/CHANGELOG.md +++ b/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 [--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 [--resolve-iaps]`), `owner.apple_iap_submit` (`apple iap-submit [--with-version]`) and + `owner.play_production` (`play release --version-code --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 diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index bb026fe..75877e2 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -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[]; 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 } diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 83839bc..4e7f2f6 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -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 = { 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 = { 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 = {}): 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 = { }; const CACHED_AGO: Record = { 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): Promise 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): Promise 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; diff --git a/apps/desktop/src/dev/submit-fixtures.ts b/apps/desktop/src/dev/submit-fixtures.ts new file mode 100644 index 0000000..40891c0 --- /dev/null +++ b/apps/desktop/src/dev/submit-fixtures.ts @@ -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) { + 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
--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: [], +}; diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 9535c62..4ef8498 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 6bdde52..1a436cf 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -229,7 +229,6 @@ export const tr: Record = { '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 = { '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}', }; diff --git a/apps/desktop/src/lib/progress.ts b/apps/desktop/src/lib/progress.ts new file mode 100644 index 0000000..78d72fb --- /dev/null +++ b/apps/desktop/src/lib/progress.ts @@ -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); diff --git a/apps/desktop/src/lib/submit.ts b/apps/desktop/src/lib/submit.ts new file mode 100644 index 0000000..ef55545 --- /dev/null +++ b/apps/desktop/src/lib/submit.ts @@ -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; +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; + 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 = { WAITING_FOR_REVIEW: 'waiting', IN_REVIEW: 'inReview', UNRESOLVED_ISSUES: 'unresolved', READY_FOR_REVIEW: 'draft', COMPLETE: 'done', CANCELING: 'other' }; +const ITEM_NAME: Record = { 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(); + 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(); + 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); diff --git a/apps/desktop/src/screens/Dependencies.tsx b/apps/desktop/src/screens/Dependencies.tsx index b019541..3d7cc20 100644 --- a/apps/desktop/src/screens/Dependencies.tsx +++ b/apps/desktop/src/screens/Dependencies.tsx @@ -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(d.sidecar?.tools?.tools ?? null); + const [sdk, setSdk] = useState(d.sidecar?.tools?.androidSdk ?? null); const [info, setInfo] = useState([]); const [error, setError] = useState(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('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() { )} + {sdk && ( + {t('deps.installed')} : <>{t('deps.missing')}}> + {sdk.path ? ( +
+
{t('deps.sdkPath')}
{shortPath(sdk.path, d.info?.home)}
+
{t('deps.sdkFrom')}
{sdk.source}
+
+ ) : ( +
+ {t('deps.sdkNone')} +
    {sdk.checked.map((c, i) =>
  • {d.info?.home ? c.split(d.info.home).join("~") : c}
  • )}
+
+ )} +
+ )}
{info.map(m => ( diff --git a/apps/desktop/src/screens/tabs/AppInfo.tsx b/apps/desktop/src/screens/tabs/AppInfo.tsx index 4325fd0..f97ea67 100644 --- a/apps/desktop/src/screens/tabs/AppInfo.tsx +++ b/apps/desktop/src/screens/tabs/AppInfo.tsx @@ -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): { 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 }) { + const v = deletionView(ds); + if (!v.answer) return {t('ai.notAnswered')}; + return ( + + {v.answer === 'auto90' ? t('ai.dsAuto90') : v.answer === 'yes' ? t('common.yes') : t('common.no')} + {v.source && {t('ai.dsFrom', { source: v.source })}} + {v.url && {v.url}} + + ); +} + 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
{t('ai.dsCollects')}
{yes(ds.collectsData)}
{t('ai.dsEncrypted')}
{yes(ds.encryptedInTransit)}
- {!ds.accountCreation.includes('PSL_ACM_NONE') && <>
{t('ai.dsDeletion')}
{ds.partialDeletion === 'auto90' ? t('ai.dsAuto90') : yes(ds.partialDeletion)}
} + {!ds.accountCreation.includes('PSL_ACM_NONE') && <>
{t('ai.dsDeletion')}
}
{t('ai.dsAccounts')}
{ds.accountCreation.map(x => typeLabel(x.replace('ACM_', ''))).join(', ')}
{up === null ? {t('ai.dsNever')} @@ -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 ( + +
+ {forms.map(f => ( +
+ +
{t(f.name)}{t(f.text)}
+
+ + {f.form in CONSOLE_FORM_NAME && } +
+
+ ))} +
+
+ ); +} + export function AppInfoTab({ project }: { project: Project }) { const data = useData(); const e = data.statusOf(project); @@ -192,19 +235,8 @@ export function AppInfoTab({ project }: { project: Project }) {
)} - {d.stores.play && ( - {t('cf.playSub')}} pad={false}> -
- {([['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]) => ( -
- -
{t(title)}{t(text)}
- -
- ))} -
-
- )} + +

{t('ai.foot', { when: ago(r.at) })}

); diff --git a/apps/desktop/src/screens/tabs/Catalog.tsx b/apps/desktop/src/screens/tabs/Catalog.tsx index cc90e05..1eb33cc 100644 --- a/apps/desktop/src/screens/tabs/Catalog.tsx +++ b/apps/desktop/src/screens/tabs/Catalog.tsx @@ -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
Google Play
{t('act.playProducts')} {t('act.playSubs')} + {products > 0 && } + {subs > 0 && } )} {ios && ( @@ -46,6 +49,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project
App Store
{t('act.appleIap')} {t('act.appleSubs')} + {subs > 0 && } )}
diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index aed2213..d05f5c5 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -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(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) ? ( +
+

{t('sub.section')}

+
+ {sum?.apple && } + {sum?.play && } +
+

{t('releases.ownerNote')}

+
+ ) : null; if (!l) { return ( + <> {project && !project.playAccount && !project.appleAccount && {t('releases.noAccounts')}} }>{t('releases.notReadText')} + {submit} + ); } const history = versionHistory(l.release.all ?? []); @@ -164,10 +179,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { ) : } - - {l.app?.platforms.ios && } - {l.app?.platforms.android && } -
}>{t('releases.consoleNote')} + {submit} ); } diff --git a/apps/desktop/src/screens/tabs/Submit.tsx b/apps/desktop/src/screens/tabs/Submit.tsx new file mode 100644 index 0000000..e0243da --- /dev/null +++ b/apps/desktop/src/screens/tabs/Submit.tsx @@ -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 = { + 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(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 ( + {r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.appleSub')}} + actions={<> + + + }> + {r.error && } + {!s ? (r.loading && r.data === null && !r.error ? : {t('sub.notReadText')}) : ( +
+ {s.submissions.length > 0 && ( +
+ {s.submissions.filter(x => x.kind !== 'done').slice(0, 6).map(x => { + const [tone, key] = SUB_KIND[x.kind]; + return ( +
+ +
+ {t(key)} + {x.id} · {x.state}{x.date ? ` · ${date(x.date)}` : ''}{x.kind === 'emptyDraft' ? ` · ${t('sub.harmless')}` : ''} + {x.items.length > 0 && ( +
    + {x.items.map(i => ( +
  • {i.label}: {i.state}{i.rejected ? ` · ${i.kind === 'appStoreVersion' ? t('sub.markVersion') : t('sub.markIap')}` : ''}
  • + ))} +
+ )} +
+ {x.state} +
+ ); + })} +
+ )} + {!s.submissions.some(x => x.kind !== 'done') && {t('sub.noOpen')}} + {s.busy && {t('sub.busy', { state: s.busy.state })}} + {!s.busy && ( + <> +
+ +
+ {t('sub.version')} + {version ? `${version.version} · ${version.state}` : {t('sub.noVersion')}} +
+
+ {s.builds.length ? t('sub.buildHint') : t('sub.noBuilds')} + {s.unresolved ? ( + <> + {t('sub.unresolvedText')} + +
+ +
+ + ) : ( +
+ +
+ )} + {iaps > 0 && ( +
+ {t('sub.iapTitle', { count: iaps })} + {t('sub.iapText')} +
+ + +
+
+ )} + + )} +
+ )} +
+ ); +} + +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 ( + {r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.playSub')}} + actions={<> + + + }> + {r.error && } +
+ {pr && pr.managedPublishing !== true && {pr.managedPublishing === false ? t('sub.managedOff') : t('sub.managedUnset')}} + {pr && pr.managedPublishing === true && {t('sub.managedOn')}} + {!s ? (r.loading && r.data === null && !r.error ? : {t('sub.playNotReadText')}) : ( + <> + {s.production.length > 0 && ( + {t('sub.prodNow')}: {s.production.map(p => `${p.name ?? p.codes.join(', ')} · ${p.status}${p.fraction !== null ? ` · ${Math.round(p.fraction * 100)} %` : ''}`).join('; ')} + )} +
+ +
+ {t('sub.as')} + +
+ {status === 'inProgress' && ( + + )} +
+ {s.codes.length ? t('sub.codeHint') : t('sub.noCodes')} +
+ { r.refresh(); }} /> +
+ + )} + + + +
}> +
+ {t('sub.consoleStepTitle')} + {t('sub.consoleStepText', { countries: pr?.countries ? t('sub.countriesN', { count: pr.countries }) : t('sub.countriesAll') })} +
+ + +
+ ); +} diff --git a/apps/desktop/src/screens/tabs/TestFlight.tsx b/apps/desktop/src/screens/tabs/TestFlight.tsx index a229b11..2e28e90 100644 --- a/apps/desktop/src/screens/tabs/TestFlight.tsx +++ b/apps/desktop/src/screens/tabs/TestFlight.tsx @@ -100,7 +100,11 @@ export function TestFlightTab({ app }: { app: UnifiedApp }) { {r.error && {t('common.readFailedKeep', { error: r.error })}} - {s.missingGroups.length > 0 && {t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}} + {s.missingGroups.length > 0 && ( + {t('tf.groupsSync')} : undefined}> + {t('tf.missingGroups', { groups: s.missingGroups.join(', ') })} + + )}
{s.groups.map(g => (
diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index c65a0b9..c9b32fb 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -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) { diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index ab62916..5a83d17 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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; } diff --git a/apps/desktop/src/ui/ConsolePlan.tsx b/apps/desktop/src/ui/ConsolePlan.tsx new file mode 100644 index 0000000..3de0bb8 --- /dev/null +++ b/apps/desktop/src/ui/ConsolePlan.tsx @@ -0,0 +1,174 @@ +// Console steps without an API, agent-first (`storebound console plan --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 `: 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 --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 --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(['apple-app-record', 'apple-age-rating']); +const STATUS: Record = { certain: ['ok', 'cp.sCertain'], 'ask-owner': ['warn', 'cp.sAsk'], 'owner-check': ['info', 'cp.sCheck'], agreement: ['bad', 'cp.sAgreement'] }; +const ROW: Record = { 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(null); + const [busy, setBusy] = useState(false); + const open = async () => { + setBusy(true); + try { + const r = await api.call('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 ( + <> + + {plan && setPlan(null)} />} + + ); +} + +function PlanPanel({ project, plan, onClose }: { project: Project; plan: Plan; onClose: () => void }) { + const d = useData(); + const root = useRef(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('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 ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+
+

{plan.title}

+ {t('cp.agentFirst')} +
+ api.openExternal(plan.start.url).catch(e => d.toast({ kind: 'bad', title: t('cp.openFailed'), detail: errorText(e) }))}>{t('cp.openPage')}}> +
+ {plan.start.url} + {plan.start.reach.length > 0 && {plan.start.reach.join(' > ')}} +
+
+
+ {t('cp.steps', { count: plan.steps.length })} +
    + {plan.steps.map(s => ( +
  1. {s.text}{s.click && s.click.length > 0 && {s.click.map((c, i) => {c})}}
  2. + ))} +
+
+ {plan.answers.length > 0 && ( +
+
{t('cp.answers', { count: plan.answers.length })}{asks.length > 0 && {t('cf.uncertain', { count: asks.length })}}
+ + + {plan.answers.map(a => { + const [tone, key] = STATUS[a.status] ?? STATUS['ask-owner']!; + return ( + + + + + + ); + })} + +
{a.label ?? a.question}
{a.note &&
{a.note}
}
{a.status === 'certain' ? {a.text} : {a.text}}{t(key)}
+
+ )} + +
+ {t('cp.final', { button: plan.buttons.final || '—' })} + {plan.buttons.neverPress.slice(0, 14).map(b => {b})} +
+
+
+ {t('cp.verify')} + {apiVerify ? t('cp.verifyApi') : t('cp.verifyText')} + {!apiVerify &&