From 1bb43fd4a0b8147d5e223fc25003c53172e09dd4 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 11:56:07 +0800 Subject: [PATCH 001/126] play managed publishing: console plan/verify play-publish-changes, ready-to-publish items in play status, desktop hint --- CHANGELOG.md | 1 + apps/desktop/src/i18n/en.ts | 2 ++ apps/desktop/src/i18n/tr.ts | 2 ++ apps/desktop/src/screens/tabs/Releases.tsx | 16 ++++++++++ cli/console/plan.ts | 6 ++++ cli/console/verify.ts | 4 +++ cli/lib/console-answers.ts | 2 +- cli/lib/console-plan.ts | 2 ++ cli/lib/console-play-plans.ts | 32 +++++++++++++++++++ cli/lib/console-verify.ts | 18 +++++++++++ cli/lib/play-publish.ts | 29 +++++++++++++++++ cli/lib/train.ts | 2 +- cli/mcp-tools.ts | 2 +- cli/play/publish-ready.ts | 18 +++++++++++ cli/play/status.ts | 4 ++- docs/pitfalls.md | 1 + skills/console-autofill/SKILL.md | 2 +- skills/play-first-release/SKILL.md | 3 ++ skills/store-pitfalls/SKILL.md | 11 +++++++ tests/console-play-plans.test.ts | 36 +++++++++++++++++++--- 20 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 cli/lib/play-publish.ts create mode 100644 cli/play/publish-ready.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f63eba..1ec495d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased +- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. - Desktop dev update channel: Settings > Updates offers Stable (the npm releases) and Dev (every push to main). Each push to main builds Windows and Linux bundles stamped `-dev.` (above the current stable, below the next one); the VDS signs them and serves `storebound.dev/desktop/dev/latest.json` (newest 5 kept). The app checks its channel at start, every 15 minutes, after sleep and right after a channel switch, installs a strictly newer build quietly (deferred while the window is in use), never downgrades (dev back to stable waits for a newer stable), rewrites its logon autostart on every start, and the tray card shows version and channel. No macOS dev builds (Developer ID signing needs the Mac's GUI session). - `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `///.png` (feature: `/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`. - `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID. diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8424ef3..3e3d6ba 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -154,6 +154,8 @@ export const en = { 'phase.pageUnknown': "Couldn't check the store page", 'phase.pageStale': "couldn't check the store page, as of {when}", 'phase.reviewLive': 'In review (live: {version})', + 'roll.publishReady': 'If the release is approved, publish it in Publishing overview ("Publish N changes"). The agent prepares it with `{cmd}`; the final press is for the owner.', + 'roll.publishOpen': 'Open Publishing overview', 'phase.reviewManaged': 'In review / ready to publish (managed publishing)', 'phase.rejected': 'Rejected', 'phase.testing': 'Testing', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 935eac8..8901d14 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -156,6 +156,8 @@ export const tr: Record = { 'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi', 'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla', 'phase.reviewLive': 'İncelemede (yayında: {version})', + 'roll.publishReady': 'Sürüm onaylandıysa Yayınlama özetinde yayınlayın ("Publish N changes"). Ajan `{cmd}` ile hazırlar; son basış sahibine aittir.', + 'roll.publishOpen': 'Yayınlama özetini aç', 'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)', 'phase.rejected': 'Reddedildi', 'phase.testing': 'Testte', diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index ee83644..fdf96ac 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -11,6 +11,7 @@ import { WriteButton } from '../../ui/WriteButton'; import { type Key, t } from '../../i18n'; import { ago, date, share } from '../../lib/format'; import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model'; +import { api } from '../../api'; import { useData, useRead, useWrite } from '../../store'; import { Icon, StoreGlyph } from '../../ui/icons'; import { RolloutBar } from '../../ui/charts'; @@ -19,6 +20,8 @@ import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds import { AppleSubmitCard, PlayProductionCard } from './Submit'; import { useContextMenu } from '../../ui/ContextMenu'; import { releaseMenu } from '../../ui/app-menu'; +import { consoleUrl } from '../../../../../cli/lib/console-links'; +import { PUBLISH_PLAN_HINT } from '../../../../../cli/lib/play-publish'; import '../../styles/releases.css'; const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' }; @@ -92,11 +95,24 @@ function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: )} )} + {r && r.store === 'android' && r.managed && r.phase === 'review' && } {r && app.project && } ); } +/** Managed publishing: the approved production release waits in Publishing overview ("Publish N changes"). */ +function PublishHint({ st }: { st: AppStatus }) { + const a = st.app?.platforms.android; + const url = consoleUrl('play.publishing', { playDeveloperId: a?.developerId, playAppId: a?.appId }); + return ( +
+ {t('roll.publishReady', { cmd: PUBLISH_PLAN_HINT })} + +
+ ); +} + const SHARES = [1, 2, 5, 10, 20, 50]; /** Rollout controls of one store release: Play staged rollout share / halt / resume / complete, iOS phased release diff --git a/cli/console/plan.ts b/cli/console/plan.ts index 846645b..be96c28 100644 --- a/cli/console/plan.ts +++ b/cli/console/plan.ts @@ -47,6 +47,12 @@ export async function run(ctx: Ctx): Promise { const { projectSignals } = await import('../lib/project-signals'); ids.signals = projectSignals(detectProject(root)); } catch { /* not scanned */ } + if (form === 'play-publish-changes' && args.has('lookup') && cfg.play?.packageName) { + try { + const { readyFromApi } = await import('../play/publish-ready'); + ids.readyChanges = await readyFromApi(root, cfg); + } catch { /* not read: the plan says so */ } + } if (args.has('all') && form !== 'play-app-content') throw new Error('console plan: --all is for play-app-content (every App content card inline)'); const plan = consolePlan(form, cfg, ids, { all: args.has('all') }); if (json) return printJson(plan); diff --git a/cli/console/verify.ts b/cli/console/verify.ts index 09d0d45..5310af6 100644 --- a/cli/console/verify.ts +++ b/cli/console/verify.ts @@ -39,6 +39,10 @@ export async function run(ctx: Ctx): Promise { const text = inline ?? await readText(from!); if (!text.trim()) throw new Error('console verify: the page text is empty'); r = verifyText(form, config, text); + } else if (form === 'play-publish-changes') { + const { readyFromApi } = await import('../play/publish-ready'); + const { verifyPublishChangesApi } = await import('../lib/console-verify'); + r = verifyPublishChangesApi(await readyFromApi(root, config)); } else if (form === 'apple-app-record' || form === 'apple-age-rating') { const bundleId = config.apple?.bundleId; if (!bundleId) throw new Error('console verify: apple.bundleId is not set'); diff --git a/cli/lib/console-answers.ts b/cli/lib/console-answers.ts index a585937..8294abb 100644 --- a/cli/lib/console-answers.ts +++ b/cli/lib/console-answers.ts @@ -29,7 +29,7 @@ export type FormId = typeof FORM_IDS[number]; * API cannot set), the App content overview and its declarations without a fill form (cli/lib/console-play-plans.ts). */ export const PLAN_ONLY_FORM_IDS = ['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', - 'play-games-services'] as const; + 'play-games-services', 'play-publish-changes'] as const; export type PlanOnlyFormId = typeof PLAN_ONLY_FORM_IDS[number]; /** Every form `console plan` / `console verify` know. */ export const PLAN_FORM_IDS = [...FORM_IDS, ...PLAN_ONLY_FORM_IDS] as const; diff --git a/cli/lib/console-plan.ts b/cli/lib/console-plan.ts index 7eeac67..ff11b8c 100644 --- a/cli/lib/console-plan.ts +++ b/cli/lib/console-plan.ts @@ -108,6 +108,8 @@ export interface PlanIds { demoAccount?: boolean; /** What the project scan found (ad / attribution SDKs, the AD_ID permission, support and AR code); undefined: not scanned. */ signals?: ProjectSignals; + /** play-publish-changes: the production releases ready to publish per the API (null/undefined: not read). */ + readyChanges?: { track: string; version: string | null; build: string | null; rollout: string }[] | null; } const ASC = 'https://appstoreconnect.apple.com'; diff --git a/cli/lib/console-play-plans.ts b/cli/lib/console-play-plans.ts index da3a979..51978b7 100644 --- a/cli/lib/console-play-plans.ts +++ b/cli/lib/console-play-plans.ts @@ -20,6 +20,7 @@ import { NEVER_PRESS, PLAY_NEVER_PRESS, playAppList } from './console-forms'; import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan'; import { AD_ID_PERMISSION, type ProjectSignals } from './project-signals'; import { playGamesPart } from './console-games-plan'; +import { PUBLISH_BUTTON, PUBLISH_CONFIRM } from './play-publish'; const PLAY = 'https://play.google.com/console'; @@ -503,10 +504,41 @@ function productionRelease(config: ShipConfig, p: PlanIds): Part { }; } +function publishChanges(config: ShipConfig, p: PlanIds): Part { + const ready = p.readyChanges ?? null; + const readyText = ready === null ? 'not read (add --lookup, or run `storebound play status`)' : ready.length ? ready.map(r => `${r.track} ${r.version ?? '?'} (${r.build ?? '?'}) ${r.rollout}`).join('; ') : 'nothing ready to publish per the API'; + return { + title: 'Play Console: publish the approved changes (managed publishing)', + start: start(config, p, 'publishing', ['Publishing overview (left menu)']), + steps: steps([ + { text: 'Precondition: `storebound play status` shows "production : In review / ready to publish" (managed publishing on, Google approved).' }, + { text: 'Open Publishing overview. The section "Changes ready to publish" lists every approved change (e.g. the production release and Data safety); read the list as text and compare it with the ready changes below.' }, + { text: `Stop. The owner presses "${PUBLISH_BUTTON}" (N = the number in the list) and then confirms in its dialog (button "${PUBLISH_CONFIRM}"; a real mouse click, a JS .click() may do nothing). It makes every listed change live at once.`, answers: ['ready'] }, + { text: 'Verify: `storebound console verify play-publish-changes` (API: no production release is left ready to publish) or pipe the page text to `--from-text -`.' }, + ]), + answers: [ans('ready', 'Changes ready to publish (API)', readyText, 'certain', 'play status (public store page + tracks)', { note: 'The API sees production releases only; Data safety, listing and other non-track changes show in the console list only.', where: 'Publishing overview > Changes ready to publish' })], + final: PUBLISH_BUTTON, + neverPress: ['Publish N changes', 'Send changes for review', 'Submit N changes for review', 'Restart review', 'Discard changes', 'Stop managed publishing', 'Turn off managed publishing', 'Halt rollout', 'Unpublish app', 'Deactivate', ...NEVER_PRESS], + allowed: [ + { button: 'Publishing overview (left menu), expand Changes ready to publish', class: 'navigation', where: 'read-only: lists the approved changes' }, + ], + uiFacts: [ + 'Managed publishing (play.managedPublishing: true): after approval the changes wait in Publishing overview > "Changes ready to publish"; the button is "Publish N changes" (N counts every change, e.g. production full rollout + Data safety) and opens a confirm dialog.', + 'The Developer API has no publishing-state endpoint: storebound infers "ready to publish" from a completed production release the public store page does not show yet.', + 'Never "Discard changes" or "Stop managed publishing" (it would unpublish or drop the approved changes).', + managedPublishingNote(config), + PLAY_MATERIAL_FACT, + ], + verify: { command: 'storebound console verify play-publish-changes', source: 'page-text', capture: PAGE_TEXT, expect: ['No production release is left ready to publish (API), or the page shows no "Publish N changes" button / "Changes ready to publish".'] }, + guidance: ['Open: Publishing overview > Changes ready to publish'], + }; +} + export function playOnlyPart(form: Exclude, config: ShipConfig, p: PlanIds): Part { switch (form) { case 'play-production-countries': return productionCountries(config, p); case 'play-production-release': return productionRelease(config, p); + case 'play-publish-changes': return publishChanges(config, p); case 'play-store-settings': return storeSettings(config, p); case 'play-privacy-policy': return privacyPolicy(config, p); case 'play-ads': return adsDeclaration(config, p); diff --git a/cli/lib/console-verify.ts b/cli/lib/console-verify.ts index 797e227..b68db83 100644 --- a/cli/lib/console-verify.ts +++ b/cli/lib/console-verify.ts @@ -426,11 +426,29 @@ export function verifyProductionRelease(text: string): VerifyResult { return result('play-production-release', 'page-text', rows); } +/** Publishing overview as text (or the API's ready list): nothing left to publish. */ +export function verifyPublishChanges(text: string): VerifyResult { + const f = fold(text); + const pending = /publish \d+ changes?|changes ready to publish/.test(f) && !/no changes ready to publish|0 changes ready/.test(f); + const rows: VerifyRow[] = [{ item: 'Changes ready to publish', expected: 'none left', seen: pending ? 'changes ready to publish' : 'none listed', status: pending ? 'mismatch' : 'match', + ...(pending ? { note: 'the owner presses "Publish N changes" and confirms' } : {}) }]; + return result('play-publish-changes', 'page-text', rows); +} + +/** The same check from the API's ready list (`readyToPublish`). */ +export function verifyPublishChangesApi(ready: { track: string; version: string | null; build: string | null; rollout: string }[]): VerifyResult { + const seen = ready.map(r => `${r.track} ${r.version ?? '?'} (${r.build ?? '?'}) ${r.rollout}`).join('; '); + const rows: VerifyRow[] = [{ item: 'Production releases ready to publish', expected: 'none left', seen: ready.length ? seen : 'none', status: ready.length ? 'mismatch' : 'match', + ...(ready.length ? { note: 'the owner presses "Publish N changes" and confirms (storebound console plan play-publish-changes)' } : {}) }]; + return result('play-publish-changes', 'api', rows, ['The API sees production releases only; Data safety and other non-track changes are not readable.']); +} + /** Page-text verification of any form that has one. */ export function verifyText(form: PlanFormId, config: ShipConfig, text: string): VerifyResult { if (form === 'play-app-content') return verifyAppContent(text); if (form === 'play-production-countries') return verifyProductionCountries(config, text); if (form === 'play-production-release') return verifyProductionRelease(text); + if (form === 'play-publish-changes') return verifyPublishChanges(text); if (form === 'play-games-services') return result(form, 'page-text', verifyPlayGames(config, text)); if (form === 'play-store-settings' || form === 'play-privacy-policy' || form === 'play-ads' || form === 'play-sign-in-details' || form === 'play-advertising-id' || form === 'play-government-apps' || form === 'play-financial-features' || form === 'play-health-apps') return verifyPlayDeclaration(form, config, text); diff --git a/cli/lib/play-publish.ts b/cli/lib/play-publish.ts new file mode 100644 index 0000000..d4f5c41 --- /dev/null +++ b/cli/lib/play-publish.ts @@ -0,0 +1,29 @@ +// Play managed publishing: what waits in Publishing overview as "Changes ready to publish" (button "Publish N changes"). +// The Developer API has no publishing-state endpoint; `play status` decides it the same way portfolio.playReleases does: +// a completed production release that the public store page does not show yet (404, or an older version) while +// play.managedPublishing is on is "In review / ready to publish". Pure. Non-track changes (Data safety, listing, content +// rating) are listed by the console page only; the API cannot see them. +import { playReleases } from './portfolio'; +import { consoleUrl } from './console-links'; + +export const PUBLISH_PLAN_HINT = 'storebound console plan play-publish-changes'; +/** The owner's final button: "Publish N changes" (N is the page's count) and its confirm dialog. */ +export const PUBLISH_BUTTON = 'Publish N changes'; +export const PUBLISH_CONFIRM = 'Publish'; + +export interface ReadyChange { track: string; version: string | null; build: string | null; rollout: string; liveVersion: string | null } + +/** Ready-to-publish production releases from the `play status --json` data ({ tracks, publicPage, managedPublishing }). */ +export function readyToPublish(data: unknown): ReadyChange[] { + return playReleases(data).filter(r => r.track === 'production' && r.managed === true && r.phase === 'review').map(r => ({ + track: r.track ?? 'production', version: r.version, build: r.build, + rollout: r.fraction !== null && r.fraction < 1 ? `${Math.round(r.fraction * 1000) / 10}% staged` : 'full rollout', liveVersion: r.liveVersion ?? null, + })); +} + +/** Status lines for `play status` / `status`: the items and the hint (empty when nothing is ready). */ +export function readyLines(items: ReadyChange[]): string[] { + if (!items.length) return []; + return [` ready to publish (managed publishing): ${items.map(i => `${i.track} ${i.version ?? '?'} (${i.build ?? '?'}) ${i.rollout}${i.liveVersion ? `, live: ${i.liveVersion}` : ''}`).join(' | ')}; non-track changes (Data safety, listing) show only in the console`, + ` next: ${PUBLISH_PLAN_HINT} (${consoleUrl('play.publishing')})`]; +} diff --git a/cli/lib/train.ts b/cli/lib/train.ts index 1cdb660..86cac78 100644 --- a/cli/lib/train.ts +++ b/cli/lib/train.ts @@ -239,7 +239,7 @@ function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null, break; case 'review': if (store === 'android' && r!.track === 'production') { - steps.push({ kind: r!.managed ? 'owner' : 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: r!.managed ? `${consoleLink('play.publishing')} (publish once approved)` : 'storebound play status', ...(r!.managed ? { url: consoleUrl('play.publishing') } : {}) }); + steps.push({ kind: r!.managed ? 'owner' : 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: r!.managed ? `${consoleLink('play.publishing')} (publish once approved; \`storebound console plan play-publish-changes\`)` : 'storebound play status', ...(r!.managed ? { url: consoleUrl('play.publishing') } : {}) }); break; } steps.push({ kind: 'wait', text: `${v} is in review (${r!.state}); nothing to do until the store answers`, fix: store === 'ios' ? 'storebound apple status --wait-review' : 'storebound play status' }); diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 24c7644..c7972ae 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -40,7 +40,7 @@ export const notSentProp = { type: 'boolean', default: false, description: 'Comm /** Every form `console plan` / `console verify` know (cli/lib/console-answers.ts PLAN_FORM_IDS; a test keeps them equal). */ export const CONSOLE_PLAN_FORMS = ['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', 'play-games-services']; + 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', 'play-games-services', 'play-publish-changes']; export const TOOLS: ToolDef[] = [ { name: 'check', title: 'Offline config check', command: ['check'], write: false, network: false, diff --git a/cli/play/publish-ready.ts b/cli/play/publish-ready.ts new file mode 100644 index 0000000..14f5dfe --- /dev/null +++ b/cli/play/publish-ready.ts @@ -0,0 +1,18 @@ +// Read-only: the production releases waiting in Publishing overview (managed publishing), from the tracks and the public store page. +import type { ShipConfig } from '../lib/config'; +import { need } from '../lib/ctx'; +import { type ReadyChange, readyToPublish } from '../lib/play-publish'; +import { playPublicPage } from '../lib/play-public'; +import { playClient } from './client'; + +export async function readyFromApi(root: string, config: ShipConfig): Promise { + const pkg = need(config.play?.packageName, 'play.packageName'); + const play = await playClient(root, pkg); + let out: ReadyChange[] = []; + await play.read(async id => { + const tracks = (await play.call<{ tracks?: unknown[] }>('GET', `${play.API}/edits/${id}/tracks`)).tracks ?? []; + const publicPage = await playPublicPage(play.packageName); + out = readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }); + }); + return out; +} diff --git a/cli/play/status.ts b/cli/play/status.ts index 3097c3a..74214cc 100644 --- a/cli/play/status.ts +++ b/cli/play/status.ts @@ -10,6 +10,7 @@ import { playPublicPage } from '../lib/play-public'; import { playReleases, playReleaseLabel } from '../lib/portfolio'; import { playClient } from './client'; import { listOneTimeProducts } from './products'; +import { readyLines, readyToPublish } from '../lib/play-publish'; import { clearPending, pendingLine, publishingUrl, readPending } from './review-pending'; interface Track { track: string; releases?: { name?: string; versionCodes?: string[]; status: string; userFraction?: number }[] } @@ -32,7 +33,7 @@ export async function run(ctx: Ctx): Promise { // the App category is not in the API (edits.details has no category field): a console step const cat = playCategoryOf(config); const category = { config: cat?.category ?? null, source: cat?.source ?? null, api: false, plan: 'storebound console plan play-store-settings' }; - if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null, reviewPending }); return; } + if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null, reviewPending, readyToPublish: readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }) }); return; } console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`); console.log(` category: ${cat ? `${cat.category} (${cat.source})` : 'not in the config (play.category)'} · not readable by the API: Store settings is a console step (${category.plan})`); console.log(` listings (${listings.length}): ${listings.map(l => l.language).join(', ') || '-'}`); @@ -42,6 +43,7 @@ export async function run(ctx: Ctx): Promise { } console.log(` public store page: ${publicPage.state === 'public' ? `published (200), live version ${publicPage.versionName ?? (publicPage.varies ? 'varies with device' : 'not shown')}` : publicPage.state === 'notPublic' ? 'not public yet (404)' : `couldn't check it (${publicPage.error ?? 'not read'})`}${publicPage.stale ? ` (last known, ${publicPage.checkedAt.slice(0, 16).replace('T', ' ')} UTC)` : ''}`); for (const r of playReleases({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }).filter(x => x.track === 'production')) console.log(` production ${r.version ?? '?'} (${r.build ?? '?'}): ${playReleaseLabel(r)}`); + for (const l of readyLines(readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }))) console.log(l); if (pending) console.log(` review: ${pendingLine(pending)}`); console.log(` products (${products.length}):`); for (const p of products) console.log(` ${p.productId.padEnd(40)} ${(p.purchaseOptions ?? []).map(o => `${o.purchaseOptionId}:${o.state ?? '?'}`).join(' ')}`); diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 8056ceb..4f58aed 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 94 | 2026-10 | An app with managed publishing was approved but never went live: `play status` said "In review / ready to publish" with no next step, and the Publishing overview "Publish N changes" button (production + Data safety) was easy to miss or to confuse with Discard changes. | `console plan play-publish-changes` (final "Publish N changes" is the owner's, Discard changes / Stop managed publishing never-press), `console verify play-publish-changes` (API), the hint in `play status`, `status` and the desktop Releases tab (`cli/lib/play-publish.ts`, `tests/console-play-plans.test.ts`). | | 93 | 2026-10 | A git-ignored, never-captured frames folder rendered store images with no app or game in them; render only logged 404s and `shots check` said 0 errors. | `shots render` refuses to start while a scene frame is missing (`framesForRender` in `cli/shots/lib.ts`) and fails when any load failed; report.json `missing` is a `frame-missing` error in `shots check` / `validate` (`cli/shots/report.ts`, `tests/shots-missing-frames.test.ts`). | | 90 | 2026-10 | `apple submit --build N --version X --yes` after `apple submit --no-submit --yes` (the documented two-step flow) printed the plan (every item "in it already", then "submit PATCH submitted: true"), logged "not added ...: already in the draft submission" and ended with "nothing to submit" without the PATCH: the submit only ran for new items or an UNRESOLVED_ISSUES submission. | `willSubmit` (cli/lib/review-submit.ts) also submits a READY_FOR_REVIEW draft that already holds the named app version (iap-submit: one of the asked-for items), with the same "not ready yet" retry. `tests/resubmit.test.ts` ("the second step submits the prepared draft"). | | 85 | 2026-10 | `shots record --source web` filmed at the page's CSS size: Chromium's screencast ignores the deviceScaleFactor (maxWidth only shrinks), so a 1080x1920 preview of a 430x932 @2x page was a 2.5x upscale. | `Page.captureScreenshot` with `clip.scale` = dpr (long side capped at 1920), 3 requests in flight, timestamped frames joined by ffmpeg; a warning when the first frame is smaller than asked or the rate is under 20 frames/s. `recordSize` / `captureShortfall` in `cli/shots/devices.ts`, `tests/video.test.ts`. | diff --git a/skills/console-autofill/SKILL.md b/skills/console-autofill/SKILL.md index 1048a99..251dac8 100644 --- a/skills/console-autofill/SKILL.md +++ b/skills/console-autofill/SKILL.md @@ -45,7 +45,7 @@ the UI shows. This is the primary mode. `--all` / MCP `all: true` puts every card's steps inline in fix order and ends with the one owner list: tick the IARC Terms of Use box (before), Send changes for review (after)), its cards `play-privacy-policy`, `play-ads`, `play-sign-in-details`, `play-advertising-id`, `play-government-apps`, `play-financial-features`, `play-health-apps`, and the first production release: - `play-production-countries` and `play-production-release`. `storebound console plan` lists them. + `play-production-countries`, `play-production-release` and `play-publish-changes` (managed publishing). `storebound console plan` lists them. 2. Send the owner the `ownerBatch` of every form you will fill in ONE message before you start (AGENTS.md rule 5): the `ask-owner` answers (write them into the config, so the plan has them; never guess them) and the `before` steps (Play content rating: tick the IARC Terms of Use box on the questionnaire's page 1 first; page 1's Next does diff --git a/skills/play-first-release/SKILL.md b/skills/play-first-release/SKILL.md index c1e7f26..393544a 100644 --- a/skills/play-first-release/SKILL.md +++ b/skills/play-first-release/SKILL.md @@ -103,6 +103,9 @@ products) and answers the compliance facts no file proves, all in one batch at t Managed publishing is off by default: the app goes live automatically once Google approves it. If the owner wants to pick the moment, they turn on Publishing overview > Managed publishing first; record it in `play.managedPublishing` (`play release` and the plan warn while it is off). + With managed publishing on, `play status` shows "production X: In review / ready to publish" after approval and + names `storebound console plan play-publish-changes`: Publishing overview > "Changes ready to publish" > **Publish N + changes** (owner's final button; never Discard changes / Stop managed publishing). `console verify play-publish-changes` reads the API. 13. **Verify.** `storebound validate --play` (no blockers), `storebound play status` (tracks, listings, products), Console dashboard has no open tasks, Publishing overview shows "In review". diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 19f7785..c9fa7d2 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1365,3 +1365,14 @@ that fails without the fix (see CONTRIBUTING.md). with what was found. Opening waits up to 15 s: on Windows for a visible top-level window of a process from the installed path (a running `--hidden` tray copy is shown by the single-instance handler, so a second start without `--hidden` opens it), elsewhere for a non-`--hidden` process or a handoff (`tests/desktop-proc.test.ts`). + +## 114. Managed publishing: approved, but nothing went live +- **What happens:** with `play.managedPublishing: true` Google approves the release and it waits in Publishing overview + ("Changes ready to publish", button "Publish N changes", e.g. production plus Data safety). `play status` only said + "In review / ready to publish". +- **Do:** `storebound console plan play-publish-changes`, drive to Publishing overview, then the owner's "Publish N + changes" and its confirm dialog. Never "Discard changes" or "Stop managed publishing". Check with + `storebound console verify play-publish-changes` (API: no production release left ready to publish). +- **Guard:** `cli/lib/play-publish.ts` (ready list from tracks plus the public store page, the only signal the API + gives), the plan, the verify, the hint in `play status` / `status` / the desktop Releases tab. Non-track changes are + not visible to the API. diff --git a/tests/console-play-plans.test.ts b/tests/console-play-plans.test.ts index b7c6c65..f517dd9 100644 --- a/tests/console-play-plans.test.ts +++ b/tests/console-play-plans.test.ts @@ -7,7 +7,8 @@ import { consoleAnswers, PLAN_FORM_IDS, PLAN_ONLY_FORM_IDS } from '../cli/lib/co import { declarationSaveAllowed, flowAllowed, forbiddenControl, formDef, forbiddenTick, publishingControl } from '../cli/lib/console-forms'; import { consolePlan, IN_PAGE_LOOP, playConsoleReminders, planText } from '../cli/lib/console-plan'; import { APP_CONTENT_CARDS, PLAY_MATERIAL_FACT, startLabel } from '../cli/lib/console-play-plans'; -import { appPrivacyPublished, verifyAppContent, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify'; +import { readyLines, readyToPublish } from '../cli/lib/play-publish'; +import { appPrivacyPublished, verifyPublishChangesApi, verifyAppContent, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify'; import { CONSOLE_PLAN_FORMS, TOOLS, validateInput } from '../cli/mcp-tools'; import { playRules, type PlaySnapshot } from '../cli/lib/validate-rules'; @@ -27,7 +28,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch 'play-store-settings': 'store-settings', 'play-app-content': 'app-content/overview', 'play-privacy-policy': 'app-content/privacy-policy', 'play-ads': 'app-content/ads-declaration', 'play-sign-in-details': 'app-content/testing-credentials', 'play-advertising-id': 'app-content/ad-id-declaration', 'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health', 'play-games-services': 'play-games-services/setup', - 'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production', + 'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production', 'play-publish-changes': 'publishing', }; for (const form of PLAN_ONLY_FORM_IDS) { test(form, () => { @@ -45,7 +46,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch for (const b of ['Publish', 'Submit', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b); for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit'); // an App content card's Save is the agent's (verified 2026-10-13); the owner's button is Send changes for review - if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release', 'play-games-services'].includes(form)) { + if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release', 'play-games-services', 'play-publish-changes'].includes(form)) { expect(p.buttons.final).toBe('Send changes for review'); expect(p.buttons.neverPress).not.toContain('Save'); expect(p.buttons.allowed).toContainEqual(expect.objectContaining({ button: 'Save', class: 'declaration-save' })); @@ -58,7 +59,8 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch p.ownerBatch.forEach((o, i) => expect(o.n).toBe(i + 1)); expect(planText(p).indexOf('Owner batch')).toBeLessThan(planText(p).indexOf('Steps:')); expect(p.uiFacts.join('\n')).toContain(PLAY_MATERIAL_FACT); - expect(p.verify.command).toBe(`storebound console verify ${form} ${p.verify.capture.includes("aria-selected") ? "--from" : "--from-text"} -`); + // play-publish-changes verifies through the API (no page text needed) + expect(p.verify.command).toBe(form === 'play-publish-changes' ? 'storebound console verify play-publish-changes' : `storebound console verify ${form} ${p.verify.capture.includes("aria-selected") ? "--from" : "--from-text"} -`); expect(p.inPageLoop).toBe(IN_PAGE_LOOP); expect(planText(p)).toContain('window.__sb.run'); expect(JSON.parse(JSON.stringify(p))).toEqual(p); @@ -328,6 +330,32 @@ describe('the first production release: countries, managed publishing, the owner expect(verifyText('play-production-release', base, 'Publishing overview\nChanges in review').ok).toBe(true); }); + test('publish changes: Publish N changes is the final button, never-press holds Discard / Stop managed publishing, ready list from the API', () => { + const managed = { ...base, play: { ...base.play!, managedPublishing: true } } as ShipConfig; + const p = consolePlan('play-publish-changes', managed, { readyChanges: [{ track: 'production', version: '2.0.0', build: '45', rollout: 'full rollout' }] }); + expect(p.buttons.final).toBe('Publish N changes'); + for (const b of ['Discard changes', 'Stop managed publishing', 'Publish N changes']) expect(p.buttons.neverPress).toContain(b); + expect(p.answers[0]!.text).toContain('production 2.0.0 (45) full rollout'); + expect(p.steps.map(s => s.text).join('\n')).toContain('Changes ready to publish'); + expect(p.ownerBatch.at(-1)!.text).toContain('Publish N changes'); + expect(consolePlan('play-publish-changes', managed).answers[0]!.text).toContain('not read'); + }); + + test('publish changes: ready = managed + completed production the store page does not show; verify by API and by page text', () => { + const tracks = [{ track: 'production', releases: [{ name: '2.0.0 (45)', versionCodes: ['45'], status: 'completed' }] }]; + const ready = readyToPublish({ tracks, publicPage: { state: 'notPublic' }, managedPublishing: true }); + expect(ready).toEqual([{ track: 'production', version: '2.0.0', build: '45', rollout: 'full rollout', liveVersion: null }]); + expect(readyToPublish({ tracks, publicPage: { state: 'notPublic' }, managedPublishing: false })).toEqual([]); + expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '2.0.0' }, managedPublishing: true })).toEqual([]); + expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '1.9.0' }, managedPublishing: true })[0]!.liveVersion).toBe('1.9.0'); + expect(readyLines(ready).join('\n')).toContain('storebound console plan play-publish-changes'); + expect(readyLines([])).toEqual([]); + expect(verifyPublishChangesApi(ready).ok).toBe(false); + expect(verifyPublishChangesApi([]).ok).toBe(true); + expect(verifyText('play-publish-changes', base, 'Publishing overview\nChanges ready to publish\nPublish 2 changes').ok).toBe(false); + expect(verifyText('play-publish-changes', base, 'Publishing overview\nChanges in review').ok).toBe(true); + }); + test('validate: production with 0 countries is a blocker with a release on the track, a warning without', () => { const snap = { defaultLanguage: 'en-US', expectedLanguages: [], listings: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [] } as unknown as PlaySnapshot; expect(playRules({ ...snap, productionCountries: 0 }).find(f => f.code === 'countries')).toMatchObject({ level: 'warn', msg: 'production has 0 countries / regions (the API cannot set them)', fix: 'storebound console plan play-production-countries' }); From 4e2ffd6b3dc98c416f0ac6bfd6356800376c4d07 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:01:37 +0800 Subject: [PATCH 002/126] generated API tables refreshed for the play-publish-changes form --- cli/api/wrapped.gen.ts | 2 +- docs/api-coverage.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index 8693d61..1b484d8 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -254,7 +254,7 @@ export const WRAPPED: { apple: Record; play: Record Date: Sat, 24 Oct 2026 12:12:37 +0800 Subject: [PATCH 003/126] play-publish-changes: a release is ready only when the store page shows an older version; unknown reads are not determined, never a mismatch --- CHANGELOG.md | 2 +- apps/desktop/src/screens/tabs/Releases.tsx | 2 +- cli/lib/console-verify.ts | 7 ++++--- cli/lib/play-publish.ts | 16 +++++++++++++++- cli/play/publish-ready.ts | 10 ++++++---- tests/console-play-plans.test.ts | 18 +++++++++++++----- 6 files changed, 40 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec495d..b4f69d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased -- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. +- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch). - Desktop dev update channel: Settings > Updates offers Stable (the npm releases) and Dev (every push to main). Each push to main builds Windows and Linux bundles stamped `-dev.` (above the current stable, below the next one); the VDS signs them and serves `storebound.dev/desktop/dev/latest.json` (newest 5 kept). The app checks its channel at start, every 15 minutes, after sleep and right after a channel switch, installs a strictly newer build quietly (deferred while the window is in use), never downgrades (dev back to stable waits for a newer stable), rewrites its logon autostart on every start, and the tray card shows version and channel. No macOS dev builds (Developer ID signing needs the Mac's GUI session). - `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `///.png` (feature: `/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`. - `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID. diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index fdf96ac..17b99ad 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -95,7 +95,7 @@ function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: )} )} - {r && r.store === 'android' && r.managed && r.phase === 'review' && } + {r && r.store === 'android' && r.managed && r.phase === 'review' && !!r.liveVersion && !r.pageStaleAt && } {r && app.project && } ); diff --git a/cli/lib/console-verify.ts b/cli/lib/console-verify.ts index b68db83..730997d 100644 --- a/cli/lib/console-verify.ts +++ b/cli/lib/console-verify.ts @@ -436,11 +436,12 @@ export function verifyPublishChanges(text: string): VerifyResult { } /** The same check from the API's ready list (`readyToPublish`). */ -export function verifyPublishChangesApi(ready: { track: string; version: string | null; build: string | null; rollout: string }[]): VerifyResult { +export function verifyPublishChangesApi(ready: { track: string; version: string | null; build: string | null; rollout: string }[] & { undetermined?: boolean }): VerifyResult { const seen = ready.map(r => `${r.track} ${r.version ?? '?'} (${r.build ?? '?'}) ${r.rollout}`).join('; '); - const rows: VerifyRow[] = [{ item: 'Production releases ready to publish', expected: 'none left', seen: ready.length ? seen : 'none', status: ready.length ? 'mismatch' : 'match', + const rows: VerifyRow[] = [{ item: 'Production releases ready to publish', expected: 'none left', seen: ready.length ? seen : ready.undetermined ? 'none proven (the store page does not show an older live version; not determined)' : 'none', status: ready.length ? 'mismatch' : 'match', ...(ready.length ? { note: 'the owner presses "Publish N changes" and confirms (storebound console plan play-publish-changes)' } : {}) }]; - return result('play-publish-changes', 'api', rows, ['The API sees production releases only; Data safety and other non-track changes are not readable.']); + return result('play-publish-changes', 'api', rows, ['The API sees production releases only; Data safety and other non-track changes are not readable.', + ...(ready.undetermined ? ['A release counts as ready only when the public store page shows an OLDER version; a 404, failed read or same version is never ready. Check Publishing overview ("Last published on ...") or pass the page text with --from-text -.'] : [])]); } /** Page-text verification of any form that has one. */ diff --git a/cli/lib/play-publish.ts b/cli/lib/play-publish.ts index d4f5c41..6174390 100644 --- a/cli/lib/play-publish.ts +++ b/cli/lib/play-publish.ts @@ -13,9 +13,23 @@ export const PUBLISH_CONFIRM = 'Publish'; export interface ReadyChange { track: string; version: string | null; build: string | null; rollout: string; liveVersion: string | null } +/** + * Ready only when the public store page POSITIVELY shows an older version than the completed production release + * (both parsed and compared; playReleases sets liveVersion only then) and the page read is fresh. A 404, a failed or + * stale read, "Varies with device", a same or newer version, or an unparseable version is never "ready": a release + * already published (managed publishing, "Last published on ...") must not be reported as waiting. + */ +const isReady = (r: ReturnType[number]): boolean => r.track === 'production' && r.managed === true && r.phase === 'review' && !!r.liveVersion && !r.pageStaleAt && !!r.version; + +/** True when a completed managed production release exists but the store page cannot tell whether it is published. */ +export function readyUndetermined(data: unknown): boolean { + const rs = playReleases(data); + return (data as { managedPublishing?: unknown })?.managedPublishing === true && rs.some(r => r.track === 'production' && r.state === 'completed' && !isReady(r) && r.phase !== 'live'); +} + /** Ready-to-publish production releases from the `play status --json` data ({ tracks, publicPage, managedPublishing }). */ export function readyToPublish(data: unknown): ReadyChange[] { - return playReleases(data).filter(r => r.track === 'production' && r.managed === true && r.phase === 'review').map(r => ({ + return playReleases(data).filter(isReady).map(r => ({ track: r.track ?? 'production', version: r.version, build: r.build, rollout: r.fraction !== null && r.fraction < 1 ? `${Math.round(r.fraction * 1000) / 10}% staged` : 'full rollout', liveVersion: r.liveVersion ?? null, })); diff --git a/cli/play/publish-ready.ts b/cli/play/publish-ready.ts index 14f5dfe..705400b 100644 --- a/cli/play/publish-ready.ts +++ b/cli/play/publish-ready.ts @@ -1,18 +1,20 @@ // Read-only: the production releases waiting in Publishing overview (managed publishing), from the tracks and the public store page. import type { ShipConfig } from '../lib/config'; import { need } from '../lib/ctx'; -import { type ReadyChange, readyToPublish } from '../lib/play-publish'; +import { type ReadyChange, readyToPublish, readyUndetermined } from '../lib/play-publish'; import { playPublicPage } from '../lib/play-public'; import { playClient } from './client'; -export async function readyFromApi(root: string, config: ShipConfig): Promise { +export async function readyFromApi(root: string, config: ShipConfig): Promise { const pkg = need(config.play?.packageName, 'play.packageName'); const play = await playClient(root, pkg); - let out: ReadyChange[] = []; + let out: ReadyChange[] & { undetermined?: boolean } = []; await play.read(async id => { const tracks = (await play.call<{ tracks?: unknown[] }>('GET', `${play.API}/edits/${id}/tracks`)).tracks ?? []; const publicPage = await playPublicPage(play.packageName); - out = readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }); + const data = { tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }; + out = readyToPublish(data); + out.undetermined = !out.length && readyUndetermined(data); }); return out; } diff --git a/tests/console-play-plans.test.ts b/tests/console-play-plans.test.ts index f517dd9..c65df5d 100644 --- a/tests/console-play-plans.test.ts +++ b/tests/console-play-plans.test.ts @@ -7,7 +7,7 @@ import { consoleAnswers, PLAN_FORM_IDS, PLAN_ONLY_FORM_IDS } from '../cli/lib/co import { declarationSaveAllowed, flowAllowed, forbiddenControl, formDef, forbiddenTick, publishingControl } from '../cli/lib/console-forms'; import { consolePlan, IN_PAGE_LOOP, playConsoleReminders, planText } from '../cli/lib/console-plan'; import { APP_CONTENT_CARDS, PLAY_MATERIAL_FACT, startLabel } from '../cli/lib/console-play-plans'; -import { readyLines, readyToPublish } from '../cli/lib/play-publish'; +import { readyLines, readyToPublish, readyUndetermined } from '../cli/lib/play-publish'; import { appPrivacyPublished, verifyPublishChangesApi, verifyAppContent, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify'; import { CONSOLE_PLAN_FORMS, TOOLS, validateInput } from '../cli/mcp-tools'; import { playRules, type PlaySnapshot } from '../cli/lib/validate-rules'; @@ -343,11 +343,19 @@ describe('the first production release: countries, managed publishing, the owner test('publish changes: ready = managed + completed production the store page does not show; verify by API and by page text', () => { const tracks = [{ track: 'production', releases: [{ name: '2.0.0 (45)', versionCodes: ['45'], status: 'completed' }] }]; - const ready = readyToPublish({ tracks, publicPage: { state: 'notPublic' }, managedPublishing: true }); - expect(ready).toEqual([{ track: 'production', version: '2.0.0', build: '45', rollout: 'full rollout', liveVersion: null }]); - expect(readyToPublish({ tracks, publicPage: { state: 'notPublic' }, managedPublishing: false })).toEqual([]); + const ready = readyToPublish({ tracks, publicPage: { state: 'public', versionName: '1.9.0' }, managedPublishing: true }); + expect(ready).toEqual([{ track: 'production', version: '2.0.0', build: '45', rollout: 'full rollout', liveVersion: '1.9.0' }]); + expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '1.9.0' }, managedPublishing: false })).toEqual([]); expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '2.0.0' }, managedPublishing: true })).toEqual([]); - expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '1.9.0' }, managedPublishing: true })[0]!.liveVersion).toBe('1.9.0'); + // already published (real case: 2.0.0 / 2000001 full rollout, "Last published on ..."): never ready, whatever the page says + const live = [{ track: 'production', releases: [{ name: '2.0.0 (2000001)', versionCodes: ['2000001'], status: 'completed' }] }]; + for (const publicPage of [{ state: 'public', versionName: '2.0.0' }, { state: 'public', versionName: '2.1.0' }, { state: 'public', versionName: null }, { state: 'public', varies: true }, { state: 'notPublic' }, { state: 'unknown' }, { state: 'public', versionName: '1.9.0', stale: true, checkedAt: '2026-10-24T00:00:00Z' }]) { + expect({ publicPage, ready: readyToPublish({ tracks: live, publicPage, managedPublishing: true }) }).toEqual({ publicPage, ready: [] }); + } + const unk = Object.assign([], { undetermined: readyUndetermined({ tracks: live, publicPage: { state: 'notPublic' }, managedPublishing: true }) }); + expect(unk.undetermined).toBe(true); + expect(verifyPublishChangesApi(unk).ok).toBe(true); + expect(verifyPublishChangesApi(unk).rows[0]!.seen).toContain('not determined'); expect(readyLines(ready).join('\n')).toContain('storebound console plan play-publish-changes'); expect(readyLines([])).toEqual([]); expect(verifyPublishChangesApi(ready).ok).toBe(false); From fb1609cba77ec86f29daad3e563926ecc155bd43 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:10:23 +0800 Subject: [PATCH 004/126] upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint --- CHANGELOG.md | 5 ++ cli/apple/game-center.ts | 17 ++-- cli/apple/upload.ts | 6 +- cli/check.ts | 8 ++ cli/commands.ts | 1 + cli/data/flags.json | 35 +++++++- cli/games-status.ts | 2 +- cli/lib/config.ts | 9 +++ cli/lib/console-games-plan.ts | 2 +- cli/lib/credentials.ts | 4 +- cli/lib/game-services.ts | 48 ++++++++++- cli/lib/google-errors.ts | 2 + cli/lib/pre-check.ts | 72 +++++++++++++++++ cli/lib/ship-step.ts | 6 +- cli/lib/shots-copy-translate.ts | 75 +++++++++++++++++ cli/play/games.ts | 22 ++++- cli/play/upload.ts | 6 +- cli/shots/copy.ts | 125 +++++++++++++++++++++++++++++ cli/shots/lib.ts | 2 +- docs/ship-step.md | 7 ++ skills/store-pitfalls/SKILL.md | 39 +++++++++ tests/game-services.test.ts | 53 +++++++++++- tests/ship-step.test.ts | 28 +++++++ tests/shots-copy-translate.test.ts | 52 ++++++++++++ 24 files changed, 599 insertions(+), 27 deletions(-) create mode 100644 cli/lib/pre-check.ts create mode 100644 cli/lib/shots-copy-translate.ts create mode 100644 cli/shots/copy.ts create mode 100644 tests/shots-copy-translate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f69d8..043b6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## Unreleased - Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch). +||||||| parent of abe0821 (upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint) +- `apple upload` / `play upload` `--pre-check ""` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence ` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`. +- New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate ""`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language. +- `apple game-center --locale-map en=en-GB` (config `games.localeMap`) maps a config language to the live Game Center locale; the dry run suggests it when the live Game Center has exactly one variant of the language. +- `play games`: a 403 SERVICE_DISABLED / accessNotConfigured names the Google Play Game Services Publishing API, the enable URL for the project and `console plan play-games-services`. - Desktop dev update channel: Settings > Updates offers Stable (the npm releases) and Dev (every push to main). Each push to main builds Windows and Linux bundles stamped `-dev.` (above the current stable, below the next one); the VDS signs them and serves `storebound.dev/desktop/dev/latest.json` (newest 5 kept). The app checks its channel at start, every 15 minutes, after sleep and right after a channel switch, installs a strictly newer build quietly (deferred while the window is in use), never downgrades (dev back to stable waits for a newer stable), rewrites its logon autostart on every start, and the tray card shows version and channel. No macOS dev builds (Developer ID signing needs the Mac's GUI session). - `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `///.png` (feature: `/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`. - `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID. diff --git a/cli/apple/game-center.ts b/cli/apple/game-center.ts index e122b2d..ba757e0 100644 --- a/cli/apple/game-center.ts +++ b/cli/apple/game-center.ts @@ -1,5 +1,7 @@ // Game Center through the App Store Connect API, from the `games` config section (cli/lib/game-services.ts). -// storebound apple game-center [status] [--dry] [--yes] [--json] +// storebound apple game-center [status] [--locale-map en=en-GB] [--dry] [--yes] [--json] +// --locale-map (config games.localeMap): config language -> the locale the live Game Center uses; the dry run suggests it +// when Game Center has exactly one variant of a language (en-GB) and the config says another (en-US). // status: read-only (App ID capability, Game Center detail, the editable version's switch, leaderboards and // achievements on the store vs the config, the project's entitlement). Default: the upsert. Every write is a // find-then-create (createOnce: a conflict or an unclear answer reads the store again, never a second create) or a @@ -10,7 +12,7 @@ import { consoleLink } from '../lib/console-links'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { dryPlan } from '../lib/dry'; -import { type GcExisting, gcFindings, type ItemPlan, planGameCenter, planRows, usesGameCenter } from '../lib/game-services'; +import { type GcExisting, gcFindings, gcLocaleMap, type ItemPlan, liveGcLocales, planGameCenter, planRows, suggestLocaleMap, usesGameCenter } from '../lib/game-services'; import { done, log, warn } from '../lib/log'; import { createOnce } from '../lib/upsert'; import { type Asc, ascClient, type J } from './asc'; @@ -61,21 +63,24 @@ export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const a = ctx.args; const sub = a._[0] ?? 'sync'; - if (!['sync', 'status'].includes(sub)) throw new Error('usage: storebound apple game-center [status] [--dry] [--yes] [--json]'); + if (!['sync', 'status'].includes(sub)) throw new Error('usage: storebound apple game-center [status] [--locale-map en=en-GB] [--dry] [--yes] [--json]'); if (!usesGameCenter(config)) throw new Error('games.gameCenter is not true in storebound.config.ts (game-services skill): nothing to do'); const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); const c = await ascClient(root, bundleId, config.apple?.appId); const appId = await c.app(); const s = await readGameCenter(c, bundleId); const g = config.games!; - const plan = planGameCenter(g, config.languages, s); + const localeMap = gcLocaleMap(g, a.many('locale-map')); + const plan = planGameCenter(g, config.languages, s, localeMap); + const suggest = suggestLocaleMap(config.languages, liveGcLocales(s), localeMap); const project = gcFindings(config, readProjectEntitlements(root)); - if (a.has('json')) { console.log(JSON.stringify({ ...stateSummary(s, plan), project, plan }, null, 2)); return; } + if (a.has('json')) { console.log(JSON.stringify({ ...stateSummary(s, plan), project, localeMap, ...(suggest.length ? { suggestLocaleMap: suggest } : {}), plan }, null, 2)); return; } if (s.capability === false) warn(TAG, `the App ID ${bundleId} has no GAME_CENTER capability: storebound apple signing ensure --dry, then without --dry (games.gameCenter adds it)`); if (s.capability === null) warn(TAG, `bundle id ${bundleId} not found in Certificates, Identifiers & Profiles: storebound apple bundle`); for (const f of project) (f.level === 'ok' ? log : warn)(TAG, `${f.msg}${f.fix ? ` (${f.fix})` : ''}`); + for (const x of suggest) warn(TAG, `config language "${x.id}" is ${x.from}, but the live Game Center has only ${x.to} for that language, so ${x.from} would be created next to it. Map it: storebound apple game-center --locale-map ${x.id}=${x.to} (or games: { localeMap: { ${x.id}: '${x.to}' } } in the config)`); const rows = [ { item: 'Game Center (app)', action: s.detail ? 'keep' : 'create', detail: 'POST /v1/gameCenterDetails' }, ...(s.version ? [{ item: `Game Center (version ${s.version.versionString})`, action: s.version.enabled ? 'keep' : 'create', detail: 'POST /v1/gameCenterAppVersions' }] : []), @@ -122,7 +127,7 @@ export async function run(ctx: Ctx): Promise { } // read back: the store must now match the config (lists can lag right after a create: say so, do not fail) const after = await readGameCenter(c, bundleId); - const left = planGameCenter(g, config.languages, after).filter(p => p.action !== 'keep' || p.locales.some(l => l.action !== 'keep')); + const left = planGameCenter(g, config.languages, after, localeMap).filter(p => p.action !== 'keep' || p.locales.some(l => l.action !== 'keep')); if (!after.detail || left.length) warn(TAG, `read back: ${!after.detail ? 'no Game Center detail yet; ' : ''}${left.map(p => `${p.kind} ${p.id}`).join(', ') || ''} not listed as written yet (App Store Connect lists lag after a create): run storebound apple game-center status in a minute`); done(TAG, `Game Center: ${after.leaderboards.length} leaderboard(s), ${after.achievements.length} achievement(s); images and review go with the next version (${consoleLink('asc.version')})`); } diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 6868d25..966019f 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -1,5 +1,5 @@ // Upload an IPA to App Store Connect and wait until Apple has processed the build. -// storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json] +// storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] // Default IPA: the newest in /export (and its -/ folders), or with // --build N the one with that build number. The bundle id, version and build number come from the IPA's Info.plist. // An IPA whose build number is already on App Store Connect fails with exit 1 (a stale IPA left by a failed @@ -16,6 +16,7 @@ // with `apple testflight wait` (waitForBuild) until processingState is VALID (exit 0), INVALID / FAILED (exit 1) or the // timeout (exit 7). // An upload is a write: --dry shows the plan; a real run asks first (--yes in scripts). +import { runPreCheck } from '../lib/pre-check'; import { appleSdkProblem } from '../lib/store-minimums'; import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs'; import { basename, join, relative } from 'node:path'; @@ -251,6 +252,7 @@ export async function run(ctx: Ctx): Promise { // upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call const privacy = bundleTrackingIssues(ipaManifests(ipa)); if (privacy.length) throw new Error(`${relative(root, ipa)}: not uploaded, Apple would refuse it as Invalid Binary:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest, rebuild (storebound apple build) and upload the new IPA.`); + const pre = await runPreCheck({ args: a, config, root, resolve, file: ipa, shown: relative(root, ipa), tag: TAG }); const size = statSync(ipa).size; const dry = a.has('dry'); const json = a.has('json'); @@ -294,7 +296,7 @@ export async function run(ctx: Ctx): Promise { ? [describeReq(reqs.upload), describeReq(reqs.file('')), `PUT the file parts to the upload URLs Apple returns`, describeReq(reqs.commit('', '')), 'GET /v1/buildUploads/ until COMPLETE'] : [showCmd(altoolArgs(ipa, { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' }), null)]; if (wait) plan.push(`GET /v1/builds?filter[version]=${info.build} until processingState is VALID (timeout ${a.str('timeout', '60m')})`); - const summary = { ipa: relative(root, ipa), bundleId: info.bundleId, version: info.version, build: info.build, sizeMB: Math.round(size / 104857.6) / 10, via, plan }; + const summary = { ipa: relative(root, ipa), bundleId: info.bundleId, version: info.version, build: info.build, sizeMB: Math.round(size / 104857.6) / 10, via, plan, ...(pre ? { preCheck: pre } : {}) }; if (dry) { if (json) { printJson(summary); return; } log(TAG, `${summary.ipa}: ${info.bundleId} ${info.version} (${info.build}), ${summary.sizeMB} MB, build number free`); diff --git a/cli/check.ts b/cli/check.ts index ed1f5ab..89ddd21 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -5,6 +5,7 @@ // storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors // --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems import { localGameFindings } from './games-status'; +import { marksNotes, marksPath, parseMarks } from './lib/shots-copy-translate'; import { templateBlockers } from './lib/template-answers'; import { COPYRIGHT_MISSING, templatePlaceholders } from './lib/validate-rules'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; @@ -212,6 +213,13 @@ export async function run(ctx: Ctx): Promise { if (e.length) throw new Error(e.join('; ')); return `${Object.keys(copy).length} languages x ${config.shots!.scenes.length} scenes`; }); + if (config.shots) { + try { + const cp = resolve(config.shots.copy); + const mf = marksPath(cp); + if (existsSync(mf)) for (const n of marksNotes(parseMarks(readFileSync(mf, 'utf8')), relative(process.cwd(), cp) || cp)) note(n); + } catch { /* unreadable marks: shots copy check says so */ } + } // Capacitor: the native builds ship the web assets cap sync copied last (pitfall 20) for (const store of ['apple', 'play'] as const) { if (!config[store] || prebuildFor(config, store)) continue; diff --git a/cli/commands.ts b/cli/commands.ts index ddce923..e4df348 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -103,6 +103,7 @@ export const COMMANDS: Record Promise, string]> = { 'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright; signed-in apps: shots.capture.auth) | ios-sim (simctl) | android-emu (adb) | folder; --dry'], 'shots login': [() => import('./shots/login'), 'opens a visible browser (fresh profile) at shots.capture.baseUrl or --url; you sign in to a demo account by hand, Enter saves .storebound/auth.json (0600) for shots.capture.auth.storageState; --timeout '], 'shots fonts': [() => import('./shots/fonts'), 'download font subsets for the copy (CJK / Arabic / Cyrillic ...)'], + 'shots copy': [() => import('./shots/copy'), 'translate --to de,es [--from en] [--translate ""]: fill store/shots/copy.json per language (machine-translated until reviewed, caption limits); check | reviewed --lang de'], 'shots render': [() => import('./shots/render'), 'store screenshots + feature graphic in every size and language'], 'shots jpeg': [() => import('./shots/jpeg'), 'JPEG q92 copies of the Play renders (4x faster uploads)'], 'shots check': [() => import('./shots/check'), 'local store checks of the renders (size, alpha, counts, overflow) and folder frames; --variants, --json'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 5fc96bf..9707cf8 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -73,6 +73,7 @@ "configuration", "env-file", "keychain", + "locale-map", "no-install", "no-prebuild", "out", @@ -113,6 +114,7 @@ ], "apple game-center": [ "keychain", + "locale-map", "no-install" ], "apple iap": [ @@ -223,6 +225,7 @@ "build", "env-file", "keychain", + "locale-map", "new-version", "no-install", "no-submit", @@ -250,11 +253,13 @@ ], "apple signing": [ "keychain", + "locale-map", "no-install" ], "apple status": [ "cancel-submission", "keychain", + "locale-map", "no-install", "platform", "timeout", @@ -265,6 +270,7 @@ "build", "env-file", "keychain", + "locale-map", "new-version", "no-install", "no-submit", @@ -324,6 +330,7 @@ "build", "csv", "env-file", + "evidence", "group", "internal-only", "invite", @@ -331,6 +338,7 @@ "locale", "no-wait", "older-than", + "pre-check", "progress", "remove", "result-file", @@ -440,6 +448,7 @@ "flutter", "godot", "keychain", + "locale-map", "name", "no-install", "react-native", @@ -545,6 +554,7 @@ ], "games status": [ "keychain", + "locale-map", "no-install" ], "health": [ @@ -780,9 +790,11 @@ ], "play upload": [ "env-file", + "evidence", "include-drafts", "name", "not-sent-for-review", + "pre-check", "progress", "prune", "result-file", @@ -928,6 +940,14 @@ "src", "variants" ], + "shots copy": [ + "all", + "force", + "from", + "lang", + "to", + "translate" + ], "shots fonts": [], "shots gallery": [ "lang", @@ -1024,6 +1044,7 @@ "jobs", "keychain", "lang", + "locale-map", "no-health", "no-install", "no-local", @@ -1140,6 +1161,7 @@ "end", "env-file", "every", + "evidence", "experiment", "expo", "fields", @@ -1188,6 +1210,7 @@ "listing-text", "listings", "locale", + "locale-map", "lookup", "manual-release", "max-level", @@ -1243,6 +1266,7 @@ "plist", "port", "poster", + "pre-check", "production", "profile", "progress", @@ -1401,7 +1425,7 @@ "storebound apple events submit --name \"...\" [--dry] [--yes] into a review submission (the owner's step; asks)" ], "apple game-center": [ - "storebound apple game-center [status] [--dry] [--yes] [--json]" + "storebound apple game-center [status] [--locale-map en=en-GB] [--dry] [--yes] [--json]" ], "apple iap": [ "storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]" @@ -1480,7 +1504,7 @@ "storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json]" ], "apple upload": [ - "storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]" + "storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]" ], "apple win-back": [ "storebound apple win-back [--only sub1,sub2] [--dry] [--yes] [--json]" @@ -1629,7 +1653,7 @@ "storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]" ], "play upload": [ - "storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]" + "storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]" ], "prices": [ "storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]", @@ -1677,6 +1701,11 @@ "shots check": [ "storebound shots check [--lang en,tr] [--variants] [--json]" ], + "shots copy": [ + "storebound shots copy translate --to de,es [--from en] [--translate \"\"] [--force] [--dry] [--json]", + "storebound shots copy check [--json] marked languages, caption limits; exit 1 on a problem", + "storebound shots copy reviewed --lang de[,es] | --all [--dry] a human read the copy: remove its marker" + ], "shots fonts": [ "storebound shots fonts [--json] (re-run after editing the copy or the fonts)" ], diff --git a/cli/games-status.ts b/cli/games-status.ts index a1a2048..5a06489 100644 --- a/cli/games-status.ts +++ b/cli/games-status.ts @@ -47,7 +47,7 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p const c = await ascClient(root, config.apple.bundleId, config.apple.appId); const s = await readGameCenter(c, config.apple.bundleId); out.apple = { capability: s.capability, enabled: !!s.detail, version: s.version?.versionString ?? null, versionEnabled: s.version ? s.version.enabled : null, - leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s)), why: gcUse.why ?? undefined }; + leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s, g.localeMap)), why: gcUse.why ?? undefined }; } catch (e) { out.apple = { capability: null, enabled: false, version: null, versionEnabled: null, leaderboards: 0, achievements: 0, config: count('apple'), changes: [], error: (e as Error).message.slice(0, 300) }; } } if (config.play && usesPlayGames(config) && o.play !== false) { diff --git a/cli/lib/config.ts b/cli/lib/config.ts index e6e87cc..f300e23 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -768,6 +768,15 @@ export interface ShipConfig { /** Words `whats-new check` must not flag as internal jargon (a product or game term that collides), any case. */ allowWords?: string[]; }; + /** Uploads (`apple upload`, `play upload`). */ + upload?: { + /** Shell command run before any byte is sent; `{file}` is the IPA / AAB path. A nonzero exit refuses the upload + * (ship-step error code PRE_CHECK_FAILED). `--pre-check` overrides it. */ + preCheck?: string; + /** Where the pre-check evidence JSON (file, sha256, size, command, exit code, stdout tail, time, storebound + * version) is written. `--evidence` overrides it. */ + evidence?: string; + }; /** Native builds (`apple build`, `play build`). */ build?: { /** Shell command run in the project root before the archive / bundle, e.g. "npm run build && npx cap sync" for a diff --git a/cli/lib/console-games-plan.ts b/cli/lib/console-games-plan.ts index 48bb8cc..c3c9b4a 100644 --- a/cli/lib/console-games-plan.ts +++ b/cli/lib/console-games-plan.ts @@ -49,7 +49,7 @@ export function playGamesPart(config: ShipConfig, p: PlanIds): Part { { n: 5, text: `Credentials > Add credential > Android: pick ${pkg}; it links to an OAuth client in Google Cloud. Create it at ${GOOGLE_CLOUD_CREDENTIALS} > Create credentials > OAuth client ID > Android (package ${pkg}, the SHA-1 of step 4) > Create, then back in Play Console pick that client > Save changes.`, click: ['Add credential', 'Save changes'], answers: ['package', 'sha1'] }, { n: 6, text: `Only with a game server (ask-owner): Add credential > Game server: create a Web application OAuth client at ${GOOGLE_CLOUD_CREDENTIALS}, pick it, Save changes. Its client id goes into the game's requestServerSideAccess; the client secret stays on the server (never in chat or files here).`, answers: ['webClient'] }, { n: 7, text: 'Copy the project id (Configuration page, "Project ID" / Get resources): write it to games.playGames.appId, and put the resources XML into android/app/src/main/res/values/games-ids.xml. `storebound check` then compares it with the manifest\'s com.google.android.gms.games.APP_ID.' }, - { n: 8, text: `Leaderboards (${lb}) and achievements (${ach}) come from the config: run \`storebound play games --dry\`, then without --dry (the Configuration API; drafts only). Play needs at least 5 achievements before it publishes the games project.` }, + { n: 8, text: `Leaderboards (${lb}) and achievements (${ach}) come from the config: run \`storebound play games --dry\`, then without --dry (the Configuration API; drafts only). If it answers 403 SERVICE_DISABLED / accessNotConfigured, the Google Play Game Services Publishing API is off in the service account's Cloud project: open https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project= (the error names the project), press Enable, wait a minute, run it again. Play needs at least 5 achievements before it publishes the games project.` }, { n: 9, text: 'Testers: Play Games Services > Testers lists who can sign in before publishing (the owner adds them; storebound never invites testers).' }, { n: 10, text: `Stop before "${PGS_PUBLISH}" (Review and publish): the owner presses it. Then read the page text and pipe it to \`storebound console verify play-games-services --from-text -\`.` }, ], diff --git a/cli/lib/credentials.ts b/cli/lib/credentials.ts index 709be38..186a8fe 100644 --- a/cli/lib/credentials.ts +++ b/cli/lib/credentials.ts @@ -28,7 +28,7 @@ import { homedir, tmpdir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; import * as rt from './rt'; -export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string } +export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string; project_id?: string } export interface AscKey { keyId: string; issuerId: string; pem: string } export interface ReviewContact { firstName: string; lastName: string; phone: string; email: string } @@ -201,7 +201,7 @@ export function parseServiceAccount(text: string): ServiceAccount { const j = JSON.parse(text) as Partial; if (typeof j.client_email !== 'string' || typeof j.private_key !== 'string' || !j.private_key.includes('PRIVATE KEY')) throw new Error('not a Google service account JSON key (client_email / private_key missing)'); - return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri }; + return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri, ...(typeof j.project_id === 'string' ? { project_id: j.project_id } : {}) }; } export function playServiceAccount(root: string): ServiceAccount { diff --git a/cli/lib/game-services.ts b/cli/lib/game-services.ts index 9de1f7f..47fd17c 100644 --- a/cli/lib/game-services.ts +++ b/cli/lib/game-services.ts @@ -65,6 +65,8 @@ export interface GamesConfig { gameCenter?: boolean; /** Play Games Services: the games project id (the digits of games_APP_ID / com.google.android.gms.games.APP_ID). */ playGames?: { appId?: string }; + /** Game Center only: config language id (or its apple locale) -> the locale the live Game Center uses, e.g. { en: 'en-GB' } when the app's English is en-GB there but the language says en-US. `storebound apple game-center --locale-map en=en-GB` overrides. */ + localeMap?: Record; leaderboards?: GameLeaderboard[]; achievements?: GameAchievement[]; } @@ -78,6 +80,10 @@ export function validateGames(g: GamesConfig | undefined, languages: Language[] if (g.gameCenter !== undefined && typeof g.gameCenter !== 'boolean') e.push('games.gameCenter: true | false'); if (g.playGames !== undefined && (typeof g.playGames !== 'object' || (g.playGames.appId !== undefined && !/^\d{6,20}$/.test(String(g.playGames.appId))))) e.push('games.playGames.appId: the digits of the Play Games Services project id (games_APP_ID)'); + if (g.localeMap !== undefined) { + if (!g.localeMap || typeof g.localeMap !== 'object' || Array.isArray(g.localeMap)) e.push('games.localeMap: an object { en: "en-GB" } (config language id or locale -> Game Center locale)'); + else for (const [k, v] of Object.entries(g.localeMap)) if (typeof v !== 'string' || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(v)) e.push(`games.localeMap.${k}: "${String(v)}" is not a locale like en-GB`); + } const langs = new Set(languages.map(l => l.id)); const seen = new Set(); const checkId = (kind: string, id: unknown) => { @@ -133,7 +139,41 @@ export function changedFields(have: Record | undefined, want: R return Object.keys(want).filter(k => JSON.stringify(have?.[k] ?? null) !== JSON.stringify(want[k] ?? null)); } -const appleLocale = (languages: Language[], id: string): string | undefined => languages.find(l => l.id === id)?.apple; +const appleLocale = (languages: Language[], id: string, map: Record = {}): string | undefined => { + const apple = languages.find(l => l.id === id)?.apple; + return map[id] ?? (apple ? map[apple] : undefined) ?? apple; +}; + +/** The effective Game Center locale map: games.localeMap, then --locale-map en=en-GB,tr=tr (flag entries win). */ +export function gcLocaleMap(g: GamesConfig | undefined, flag: string[] = []): Record { + const out: Record = { ...(g?.localeMap ?? {}) }; + for (const item of flag.flatMap(x => x.split(',')).map(x => x.trim()).filter(Boolean)) { + const m = /^([A-Za-z0-9_-]+)=([A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*)$/.exec(item); + if (!m) throw new Error(`--locale-map "${item}": use =, e.g. en=en-GB (several: en=en-GB,pt=pt-PT)`); + out[m[1]!] = m[2]!; + } + return out; +} + +/** A suggestion per config language whose Game Center locale is not on the live Game Center while exactly one other + * variant of the same language is (en-US configured, only en-GB live): the map entry that stops the spurious creates. */ +export function suggestLocaleMap(languages: Language[], liveLocales: string[], map: Record = {}): { id: string; from: string; to: string }[] { + const live = [...new Set(liveLocales)]; + if (!live.length) return []; + const out: { id: string; from: string; to: string }[] = []; + for (const l of languages) { + const from = appleLocale(languages, l.id, map); + if (!from || live.includes(from) || map[l.id] || map[l.apple ?? '']) continue; + const base = from.split('-')[0]!.toLowerCase(); + const same = live.filter(x => x.split('-')[0]!.toLowerCase() === base); + if (same.length === 1) out.push({ id: l.id, from, to: same[0]! }); + } + return out; +} + +/** The live locales of a Game Center read (every leaderboard and achievement localization). */ +export const liveGcLocales = (have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): string[] => + [...have.leaderboards, ...have.achievements].flatMap(x => x.localizations.map(l => String(l.attributes.locale ?? '')).filter(Boolean)); export function gcLeaderboardAttrs(l: GameLeaderboard): Record { return { @@ -151,7 +191,7 @@ export interface GcExisting { id: string; attributes: Record; l /** Game Center plan: matched by vendorIdentifier; create what is missing, PATCH what differs, keep the rest. Archived * items on the store are left alone (an archived vendor id cannot be reused; the plan names it). */ -export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): ItemPlan[] { +export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }, localeMap: Record = {}): ItemPlan[] { const out: ItemPlan[] = []; const one = (kind: ItemPlan['kind'], id: string, attrs: Record, locs: Record>, list: GcExisting[]) => { const cur = list.find(x => x.attributes.vendorIdentifier === id); @@ -166,7 +206,7 @@ export function planGameCenter(g: GamesConfig, languages: Language[], have: { le for (const l of (g.leaderboards ?? []).filter(x => onStore(x, 'apple'))) { const locs: Record> = {}; for (const [k, v] of Object.entries(l.locales)) { - const loc = appleLocale(languages, k); + const loc = appleLocale(languages, k, localeMap); if (loc) locs[loc] = { name: v.name, ...(v.suffix ? { formatterSuffix: v.suffix } : {}) }; } one('leaderboard', l.id, gcLeaderboardAttrs(l), locs, have.leaderboards); @@ -174,7 +214,7 @@ export function planGameCenter(g: GamesConfig, languages: Language[], have: { le for (const a of (g.achievements ?? []).filter(x => onStore(x, 'apple'))) { const locs: Record> = {}; for (const [k, v] of Object.entries(a.locales)) { - const loc = appleLocale(languages, k); + const loc = appleLocale(languages, k, localeMap); if (loc) locs[loc] = { name: v.name, beforeEarnedDescription: v.description, afterEarnedDescription: v.earned ?? v.description }; } one('achievement', a.id, gcAchievementAttrs(a), locs, have.achievements); diff --git a/cli/lib/google-errors.ts b/cli/lib/google-errors.ts index 91c0349..606307e 100644 --- a/cli/lib/google-errors.ts +++ b/cli/lib/google-errors.ts @@ -19,6 +19,7 @@ export const PLAY_USERS_URL = 'https://play.google.com/console/developers/users- const TITLES: Record = { 'playdeveloperreporting.googleapis.com': 'Google Play Developer Reporting API', 'androidpublisher.googleapis.com': 'Google Play Android Developer API', + 'gamesconfiguration.googleapis.com': 'Google Play Game Services Publishing API', 'storage.googleapis.com': 'Cloud Storage API', 'storage-api.googleapis.com': 'Cloud Storage API', }; @@ -26,6 +27,7 @@ const TITLES: Record = { const NEEDS: Record = { 'playdeveloperreporting.googleapis.com': 'Crash and ANR data', 'androidpublisher.googleapis.com': 'Reading and updating the app on Google Play', + 'gamesconfiguration.googleapis.com': 'Play Games Services leaderboards and achievements (storebound play games)', 'storage.googleapis.com': 'Play statistics (store listing visitors, installs)', 'storage-api.googleapis.com': 'Play statistics (store listing visitors, installs)', }; diff --git a/cli/lib/pre-check.ts b/cli/lib/pre-check.ts new file mode 100644 index 0000000..f85bb15 --- /dev/null +++ b/cli/lib/pre-check.ts @@ -0,0 +1,72 @@ +// The pre-upload check of `apple upload` and `play upload`: a shell command the project owns (a store-readiness gate, +// a policy scan of the binary) runs before any byte is sent. +// --pre-check "" (config: upload.preCheck) {file} is replaced by the quoted IPA / AAB path +// --evidence (config: upload.evidence) what ran, written also when the check fails +// A nonzero exit refuses the upload: ShipStepError PRE_CHECK_FAILED (exit 1 under the ship-step contract), nothing +// is sent. The evidence ({file, sha256, size, command, exitCode, stdoutTail, timestamp, storeboundVersion}) goes to +// --evidence and into the --json / --result-file result as `preCheck`. +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { basename, dirname } from 'node:path'; +import type { Args } from './args'; +import type { ShipConfig } from './config'; +import { log } from './log'; +import { pump } from './runner'; +import * as rt from './rt'; +import { SHIP_EXIT, ShipStepError, shipExtra, shipPhase } from './ship-step'; + +export interface PreCheckEvidence { + file: string; sha256: string; size: number; command: string; exitCode: number; + stdoutTail: string[]; timestamp: string; storeboundVersion: string; +} + +/** The command with {file} replaced by the POSIX-quoted path (sh -c); a command without {file} gets none. */ +export function preCheckCommand(template: string, file: string): string { + const q = `'${file.replace(/\\/g, '/').replace(/'/g, `'\\''`)}'`; + return template.split('{file}').join(q); +} + +function sha256File(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } + +/** + * Runs the pre-check for `file` when --pre-check / upload.preCheck is set (else returns null). Throws PRE_CHECK_FAILED + * on a nonzero exit, after writing the evidence. `shown` is the file name for the evidence (not the absolute path). + */ +export async function runPreCheck(o: { args: Args; config: ShipConfig; root: string; resolve: (p: string) => string; file: string; shown: string; tag: string }): Promise { + const flag = o.args.get('pre-check'); + if (flag === 'true') throw new Error('--pre-check "" needs a command'); + const template = (flag ?? o.config.upload?.preCheck)?.trim(); + const evFlag = o.args.get('evidence'); + if (evFlag === 'true') throw new Error('--evidence needs a path'); + const evPath = evFlag ?? o.config.upload?.evidence; + if (!template) { + if (evFlag) throw new Error('--evidence needs --pre-check (or upload.preCheck in the config)'); + return null; + } + const command = preCheckCommand(template, o.file); + shipPhase('pre-check', command); + log(o.tag, `pre-check: $ ${command}`); + const p = rt.spawn(['sh', '-c', command], { cwd: o.root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env }); + const tail: string[] = []; + const each = (line: string, kind: 'stdout' | 'stderr'): void => { + if (kind === 'stdout') { tail.push(line); if (tail.length > 20) tail.shift(); } + console.error(` ${line}`); + }; + await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]); + const exitCode = Number(await p.exited); + const version = ((await import('../../package.json')) as { version: string }).version; + const ev: PreCheckEvidence = { + file: o.shown.split('\\').join('/'), sha256: sha256File(o.file), size: statSync(o.file).size, command, exitCode, + stdoutTail: tail, timestamp: new Date().toISOString(), storeboundVersion: version, + }; + if (evPath) { + const abs = o.resolve(evPath); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, JSON.stringify(ev, null, 2) + '\n'); + log(o.tag, `pre-check evidence: ${basename(abs)}`); + } + shipExtra({ preCheck: ev }); + if (exitCode !== 0) throw new ShipStepError(`pre-check failed (exit ${exitCode}), nothing was uploaded: ${command}${tail.length ? `\n${tail.slice(-5).join('\n')}` : ''}`, SHIP_EXIT.failure, 'PRE_CHECK_FAILED'); + log(o.tag, 'pre-check: ok'); + return ev; +} diff --git a/cli/lib/ship-step.ts b/cli/lib/ship-step.ts index 796ce18..af8e3da 100644 --- a/cli/lib/ship-step.ts +++ b/cli/lib/ship-step.ts @@ -59,7 +59,7 @@ type Fields = Partial; noop: boolean; warnings: string[]; detail?: unknown; declined?: string; } const RUN = Symbol.for('storebound.shipRun'); const g = globalThis as unknown as Record; @@ -69,6 +69,8 @@ export const shipRun = (): Run | null => g[RUN] ?? null; /** What the command did: fields of the final result (later calls overwrite earlier ones). */ export function shipReport(f: Fields): void { const r = shipRun(); if (r) Object.assign(r.fields, Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined))); } +/** Extra keys of the final result (e.g. the preCheck evidence), kept also when the step fails. */ +export function shipExtra(x: Record): void { const r = shipRun(); if (r) r.extra = { ...r.extra, ...x }; } /** Nothing to do (the build is on the store already, the release is there): exit 7 with what is there. */ export function shipNoop(f: Fields = {}): void { const r = shipRun(); if (r) { r.noop = true; shipReport(f); } } /** A warning for the result's warnings[] (the log line is the command's own). */ @@ -178,7 +180,7 @@ export function buildResult(run: Run, exit: ShipExitCode, error?: ShipErrorInfo) ...(f.artifact ? { artifact: f.artifact } : {}), ...(error ? { error } : {}), }; - return { ...out, ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record; + return { ...out, ...(run.extra ?? {}), ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record; } const CONTRACT_KEYS = new Set(['ok', 'step', 'store', 'track', 'version', 'build', 'state', 'url', 'warnings', 'artifact', 'error']); diff --git a/cli/lib/shots-copy-translate.ts b/cli/lib/shots-copy-translate.ts new file mode 100644 index 0000000..2145bc4 --- /dev/null +++ b/cli/lib/shots-copy-translate.ts @@ -0,0 +1,75 @@ +// Screenshot copy translation (`storebound shots copy translate`): fills the languages of shots.copy (store/shots/ +// copy.json: language id -> scene id -> {eyebrow, title [line1, line2], sub, badges}) from the source language's copy. +// Pure. Like the listing (lib/listing-translate.ts) the CLI never calls a translation service: the agent translates the +// sections marked "translate", or `--translate ""` runs a command of your own once per caption field. A +// language written by this command is machine-translated until a human read it: the marker lives next to the copy file +// (copy.translations.json), because copy.json itself has no place for comments: +// { "de": { "from": "en", "status": "translate" | "draft" } } +// translate: the source text was copied in, still to translate draft: machine-translated, review then `shots copy reviewed` +// Caption limits (characters, per line / field), so a translation still fits the template without the overflow +// `shots check` would flag: eyebrow 30, each of the two title lines 28, sub 90, a badge 18. A translation over its limit +// is not kept (the source text stays and the language stays "translate"). +import type { SceneCopy } from '../shots/lib'; + +export type CopyStatus = 'translate' | 'draft'; +export type CopyMarks = Record; + +export const CAPTION_LIMITS = { eyebrow: 30, title: 28, sub: 90, badge: 18 } as const; + +export const marksPath = (copyPath: string): string => copyPath.replace(/\.json$/i, '') + '.translations.json'; + +export interface CopyField { key: string; text: string; limit: number } + +/** The translatable strings of a scene, in a fixed order (key: eyebrow, title.0, title.1, sub, badge..<0|1>). */ +export function sceneFields(sc: SceneCopy): CopyField[] { + const out: CopyField[] = []; + if (sc.eyebrow) out.push({ key: 'eyebrow', text: sc.eyebrow, limit: CAPTION_LIMITS.eyebrow }); + sc.title.forEach((t, i) => { if (t) out.push({ key: `title.${i}`, text: t, limit: CAPTION_LIMITS.title }); }); + if (sc.sub) out.push({ key: 'sub', text: sc.sub, limit: CAPTION_LIMITS.sub }); + (sc.badges ?? []).forEach((b, i) => b.forEach((t, j) => { if (t) out.push({ key: `badge.${i}.${j}`, text: t, limit: CAPTION_LIMITS.badge }); })); + return out; +} + +/** The scene with the given field texts put in (keys of sceneFields; a missing key keeps the source text). */ +export function applyFields(src: SceneCopy, texts: Record): SceneCopy { + const t = (key: string, d: string): string => texts[key] ?? d; + const out: SceneCopy = { title: [t('title.0', src.title[0]), t('title.1', src.title[1])] }; + if (src.eyebrow !== undefined) out.eyebrow = t('eyebrow', src.eyebrow); + if (src.sub !== undefined) out.sub = t('sub', src.sub); + if (src.badges) out.badges = src.badges.map((b, i) => b.map((x, j) => (x === undefined ? x : t(`badge.${i}.${j}`, x))) as [string, string?]); + return out; +} + +/** Characters the way a person counts them (code points). */ +export const chars = (s: string): number => [...s].length; + +export interface SceneCheck { scene: string; field: string; chars: number; limit: number } +/** Fields of a language's copy over their caption limit. */ +export function overLimit(copy: Record | undefined): SceneCheck[] { + const out: SceneCheck[] = []; + for (const [scene, sc] of Object.entries(copy ?? {})) for (const f of sceneFields(sc)) if (chars(f.text) > f.limit) out.push({ scene, field: f.key, chars: chars(f.text), limit: f.limit }); + return out; +} + +/** Parses the marks file; anything unreadable counts as no marks. */ +export function parseMarks(text: string | null): CopyMarks { + if (!text) return {}; + try { + const j = JSON.parse(text) as Record; + return Object.fromEntries(Object.entries(j).filter(([, v]) => v && (v.status === 'translate' || v.status === 'draft') && typeof v.from === 'string') + .map(([k, v]) => [k, { from: v.from as string, status: v.status as CopyStatus }])); + } catch { return {}; } +} + +/** One line per marked language for `storebound check` / `shots copy check`; empty when nothing is marked. */ +export function marksNotes(marks: CopyMarks, where: string): string[] { + const todo = Object.entries(marks).filter(([, m]) => m.status === 'translate').map(([k]) => k); + const drafts = Object.entries(marks).filter(([, m]) => m.status === 'draft').map(([k]) => k); + return [ + ...(todo.length ? [`${where}: not translated yet (status=translate): ${todo.join(', ')} (storebound shots copy translate --to ${todo.join(',')})`] : []), + ...(drafts.length ? [`${where}: machine-translated drafts not reviewed: ${drafts.join(', ')} (storebound shots copy reviewed --lang after reading them)`] : []), + ]; +} + +/** The fix to name in "no copy" problems of validateCopy. */ +export const copyFix = (lang: string): string => `storebound shots copy translate --to ${lang}`; diff --git a/cli/play/games.ts b/cli/play/games.ts index 3cebf46..009683f 100644 --- a/cli/play/games.ts +++ b/cli/play/games.ts @@ -14,16 +14,30 @@ import { confirm, need } from '../lib/ctx'; import { dryPlan } from '../lib/dry'; import { hasAndroidProject, type ItemPlan, pgsFindings, type PlayExisting, planPlayGames, planRows, readPgsProject, usesPlayGames } from '../lib/game-services'; import { PLAY_SCOPE, tokenSource } from '../lib/google-auth'; +import { enableUrl, findingText, parseGoogleError } from '../lib/google-errors'; import { errorBody } from '../lib/http'; import { done, log, warn } from '../lib/log'; import * as rt from '../lib/rt'; import { AmbiguousWriteError, createOnce, retrySafe } from '../lib/upsert'; const TAG = 'play-games'; +export const GAMES_SERVICE = 'gamesconfiguration.googleapis.com'; +export const GAMES_API_TITLE = 'Google Play Game Services Publishing API'; export const GAMES_CONFIG_API = 'https://gamesconfiguration.googleapis.com/games/v1configuration'; +/** The Games Configuration API is off in the service account's Cloud project (403 SERVICE_DISABLED / accessNotConfigured): the exact API name, where to turn it on and the console plan. Null for any other error. */ +export function gamesApiDisabledText(errText: string, fallbackProject: string | null): string | null { + const f = parseGoogleError(errText); + if (f?.kind !== 'api-disabled') return null; + const projectId = f.projectId ?? fallbackProject; + const line = findingText({ ...f, service: GAMES_SERVICE, serviceTitle: GAMES_API_TITLE, projectId, activationUrl: enableUrl(GAMES_SERVICE, projectId) }, { withUrl: true }); + return `${line} +API: ${GAMES_API_TITLE} (${GAMES_SERVICE}). Enable it, wait a minute, then run storebound play games again. Plan: storebound console plan play-games-services`; +} + export function gamesConfigClient(root: string) { - const token = tokenSource(playServiceAccount(root), PLAY_SCOPE); + const account = playServiceAccount(root); + const token = tokenSource(account, PLAY_SCOPE); async function call(method: string, path: string, body?: unknown): Promise { const url = GAMES_CONFIG_API + path; const safe = method !== 'POST' && retrySafe(method, url); @@ -37,7 +51,11 @@ export function gamesConfigClient(root: string) { throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t, 300)}`); } const t = await r.text(); - if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t, 800)}`); + if (!r.ok) { + const msg = `${method} ${path} -> ${r.status} ${errorBody(t, 800)}`; + const off = r.status === 403 ? gamesApiDisabledText(msg, account.project_id ?? null) : null; + throw new Error(off ? `${msg.split(' -> ')[0]} -> 403: ${off}` : msg); + } return (t ? JSON.parse(t) : {}) as T; } } diff --git a/cli/play/upload.ts b/cli/play/upload.ts index d7c93a1..0ccad8d 100644 --- a/cli/play/upload.ts +++ b/cli/play/upload.ts @@ -1,5 +1,5 @@ // Upload an Android App Bundle to a Google Play testing track. -// storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json] +// storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] // Default AAB: the newest in build/android (storebound play build), or with --version-code N the one with that // versionCode. Package and versionCode are read from the bundle; the upload is refused when Play already has that // versionCode (exit 1), and under the ship-step contract (--json / --result-file, docs/ship-step.md) it is exit 7 @@ -18,6 +18,7 @@ import { nextVersionCode } from '../lib/release'; import { playClient } from './client'; import { readPlayCopy, refusePlayNotes } from './listing'; import { usedVersionCodes } from './next-version-code'; +import { runPreCheck } from '../lib/pre-check'; import { shipNoop, shipPhase, shipReport, shipRun, shipWarn } from '../lib/ship-step'; import { consoleUrl, playTrackPage } from '../lib/console-links'; @@ -53,6 +54,7 @@ export async function run(ctx: Ctx): Promise { const sdkWhy = playTargetSdkProblem(info.targetSdk, { where: targetSdkFile(root) }); if (sdkWhy) throw new Error(`${relative(root, aab)}: not uploaded, ${sdkWhy}`); assertAabSigned(aab, relative(root, aab)); + const pre = await runPreCheck({ args: a, config, root, resolve, file: aab, shown: relative(root, aab), tag: TAG }); const size = statSync(aab).size; const dry = a.has('dry'); const json = a.has('json'); @@ -82,7 +84,7 @@ export async function run(ctx: Ctx): Promise { } if (used.includes(info.versionCode)) throw new Error(`versionCode ${info.versionCode} is already on Google Play: rebuild with --version-code ${nextVersionCode(used)} (storebound play build)`); const summary = { aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10, - track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`) }; + track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`), ...(pre ? { preCheck: pre } : {}) }; if (dry) { if (json) { printJson(summary); return; } log(TAG, `${summary.aab}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}), ${summary.sizeMB} MB, versionCode free`); diff --git a/cli/shots/copy.ts b/cli/shots/copy.ts new file mode 100644 index 0000000..96ca84e --- /dev/null +++ b/cli/shots/copy.ts @@ -0,0 +1,125 @@ +// Screenshot caption copy per language (shots.copy, store/shots/copy.json). +// storebound shots copy translate --to de,es [--from en] [--translate ""] [--force] [--dry] [--json] +// fills the missing languages (and the missing scenes of a language) from the source language: without --translate +// the source text is copied in and marked status=translate for your agent to translate in place (listing-localization +// skill); with --translate your command runs once per caption field with the source text on stdin and +// STOREBOUND_SOURCE_LANG, STOREBOUND_TARGET_LANG, STOREBOUND_TARGET_LANG_ID, STOREBOUND_FIELD, STOREBOUND_LIMIT in the +// environment; its stdout is the translation (status=draft). A translation over the caption limit is not kept. +// Scenes already there are kept unless --force. The marker lives in copy.translations.json next to the copy file. +// storebound shots copy check [--json] marked languages, caption limits; exit 1 on a problem +// storebound shots copy reviewed --lang de[,es] | --all [--dry] a human read the copy: remove its marker +// --to / --from / --lang take config language ids, App Store locales or Play codes. Local files only. +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { playCodes } from '../lib/config'; +import type { Ctx } from '../lib/ctx'; +import { need } from '../lib/ctx'; +import { done, log, printJson, warn } from '../lib/log'; +import * as rt from '../lib/rt'; +import { applyFields, type CopyMarks, chars, marksNotes, marksPath, overLimit, parseMarks, sceneFields } from '../lib/shots-copy-translate'; +import { resolveLanguages } from '../lib/whats-new'; +import type { CopyFile } from './lib'; + +const TAG = 'shots-copy'; + +function readCopy(path: string): CopyFile { + if (!existsSync(path)) throw new Error(`${path}: no copy file (storebound init creates store/shots/copy.json)`); + return JSON.parse(readFileSync(path, 'utf8')) as CopyFile; +} +const readMarks = (path: string): CopyMarks => parseMarks(existsSync(marksPath(path)) ? readFileSync(marksPath(path), 'utf8') : null); +const write = (path: string, v: unknown): void => writeFileSync(path, JSON.stringify(v, null, 2) + '\n'); + +async function translate(ctx: Ctx): Promise { + const { config, resolve } = ctx.loaded; + const a = ctx.args; + const shots = need(config.shots, 'shots'); + const path = resolve(shots.copy); + const targets = resolveLanguages(config.languages, a.list('to')); + if (!targets.length) throw new Error('--to : config language ids, App Store locales or Play codes (add a language to languages[] in the config first)'); + const primary = config.apple?.primaryLocale ?? config.play?.defaultLanguage; + const want = a.get('from'); + const src = want ? resolveLanguages(config.languages, [want])[0]! : config.languages.find(l => l.apple === primary || playCodes(l).includes(primary ?? '')) ?? config.languages[0]!; + const copy = readCopy(path); + const from = copy[src.id]; + if (!from) throw new Error(`${path}: no copy for the source language ${src.id} (--from )`); + const hook = a.get('translate'); + const dry = a.has('dry'); + const marks = readMarks(path); + const rows: { lang: string; scenes: number; kept: number; status: string; problems: string[] }[] = []; + const runHook = (text: string, l: (typeof targets)[number], field: string, limit: number): string | null => { + const r = rt.spawnSync(['sh', '-c', hook!], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe', + env: { ...process.env, STOREBOUND_SOURCE_LANG: src.apple ?? src.id, STOREBOUND_TARGET_LANG: l.apple ?? playCodes(l)[0] ?? l.id, STOREBOUND_TARGET_LANG_ID: l.id, STOREBOUND_FIELD: field, STOREBOUND_LIMIT: String(limit) } }); + const out = r.stdout.toString().trim(); + if (r.exitCode !== 0 || !out) { warn(TAG, `${l.id} ${field}: --translate failed (exit ${r.exitCode}): ${r.stderr.toString().trim().slice(0, 200)}`); return null; } + return out; + }; + for (const l of targets) { + if (l === src) { warn(TAG, `${l.id} is the source language: skipped`); continue; } + const cur = { ...(copy[l.id] ?? {}) }; + let written = 0, kept = 0, failed = false; + const problems: string[] = []; + for (const scene of shots.scenes) { + const sc = from[scene.id]; + if (!sc) continue; + if (cur[scene.id] && !a.has('force')) { kept++; continue; } + const texts: Record = {}; + if (hook && !dry) for (const f of sceneFields(sc)) { + const t = runHook(f.text, l, `${scene.id}.${f.key}`, f.limit); + if (t === null) { failed = true; continue; } + const one = t.replace(/\s*\n\s*/g, ' '); + if (chars(one) > f.limit) { failed = true; problems.push(`${scene.id}.${f.key}: ${chars(one)} > ${f.limit} characters (source text kept)`); continue; } + texts[f.key] = one; + } + cur[scene.id] = applyFields(sc, texts); + written++; + } + const status = hook && !dry && !failed ? 'draft' : 'translate'; + rows.push({ lang: l.id, scenes: written, kept, status: written ? status : 'kept', problems }); + if (!written || dry) continue; + copy[l.id] = cur; + marks[l.id] = { from: src.id, status }; + } + if (!dry && rows.some(r => r.scenes)) { write(path, copy); write(marksPath(path), marks); } + if (a.has('json')) { printJson({ copy: path, from: src.id, dry, languages: rows }); return; } + for (const r of rows) log(TAG, `${r.lang}: ${r.scenes ? `${r.scenes} scene(s) ${dry ? 'would be ' : ''}written (${r.status})` : 'nothing to write'}${r.kept ? `, ${r.kept} kept (--force replaces)` : ''}${r.problems.length ? `; ${r.problems.join('; ')}` : ''}`); + const todo = rows.filter(r => r.scenes && r.status === 'translate').length; + done(TAG, dry ? 'translation planned (dry run, nothing written)' : todo ? `${todo} language(s) to translate in place (edit copy.json, keep the marker in copy.translations.json), then storebound shots copy check` : 'drafts written: read them, storebound shots copy check, then storebound shots copy reviewed --lang '); +} + +async function check(ctx: Ctx): Promise { + const { config, resolve } = ctx.loaded; + const shots = need(config.shots, 'shots'); + const path = resolve(shots.copy); + const copy = readCopy(path); + const marks = readMarks(path); + const over = Object.fromEntries(Object.keys(copy).map(l => [l, overLimit(copy[l])] as const).filter(([, v]) => v.length)); + const missing = config.languages.map(l => l.id).filter(id => !copy[id]); + if (ctx.args.has('json')) printJson({ copy: path, marks, overLimit: over, missing }); + else { + for (const n of marksNotes(marks, path)) warn(TAG, n); + for (const [l, v] of Object.entries(over)) for (const o of v) warn(TAG, `${l} ${o.scene}.${o.field}: ${o.chars} > ${o.limit} characters`); + if (missing.length) warn(TAG, `no copy for ${missing.join(', ')}: storebound shots copy translate --to ${missing.join(',')}`); + } + if (Object.keys(over).length || missing.length) process.exitCode = 1; + else if (!ctx.args.has('json')) done(TAG, 'shots copy ok'); +} + +async function reviewed(ctx: Ctx): Promise { + const { config, resolve } = ctx.loaded; + const path = resolve(need(config.shots, 'shots').copy); + const marks = readMarks(path); + const ids = ctx.args.has('all') ? Object.keys(marks) : resolveLanguages(config.languages, ctx.args.list('lang')).map(l => l.id); + if (!ids.length) throw new Error('--lang | --all'); + const cleared = ids.filter(id => marks[id]); + const refused = cleared.filter(id => marks[id]!.status === 'translate'); + if (refused.length) throw new Error(`${refused.join(', ')}: still status=translate (the source text is in place): translate it first (storebound shots copy translate --translate "", or edit ${path})`); + if (!ctx.args.has('dry')) { for (const id of cleared) delete marks[id]; write(marksPath(path), marks); } + done(TAG, `${cleared.length ? cleared.join(', ') : 'nothing'} marked reviewed${ctx.args.has('dry') ? ' (dry run)' : ''}`); +} + +export async function run(ctx: Ctx): Promise { + const sub = ctx.args._[0]; + if (sub === 'translate') return translate(ctx); + if (sub === 'check') return check(ctx); + if (sub === 'reviewed') return reviewed(ctx); + throw new Error('usage: storebound shots copy translate --to de,es [--from en] [--translate ""] [--force] [--dry] | check | reviewed --lang de | --all'); +} diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index 57b2b59..5b882ab 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -145,7 +145,7 @@ export function validateCopy(copy: CopyFile, shots: ShotsConfig, langs: string[] const e: string[] = []; for (const lang of langs) { const c = copy[lang]; - if (!c) { e.push(`${lang}: no copy`); continue; } + if (!c) { e.push(`${lang}: no copy (storebound shots copy translate --to ${lang})`); continue; } for (const s of shots.scenes) { const sc = c[s.id]; if (!sc) { e.push(`${lang} scene ${s.id}: missing`); continue; } diff --git a/docs/ship-step.md b/docs/ship-step.md index dc9aa02..cb44b15 100644 --- a/docs/ship-step.md +++ b/docs/ship-step.md @@ -128,3 +128,10 @@ storebound play build --version-from version.json --env-file /run/secrets/ship.e storebound play upload --track production --version-from version.json --env-file /run/secrets/ship.env --json --result-file out/upload.json --yes case $? in 0|7) ;; 3) echo "owner: $(jq -r .error.consoleUrl out/upload.json)"; exit 1 ;; *) exit 1 ;; esac ``` + +## `--pre-check` and `--evidence` (upload steps) + +`apple upload` and `play upload` take `--pre-check ""` (config `upload.preCheck`): the command runs +(sh -c, project root) before any byte is sent. A nonzero exit is exit 1 with error code `PRE_CHECK_FAILED`. `--evidence +` (config `upload.evidence`) writes `{file, sha256, size, command, exitCode, stdoutTail, timestamp, +storeboundVersion}`, also on failure; the same object is the result's `preCheck` key. diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index c9fa7d2..b0c0381 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1376,3 +1376,42 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `cli/lib/play-publish.ts` (ready list from tracks plus the public store page, the only signal the API gives), the plan, the verify, the hint in `play status` / `status` / the desktop Releases tab. Non-track changes are not visible to the API. +||||||| parent of abe0821 (upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint) +## 115. A project's own pre-upload gate ran as a separate step and could be skipped + +- **What happens:** a store-readiness scan of the binary lived in the pipeline next to `apple upload` / `play upload`, so + a re-run, a manual upload or a reordered job sent the file without it, and nothing proved afterwards what had run. +- **Do:** pass the gate to the upload: `storebound apple upload app.ipa --pre-check "./scan.sh {file}" --evidence + build/pre-check.json` (same for `play upload`; or `upload.preCheck` / `upload.evidence` in the config). A nonzero exit + refuses the upload before any network call: ship-step exit 1, error code `PRE_CHECK_FAILED`. The evidence (file, + sha256, size, command, exit code, stdout tail, time, storebound version) is written also on failure and is in the + `--json` / `--result-file` result as `preCheck`. +- **Guard:** `cli/lib/pre-check.ts`, `tests/ship-step.test.ts` (failing check: no store write, evidence + error code). + +## 116. `storebound play games` died with a bare 403 when the Games Configuration API was off + +- **What happens:** the Play Games Services Publishing API (`gamesconfiguration.googleapis.com`) is a separate API in the + service account's Cloud project; while it is off every call is 403 SERVICE_DISABLED / accessNotConfigured. +- **Do:** the error now names the API, the project and the enable URL + (`https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=`); press Enable, wait a + minute, run it again. The plan: `storebound console plan play-games-services` (step 8 has the case). +- **Guard:** `gamesApiDisabledText` in `cli/play/games.ts`, `tests/game-services.test.ts`. + +## 117. Game Center creates `en-US` next to a live `en-GB` (spurious creates) + +- **What happens:** the config language `en` is `en-US`, the live Game Center has only `en-GB`; the plan wants to create + an `en-US` localization for every item and leaves the `en-GB` ones unmanaged. +- **Do:** map it: `storebound apple game-center --locale-map en=en-GB` or `games: { localeMap: { en: 'en-GB' } }` + (key: config language id or its App Store locale). The dry run suggests the exact flag when the live Game Center has + exactly one variant of a language the config spells differently. +- **Guard:** `gcLocaleMap` / `suggestLocaleMap` in `cli/lib/game-services.ts`, `tests/game-services.test.ts`. + +## 118. Screenshot copy missing for a locale, translated by hand into copy.json + +- **What happens:** `storebound check` / `shots render` stopped with ": no copy" and the only fix was typing every + caption. +- **Do:** `storebound shots copy translate --to de,es` (agent translates the copied source text, or `--translate ""` + runs your command per caption field). Languages stay marked machine-translated in `copy.translations.json` until + `storebound shots copy reviewed --lang de`. Captions over the limit (eyebrow 30, title line 28, sub 90, badge 18) are + not kept. +- **Guard:** `cli/shots/copy.ts`, `cli/lib/shots-copy-translate.ts`, `tests/shots-copy-translate.test.ts`. diff --git a/tests/game-services.test.ts b/tests/game-services.test.ts index f47c27e..636bcd8 100644 --- a/tests/game-services.test.ts +++ b/tests/game-services.test.ts @@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr import type { ShipConfig } from '../cli/lib/config'; import { validateConfig } from '../cli/lib/config'; import { capabilitySpecs, planCapabilities } from '../cli/lib/signing'; -import { gamesPrivacyFindings, gcFindings, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services'; +import { gamesPrivacyFindings, gcFindings, gcLocaleMap, liveGcLocales, suggestLocaleMap, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services'; import { consolePlan } from '../cli/lib/console-plan'; import { verifyText } from '../cli/lib/console-verify'; import { gameServicesFindings, type GameServicesStatus } from '../cli/games-status'; @@ -116,6 +116,42 @@ describe('apple game-center (fake App Store Connect)', () => { }); }); +describe('Game Center locale map', () => { + const live = { leaderboards: [{ id: 'lb-1', attributes: { vendorIdentifier: 'com.example.game.highscore', referenceName: 'com.example.game.highscore', defaultFormatter: 'INTEGER', submissionType: 'BEST_SCORE', scoreSortType: 'DESC' }, + localizations: [{ id: 'l1', attributes: { locale: 'en-GB', name: 'High score', formatterSuffix: ' points' } }, { id: 'l2', attributes: { locale: 'tr', name: 'En yüksek skor' } }] }], achievements: [] }; + const only = { ...GAME.games!, achievements: [] }; + const sig = (p: ReturnType) => p[0]!.locales.map(l => l.locale + ':' + l.action); + test('without a map the config en-US is created next to the live en-GB, and the suggestion names the map', () => { + expect(sig(planGameCenter(only, GAME.languages, live))).toEqual(['en-US:create', 'tr:keep']); + expect(suggestLocaleMap(GAME.languages, liveGcLocales(live))).toEqual([{ id: 'en', from: 'en-US', to: 'en-GB' }]); + }); + test('--locale-map en=en-GB (or games.localeMap) leaves nothing to create or update, and no suggestion remains', () => { + const map = gcLocaleMap(only, ['en=en-GB']); + expect(map).toEqual({ en: 'en-GB' }); + const plan = planGameCenter(only, GAME.languages, live, map); + expect(sig(plan)).toEqual(['en-GB:keep', 'tr:keep']); + expect(plan[0]!.action).toBe('keep'); + expect(suggestLocaleMap(GAME.languages, liveGcLocales(live), map)).toEqual([]); + expect(gcLocaleMap({ ...only, localeMap: { en: 'en-AU' } }, ['en=en-GB,tr=tr'])).toEqual({ en: 'en-GB', tr: 'tr' }); + expect(planGameCenter(only, GAME.languages, live, { 'en-US': 'en-GB' })[0]!.locales[0]!.locale).toBe('en-GB'); + }); + test('two English variants live: no guess; a bad map entry is refused', () => { + expect(suggestLocaleMap(GAME.languages, ['en-GB', 'en-AU', 'tr'])).toEqual([]); + expect(() => gcLocaleMap(only, ['en'])).toThrow('--locale-map'); + expect(validateGames({ ...only, localeMap: { en: 'english' } }, GAME.languages).join()).toContain('games.localeMap.en'); + }); + test('apple game-center against the fake store: the dry run warns with the exact flag; with the flag nothing is written', async () => { + s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } }); + const base = await run2(() => gameCenter(project(GAME).ctx(['--yes']))); + expect(base.writes.length).toBeGreaterThan(0); + for (const l of [...s.asc.all('gameCenterLeaderboardLocalizations'), ...s.asc.all('gameCenterAchievementLocalizations')]) if (l.attributes.locale === 'en-US') l.attributes.locale = 'en-GB'; + const dry = await run2(() => gameCenter(project(GAME).ctx(['--dry']))); + expect(dry.out + dry.err).toContain('--locale-map en=en-GB'); + const mapped = await run2(() => gameCenter(project(GAME).ctx(['--yes', '--locale-map', 'en=en-GB']))); + expect(mapped.writes).toEqual([]); + }); +}); + describe('play games (fake Games Configuration API)', () => { test('create, no-op re-run, update by name match; playId wins', async () => { const p = project(GAME); @@ -141,6 +177,21 @@ describe('play games (fake Games Configuration API)', () => { }); }); +describe('play games with the Games Configuration API disabled', () => { + test('a 403 SERVICE_DISABLED names the API, the enable URL for the project and the console plan', async () => { + s.faults.push({ method: 'GET', match: /leaderboards/, when: 'before', status: 403, times: 5, code: 'SERVICE_DISABLED', detail: 'Google Play Game Services Publishing API has not been used in project 987654321 before or it is disabled. SERVICE_DISABLED accessNotConfigured' }); + const err = await run2(() => playGames(project(GAME).ctx(['--dry']))).then(() => null, (e: Error) => e); + expect(err?.message).toContain('Google Play Game Services Publishing API'); + expect(err?.message).toContain('gamesconfiguration.googleapis.com'); + expect(err?.message).toContain('https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=987654321'); + expect(err?.message).toContain('storebound console plan play-games-services'); + }); + test('the console plan has the enable step', () => { + const plan = consolePlan('play-games-services', GAME); + expect(JSON.stringify(plan)).toContain('gamesconfiguration.googleapis.com'); + }); +}); + describe('project checks', () => { const ENT = 'com.apple.developer.game-center'; test('Game Center entitlement: missing, unreferenced, present, present without config', () => { diff --git a/tests/ship-step.test.ts b/tests/ship-step.test.ts index 3da79de..133b817 100644 --- a/tests/ship-step.test.ts +++ b/tests/ship-step.test.ts @@ -287,6 +287,34 @@ describe('idempotent re-upload (fake stores)', () => { expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'upload', store: 'apple', version: '1.2.0', build: '7', state: 'VALID' }); expect(writes(s.calls).filter(c => /buildUploads/.test(c.path))).toEqual([]); }); + test('play upload --pre-check: a passing command writes the evidence and joins the result; a failing one refuses with PRE_CHECK_FAILED before any write', async () => { + const d = tmp(); + const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') }); + const ev = join(d, 'ev', 'evidence.json'); + const bad = await ship('play upload', ['--json', '--yes', '--pre-check', 'echo scanning {file}; exit 3', '--evidence', ev], a => playUpload({ loaded: p.loaded, args: a })); + expect(bad.code).toBe(1); + const jb = JSON.parse(bad.out); + expect(jb).toMatchObject({ ok: false, error: { code: 'PRE_CHECK_FAILED' }, preCheck: { exitCode: 3, size: expect.any(Number) } }); + expect(JSON.parse(readFileSync(ev, 'utf8')).exitCode).toBe(3); + expect(writes(s.calls)).toEqual([]); + const ok = await ship('play upload', ['--json', '--yes', '--pre-check', 'echo scanning {file}', '--evidence', ev], a => playUpload({ loaded: p.loaded, args: a })); + expect(ok.code).toBe(0); + const e = JSON.parse(readFileSync(ev, 'utf8')); + expect(e).toMatchObject({ file: 'build/android/app.aab', exitCode: 0, storeboundVersion: expect.any(String) }); + expect(e.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(e.stdoutTail.join(' ')).toContain('scanning'); + expect(JSON.parse(ok.out).preCheck.sha256).toBe(e.sha256); + }); + + test('apple upload --pre-check: a failing command stops before any App Store Connect call', async () => { + const plist = 'CFBundleIdentifiercom.example.notesCFBundleShortVersionString1.2.0CFBundleVersion9'; + const p = project(BASE, { 'build/ios/export/Example.ipa': makeIpa(plist) }); + const n = s.calls.length; + const r = await ship('apple upload', ['--json', '--yes', '--pre-check', 'exit 1'], a => appleUpload({ loaded: p.loaded, args: a })); + expect(r.code).toBe(1); + expect(JSON.parse(r.out)).toMatchObject({ ok: false, step: 'upload', store: 'apple', error: { code: 'PRE_CHECK_FAILED' }, preCheck: { exitCode: 1 } }); + expect(s.calls.length).toBe(n); + }); }); describe('status', () => { diff --git a/tests/shots-copy-translate.test.ts b/tests/shots-copy-translate.test.ts new file mode 100644 index 0000000..85bf9d5 --- /dev/null +++ b/tests/shots-copy-translate.test.ts @@ -0,0 +1,52 @@ +// `storebound shots copy translate | check | reviewed`: fills copy.json per language with a marker file, caption limits. +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ShipConfig } from '../cli/lib/config'; +import { validateCopy } from '../cli/shots/lib'; +import { applyFields, marksNotes, overLimit, parseMarks, sceneFields } from '../cli/lib/shots-copy-translate'; +import { run } from '../cli/shots/copy'; +import { BASE, capture, project } from './helpers/store-mock'; + +const CFG: ShipConfig = { + ...BASE, + languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }], + shots: { copy: 'store/shots/copy.json', scenes: [{ id: 'home', palette: 'gold', frames: [] }] } as never, +}; +const COPY = JSON.stringify({ en: { home: { eyebrow: 'Hello', title: ['Everything', 'in order'], sub: 'Lists and notes.' } } }); +const go = async (p: ReturnType, argv: string[]) => { const r = await capture(() => run(p.ctx(argv))); return r; }; + +describe('shots copy translate', () => { + test('without a hook the source is copied in as status=translate; the missing-locale problem names the command', async () => { + const p = project(CFG, { 'store/shots/copy.json': COPY }); + expect(validateCopy(JSON.parse(COPY), CFG.shots!, ['en', 'de'])[0]).toContain('storebound shots copy translate --to de'); + await go(p, ['translate', '--to', 'de']); + const copy = JSON.parse(readFileSync(join(p.root, 'store/shots/copy.json'), 'utf8')); + expect(copy.de.home.title).toEqual(['Everything', 'in order']); + const marks = parseMarks(readFileSync(join(p.root, 'store/shots/copy.translations.json'), 'utf8')); + expect(marks.de).toEqual({ from: 'en', status: 'translate' }); + expect(marksNotes(marks, 'copy')[0]).toContain('not translated yet'); + expect(validateCopy(copy, CFG.shots!, ['en', 'de'])).toEqual([]); + }); + test('with a hook: draft; an over-limit field keeps the source and the language stays translate; reviewed refuses translate', async () => { + const p = project(CFG, { 'store/shots/copy.json': COPY }); + await go(p, ['translate', '--to', 'de', '--translate', 'tr a-z A-Z']); + let copy = JSON.parse(readFileSync(join(p.root, 'store/shots/copy.json'), 'utf8')); + expect(copy.de.home.eyebrow).toBe('HELLO'); + expect(parseMarks(readFileSync(join(p.root, 'store/shots/copy.translations.json'), 'utf8')).de!.status).toBe('draft'); + const r = await go(p, ['reviewed', '--lang', 'de']); + expect(r.exit).toBeFalsy(); + expect(readFileSync(join(p.root, 'store/shots/copy.translations.json'), 'utf8').trim()).toBe('{}'); + const q = project(CFG, { 'store/shots/copy.json': COPY }); + await go(q, ['translate', '--to', 'de', '--translate', 'echo xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx']); + copy = JSON.parse(readFileSync(join(q.root, 'store/shots/copy.json'), 'utf8')); + expect(copy.de.home.title[0]).toBe('Everything'); + expect(parseMarks(readFileSync(join(q.root, 'store/shots/copy.translations.json'), 'utf8')).de!.status).toBe('translate'); + }); + test('helpers: fields, limits', () => { + const sc = JSON.parse(COPY).en.home; + expect(sceneFields(sc).map(f => f.key)).toEqual(['eyebrow', 'title.0', 'title.1', 'sub']); + expect(applyFields(sc, { sub: 'x' }).sub).toBe('x'); + expect(overLimit({ home: { title: ['a'.repeat(40), 'b'] } })[0]).toMatchObject({ field: 'title.0', limit: 28 }); + }); +}); From 8084104a4d8eb5b8f0183068fddaebdbaceca117 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:14:18 +0800 Subject: [PATCH 005/126] desktop coverage entry for shots copy --- cli/lib/desktop-coverage.ts | 1 + docs/desktop-coverage.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/lib/desktop-coverage.ts b/cli/lib/desktop-coverage.ts index 6896869..c869941 100644 --- a/cli/lib/desktop-coverage.ts +++ b/cli/lib/desktop-coverage.ts @@ -137,6 +137,7 @@ export const DESKTOP_COVERAGE: Record = { 'shots capture': { where: `${T}: Captures`, methods: ['shots.capture'] }, 'shots login': { where: `${T}: owner steps (terminal command)`, methods: [], owner: 'signing in to a demo account by hand in a visible browser' }, 'shots fonts': { where: `${T}: Captures`, methods: ['shots.fonts'] }, + 'shots copy': { not: 'caption translation drafts are edited by the agent in store/shots/copy.json; the window shows the result in the Store art tab' }, 'shots render': { where: 'Store art tab: Render', methods: ['shots.render'] }, 'shots jpeg': { where: `${T}: Captures`, methods: ['shots.jpeg'] }, 'shots check': { where: `${T}: Captures`, methods: ['shots.check'] }, diff --git a/docs/desktop-coverage.md b/docs/desktop-coverage.md index 55f5b06..d57be84 100644 --- a/docs/desktop-coverage.md +++ b/docs/desktop-coverage.md @@ -5,7 +5,7 @@ Every CLI command (cli/commands.ts) and the desktop place that runs it. "covered (serve.ts, or a host command `host:`) and the window calls it; tests/desktop-coverage.test.ts fails on a gap. Owner-only parts are owner actions in the window or owner steps with their terminal command (More tools). -**100 commands:** 92 covered, 0 gaps, 8 not for the desktop. +**101 commands:** 92 covered, 0 gaps, 9 not for the desktop. | command | status | where in the window | methods | owner step | |---|---|---|---|---| @@ -103,6 +103,7 @@ Owner-only parts are owner actions in the window or owner steps with their termi | `shots capture` | covered | More tools: Captures | `shots.capture` | | | `shots login` | covered | More tools: owner steps (terminal command) | | signing in to a demo account by hand in a visible browser | | `shots fonts` | covered | More tools: Captures | `shots.fonts` | | +| `shots copy` | not-for-desktop | caption translation drafts are edited by the agent in store/shots/copy.json; the window shows the result in the Store art tab | | | | `shots render` | covered | Store art tab: Render | `shots.render` | | | `shots jpeg` | covered | More tools: Captures | `shots.jpeg` | | | `shots check` | covered | More tools: Captures | `shots.check` | | From 089c14928908c924ac17ef03f87164897c6f7b45 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:15:14 +0800 Subject: [PATCH 006/126] desktop coverage test: shots copy is agent-edited --- tests/desktop-coverage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/desktop-coverage.test.ts b/tests/desktop-coverage.test.ts index 36f198b..7a279e4 100644 --- a/tests/desktop-coverage.test.ts +++ b/tests/desktop-coverage.test.ts @@ -8,7 +8,7 @@ import { describeMethods } from '../cli/serve'; import { analyze, render } from '../tools/gen-desktop-coverage'; /** The only commands that make no sense in a window. Adding one here needs a reason in the coverage map. */ -const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings', 'aab', 'watch', 'events']; +const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings', 'aab', 'watch', 'events', 'shots copy']; describe('desktop coverage', () => { const { rows, extra } = analyze(); From b325b453543bac13a25f0191af7bb20836c3ba16 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:17:01 +0800 Subject: [PATCH 007/126] merge fixups: changelog and pitfall numbering --- CHANGELOG.md | 1 - skills/store-pitfalls/SKILL.md | 1 - 2 files changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 043b6e2..6f174cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ ## Unreleased - Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch). -||||||| parent of abe0821 (upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint) - `apple upload` / `play upload` `--pre-check ""` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence ` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`. - New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate ""`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language. - `apple game-center --locale-map en=en-GB` (config `games.localeMap`) maps a config language to the live Game Center locale; the dry run suggests it when the live Game Center has exactly one variant of the language. diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index b0c0381..55badb3 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1376,7 +1376,6 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `cli/lib/play-publish.ts` (ready list from tracks plus the public store page, the only signal the API gives), the plan, the verify, the hint in `play status` / `status` / the desktop Releases tab. Non-track changes are not visible to the API. -||||||| parent of abe0821 (upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint) ## 115. A project's own pre-upload gate ran as a separate step and could be skipped - **What happens:** a store-readiness scan of the binary lived in the pipeline next to `apple upload` / `play upload`, so From 2e8593ed5c55b0a95ed1257b1f2f4eea01a3838a Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:17:45 +0800 Subject: [PATCH 008/126] pitfalls spacing --- skills/store-pitfalls/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 55badb3..70aa528 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1376,6 +1376,7 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `cli/lib/play-publish.ts` (ready list from tracks plus the public store page, the only signal the API gives), the plan, the verify, the hint in `play status` / `status` / the desktop Releases tab. Non-track changes are not visible to the API. + ## 115. A project's own pre-upload gate ran as a separate step and could be skipped - **What happens:** a store-readiness scan of the binary lived in the pipeline next to `apple upload` / `play upload`, so From 9a937b5512f4a47fc90ff35c8c8fcd2e64d16675 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:04:26 +0800 Subject: [PATCH 009/126] apple build --platform macos --tauri: Mac App Store build of a Tauri app (sandbox entitlements, profile, productbuild pkg, path-free, checks); apple upload accepts the pkg --- cli/apple/build-mas.ts | 201 +++++++++++++++++++++++++++ cli/apple/build.ts | 3 + cli/apple/signing.ts | 2 +- cli/apple/upload.ts | 37 +++-- cli/lib/mas.ts | 299 ++++++++++++++++++++++++++++++++++++++++ cli/lib/signing.ts | 4 +- cli/lib/xcode.ts | 8 +- tests/mas-build.test.ts | 194 ++++++++++++++++++++++++++ 8 files changed, 726 insertions(+), 22 deletions(-) create mode 100644 cli/apple/build-mas.ts create mode 100644 cli/lib/mas.ts create mode 100644 tests/mas-build.test.ts diff --git a/cli/apple/build-mas.ts b/cli/apple/build-mas.ts new file mode 100644 index 0000000..d1bb17c --- /dev/null +++ b/cli/apple/build-mas.ts @@ -0,0 +1,201 @@ +// Mac App Store build of a Tauri app: `tauri build --bundles app`, signed for the Mac App Store, then productbuild to a .pkg. +// storebound apple build --platform macos --tauri [--path-free] [--target universal-apple-darwin] +// [--version x.y.z] [--build-number N] [--profile ] [--out build/macos] [--unsigned] [--dry] [--json] +// Steps: Tauri project -> version / build number (next free one on App Store Connect, platform MAC_OS) -> signing +// identities from the keychain ("Apple Distribution" or "3rd Party Mac Developer Application"; "Mac Installer +// Distribution" or "3rd Party Mac Developer Installer") -> Mac App Store provisioning profile (--profile, installed, or +// from the API) -> entitlements (app sandbox + application / team identifier, the project's own keys kept) -> a +// tauri.conf overlay (signingIdentity, entitlements, embedded.provisionprofile, bundleVersion) -> `tauri build` -> +// checks of the .app (sandbox entitlement, category, minimum OS, version / build, profile inside, no local paths) -> +// productbuild --sign -> pkgutil --check-signature. --path-free remaps the checkout, CARGO_HOME, RUSTUP_HOME and the home +// folder in rustc, strips debug info and symbols, and fails when the home folder or user name is still in the app. +// --unsigned builds without identities, profile and installer signature (a pipeline check on any Mac; not uploadable). +// Result: /export/-/.pkg + /latest.json; then `storebound apple upload --platform macos`. +// codesign needs the Mac's logged-in GUI session (SSH: errSecInternalComponent), same as apple build. +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, userInfo } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import type { Ctx } from '../lib/ctx'; +import { need } from '../lib/ctx'; +import { done, log, printJson, warn } from '../lib/log'; +import { parseMobileprovision, parsePlist, type PlistValue } from '../lib/plist'; +import { nextBuildNumber } from '../lib/release'; +import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing'; +import { remoteBuildSession, showCmd } from '../lib/xcode'; +import { + appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities, + pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId, +} from '../lib/mas'; +import { readdirSync } from 'node:fs'; +import { ascClient, type Asc } from './asc'; +import { findBundleId } from './signing'; +import { runLogged } from './build'; +import * as rt from '../lib/rt'; +import { shipPhase } from '../lib/ship-step'; + +const TAG = 'build'; +const run1 = (argv: string[], timeout = 120_000): { ok: boolean; out: string } => { + const r = rt.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout }); + return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}` }; +}; + +/** The Tauri CLI: the project's node_modules/.bin/tauri (walking up), `cargo tauri`, else bunx. */ +export function tauriCommand(cwd: string, exists: (p: string) => boolean = existsSync, which: (c: string) => string | null = rt.which): string[] { + for (let d = cwd, prev = ''; d !== prev; prev = d, d = dirname(d)) { const p = join(d, 'node_modules', '.bin', 'tauri'); if (exists(p)) return [p]; } + if (which('cargo-tauri')) return ['cargo', 'tauri']; + return ['bunx', '--bun', '@tauri-apps/cli']; +} + +/** Mac App Store profiles installed for Xcode (both folders, .provisionprofile and .mobileprovision). */ +export function installedMasProfiles(bundleId: string, teamId: string | null, home = homedir()): MasProfile[] { + const files: { path: string; profile: Record }[] = []; + for (const d of profileDirs(home)) { + let names: string[] = []; try { names = readdirSync(d).filter(f => /\.(provisionprofile|mobileprovision)$/.test(f)); } catch { continue; } + for (const f of names) { try { files.push({ path: join(d, f), profile: parseMobileprovision(readFileSync(join(d, f))) }); } catch { /* not a profile */ } } + } + return pickMasProfiles(files, bundleId, teamId); +} + +/** The ACTIVE MAC_APP_STORE profile of the bundle id from the API, written to `dest`. Null when there is none. */ +async function fetchMasProfile(c: Asc, bundleId: string, dest: string): Promise { + const b = await findBundleId(c, bundleId); + if (!b) return null; + const list: ApiProfile[] = await readProfiles(p => c.all(p), b.id, 'MAC_APP_STORE'); + const ok = list.filter(p => p.attributes.profileState === 'ACTIVE' && Date.parse(p.attributes.expirationDate ?? '') - Date.now() > 86_400_000) + .sort((x, y) => Date.parse(y.attributes.expirationDate!) - Date.parse(x.attributes.expirationDate!))[0]; + if (!ok) return null; + const content = ok.attributes.profileContent ?? (await c.asc('GET', `/v1/profiles/${ok.id}`)).data.attributes.profileContent; + if (!content) return null; + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, Buffer.from(content, 'base64')); + return dest; +} + +export async function run(ctx: Ctx): Promise { + const { config, root, resolve } = ctx.loaded; + const a = ctx.args; + const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); + const dry = a.has('dry'), json = a.has('json'); + const unsigned = a.has('unsigned'); + const pathFree = a.has('path-free'); + const tauri = findTauri(root, a.get('tauri')); + if (tauri.identifier !== bundleId) throw new Error(`${relative(root, tauri.confPath)}: identifier is "${tauri.identifier}", the config's apple.bundleId is "${bundleId}": they must be the same`); + const target = a.str('target', 'universal-apple-darwin'); + const out = resolve(a.get('out') ?? (config.apple?.build?.out ? `${config.apple.build.out.replace(/\/ios$/, '')}/macos` : 'build/macos')); + const version = a.get('version') ?? tauri.version; + if (!version || !/^\d+(\.\d+){0,2}$/.test(version)) throw new Error('version: x.y.z from --version or tauri.conf.json "version"'); + let build = a.get('build-number'); + let teamId = config.apple?.signing?.teamId ?? null; + let client: Asc | null = null; + if (!unsigned || !build) { + try { + client = await ascClient(root, bundleId, config.apple?.appId); + if (!build) { + const builds = await client.all(`/v1/builds?filter[app]=${await client.app()}&filter[preReleaseVersion.platform]=MAC_OS&limit=200&fields[builds]=version`); + build = nextBuildNumber(builds.map(b => String(b.attributes.version))); + log(TAG, `${builds.length} macOS builds on App Store Connect: next build number ${build} (CFBundleVersion must increase)`); + } + if (!teamId) teamId = (await findBundleId(client, bundleId))?.attributes?.seedId ?? null; + } catch (e) { + if (!dry) throw new Error(`${(e as Error).message}\n(pass --build-number N and set apple.signing.teamId to build without App Store Connect reads)`); + warn(TAG, `App Store Connect not readable (${(e as Error).message.split('\n')[0]?.slice(0, 120)}): dry run continues with placeholders`); + } + } + if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42'); + const buildNo = build ?? ''; + const team = teamId ?? ''; + + // identities and profile (a real signed run needs them; a dry run shows placeholders) + const mac = process.platform === 'darwin'; + let appIdentity: string | null = null, installerIdentity: string | null = null; + if (!unsigned && mac) { + const picked = pickMasIdentities(parseIdentities(run1(['security', 'find-identity', '-v']).out), teamId); + appIdentity = picked.app?.name ?? null; installerIdentity = picked.installer?.name ?? null; + const missing = [!appIdentity && 'an "Apple Distribution" / "3rd Party Mac Developer Application" identity', !installerIdentity && 'a "Mac Installer Distribution" / "3rd Party Mac Developer Installer" identity'].filter(Boolean); + if (missing.length && !dry) throw new Error(`no ${missing.join(' and no ')} for team ${team} in the keychain. Create them with apple.signing.certificateType DISTRIBUTION / MAC_INSTALLER_DISTRIBUTION and \`storebound apple signing ensure\`, then import the certificate into the login keychain.`); + if (missing.length) warn(TAG, `missing in the keychain: ${missing.join(', ')}`); + } + const profileFile = join(out, 'embedded.provisionprofile'); + let profile: string | null = null; + if (!unsigned) { + const given = a.get('profile'); + if (given) { profile = resolve(given); if (!existsSync(profile)) throw new Error(`--profile ${given}: no such file`); } + else { + const have = installedMasProfiles(bundleId, teamId)[0]; + if (have) { profile = have.path; log(TAG, `Mac App Store profile "${have.name}" (installed, expires ${have.expires.slice(0, 10)})`); } + else if (client && !dry) profile = await fetchMasProfile(client, bundleId, profileFile); + else if (client) { try { profile = await fetchMasProfile(client, bundleId, profileFile); } catch (e) { warn(TAG, `profile not readable: ${(e as Error).message.split('\n')[0]}`); } } + if (!profile && !dry) throw new Error(`no active MAC_APP_STORE provisioning profile for ${bundleId}: set apple.signing.profileType to MAC_APP_STORE and run \`storebound apple signing ensure\`, or pass --profile `); + if (!profile) warn(TAG, `no MAC_APP_STORE profile for ${bundleId} yet (storebound apple signing ensure with apple.signing.profileType MAC_APP_STORE)`); + } + } + + const home = homedir(), user = userInfo().username; + const env = pathFree ? pathFreeEnv({ home, user, root: process.env.STOREBOUND_PATHFREE_ROOT ?? root, cargoHome: process.env.CARGO_HOME, rustupHome: process.env.RUSTUP_HOME, rustflags: process.env.RUSTFLAGS }) : {}; + const entFile = join(out, 'mas.entitlements'); + const configFile = join(out, 'tauri.mas.conf.json'); + const exportPath = join(out, 'export', `${version}-${buildNo}`); + const pkg = join(exportPath, `${tauri.productName}.pkg`); + const tauriCmd = tauriCommand(tauri.cwd); + const category = tauri.category && !categoryId(tauri.category) ? undefined : tauri.category; + const overlay = tauriOverlay({ appIdentity, entitlementsFile: entFile, profile, version, build: buildNo, unsigned, minOs: tauri.minOs, category }); + const input = { tauri, tauriCmd, target, out, configFile, entitlementsFile: entFile, profile, appIdentity, installerIdentity: installerIdentity ?? '', version, build: buildNo, unsigned, pathFree, pkg }; + const steps = masSteps(input); + const existingEnt = tauri.entitlements && existsSync(tauri.entitlements) ? (parsePlist(readFileSync(tauri.entitlements)) as Record) : undefined; + const ent = masEntitlements({ teamId: team, bundleId, existing: existingEnt }); + const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity, + profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])), + steps: [showCmd(steps.tauri, null), showCmd(steps.codesignVerify, null), showCmd(steps.productbuild, null), showCmd(steps.pkgCheck, null)], out: relative(root, out) || '.', pkg: relative(root, pkg) }; + if (dry) { + if (json) { printJson(plan); return; } + log(TAG, `Tauri ${tauri.productName} (${bundleId}) ${version} (${buildNo}) for ${target}, ${unsigned ? 'UNSIGNED' : 'Mac App Store signing'}${pathFree ? ', path-free' : ''}`); + log(TAG, `app identity: ${appIdentity ?? '(none found)'}; installer identity: ${installerIdentity ?? '(none found)'}; profile: ${plan.profile ?? '(none)'}`); + log(TAG, `entitlements: ${plan.entitlements.join(', ')}`); + if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`); + for (const s of plan.steps) log(TAG, `would run: ${s}`); + done(TAG, 'macOS build planned (dry run)'); + return; + } + if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)'); + const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim()); + if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session (write the command into a .command file and \`open -a Terminal \`), or add --unsigned for a pipeline check.`); + mkdirSync(out, { recursive: true }); + writeFileSync(entFile, entitlementsXml(ent)); + writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`); + shipPhase('build', `tauri build ${target}, build ${buildNo}`); + await runLogged(steps.tauri, tauri.cwd, join(out, 'tauri-build.log'), showCmd(steps.tauri, null), env); + const appDir = findBuiltApp(tauri.cwd, root, target, tauri.productName); + if (!appDir) throw new Error(`tauri build exited 0 but no ${tauri.productName}.app is under ${join(tauri.cwd, 'target', target, 'release/bundle/macos')} (see ${join(out, 'tauri-build.log')})`); + shipPhase('verify', `checking ${basenameOf(appDir)}`); + if (!unsigned) { + const v = run1(steps.codesignVerify.map(x => (x.endsWith('.app') ? appDir : x))); + if (!v.ok) throw new Error(`codesign --verify failed:\n${v.out.slice(-600)}`); + } + const entOut = unsigned ? null : entitlementKeysOf(run1(['codesign', '-d', '--entitlements', ':-', appDir]).out); + const leaks = pathFree || a.has('scan-paths') ? scanPathLeaks(appDir, leakNeedles(home, user, root)) : []; + const checks = appProblems({ info: readAppInfo(appDir), entitlements: entOut, hasProfile: existsSync(join(appDir, 'Contents', 'embedded.provisionprofile')), leaks }, + { bundleId, version, build: buildNo, signed: !unsigned, pathFree }); + for (const w of checks.warnings) warn(TAG, w); + if (checks.errors.length) throw new Error(`${basenameOf(appDir)} was built, but the Mac App Store would refuse it:\n${checks.errors.map(x => ` ${x}`).join('\n')}`); + shipPhase('sign', 'productbuild'); + rmSync(exportPath, { recursive: true, force: true }); + mkdirSync(exportPath, { recursive: true }); + const pbArgv = masSteps({ ...input, installerIdentity }).productbuild.map(x => (x.endsWith('.app') ? appDir : x)); + await runLogged(pbArgv, tauri.cwd, join(out, 'productbuild.log'), showCmd(pbArgv, null)); + if (!existsSync(pkg)) throw new Error(`productbuild exited 0 but left no ${pkg}`); + if (!unsigned) { + const sig = parsePkgSignature(run1(steps.pkgCheck).out); + const bad = pkgProblems(sig); + if (bad.length) throw new Error(`${relative(root, pkg)}:\n${bad.map(x => ` ${x}`).join('\n')}`); + log(TAG, `pkg signed with "${sig.certificate}"`); + } + copyIfAny(profile, profileFile); + const result = { pkg, platform: 'MAC_OS', bundleId, version, build: buildNo, unsigned, pathFree, leaks: leaks.length }; + writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)}\n`); + writeFileSync(`${pkg}.json`, `${JSON.stringify(result, null, 2)}\n`); + if (json) printJson(result); + done(TAG, `${relative(root, pkg)}: ${bundleId} ${version} (${buildNo})${unsigned ? ' UNSIGNED, not uploadable' : `. Next: storebound apple upload ${relative(root, pkg)} --platform macos --dry`}`); +} + +const basenameOf = (p: string): string => p.split(/[\\/]/).pop() ?? p; +function copyIfAny(src: string | null, dest: string): void { if (src && src !== dest && existsSync(src) && !existsSync(dest)) { try { copyFileSync(src, dest); } catch { /* optional */ } } } diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 277bfce..b5df488 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -26,6 +26,7 @@ import { capacitorProject, webStep } from '../lib/web-assets'; import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; +import { parsePlatform } from './platform'; import { findBundleId } from './signing'; import * as rt from '../lib/rt'; import { shipPhase } from '../lib/ship-step'; @@ -98,6 +99,8 @@ export async function exportIpa(o: { root: string; out: string; archivePath: str export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; + // Mac App Store build of a Tauri app (cli/apple/build-mas.ts) + if (a.has('tauri') || parsePlatform(a.get('platform')) === 'MAC_OS') return (await import('./build-mas')).run(ctx); const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); const cfg = config.apple?.build ?? {}; const signing = config.apple?.signing ?? {}; diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index 30ce943..cea09e0 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -55,7 +55,7 @@ function localKeys(root: string): Map { /** SHA-1s of code signing identities in the keychain search list (macOS; empty elsewhere). Read-only. */ function keychainIdentities(): Set { if (process.platform !== 'darwin' || !rt.which('security')) return new Set(); - const r = rt.spawnSync(['security', 'find-identity', '-v', '-p', 'codesigning'], { stdout: 'pipe', stderr: 'pipe' }); + const r = rt.spawnSync(['security', 'find-identity', '-v'], { stdout: 'pipe', stderr: 'pipe' }); return new Set(parseIdentities(r.stdout.toString()).map(i => i.sha1)); } diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 966019f..03b19c6 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -17,6 +17,7 @@ // timeout (exit 7). // An upload is a write: --dry shows the plan; a real run asks first (--yes in scripts). import { runPreCheck } from '../lib/pre-check'; +import { pkgInfo } from '../lib/mas'; import { appleSdkProblem } from '../lib/store-minimums'; import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs'; import { basename, join, relative } from 'node:path'; @@ -29,6 +30,7 @@ import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { BUILD_WAIT_EXIT } from '../lib/build-ops'; import { type Asc, ascClient, type J } from './asc'; +import { parsePlatform } from './platform'; import { waitForBuild, waitMessage } from './testflight'; import { describeReq } from './jsonapi'; import { createOnce } from '../lib/upsert'; @@ -139,13 +141,13 @@ export async function sendParts(o: SendPartsOptions): Promise { } finally { beat.stop(); } } -async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType, deadline: number, o: HeartbeatOptions = {}): Promise { +async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType, deadline: number, o: HeartbeatOptions = {}, platform: 'IOS' | 'MAC_OS' = 'IOS'): Promise { const size = statSync(ipa).size; - const reqs = buildUploadReqs(appId, info, basename(ipa), size); + const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform); // one build upload per version + build number: an earlier one that did not fail is resumed, never doubled - const prior = (await uploadsOf(c, appId, info)).find((u: J) => u.attributes?.state?.state !== 'FAILED'); + const prior = (await uploadsOf(c, appId, info, platform)).find((u: J) => u.attributes?.state?.state !== 'FAILED'); const made = prior ? { value: prior, created: false } : await createOnce({ what: `build upload ${info.version} (${info.build})`, tag: TAG, - find: async () => (await uploadsOf(c, appId, info)).find((u: J) => u.attributes?.state?.state !== 'FAILED') ?? null, + find: async () => (await uploadsOf(c, appId, info, platform)).find((u: J) => u.attributes?.state?.state !== 'FAILED') ?? null, create: async () => (await c.asc('POST', reqs.upload.path, reqs.upload.body)).data }); const up = made.value; log(TAG, made.created ? `build upload ${up.id} created` : `build upload ${up.id} exists (${up.attributes?.state?.state ?? '?'}): resuming it`); @@ -179,8 +181,8 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType => - c.all(`/v1/apps/${appId}/buildUploads?filter[cfBundleShortVersionString]=${encodeURIComponent(info.version)}&filter[cfBundleVersion]=${encodeURIComponent(info.build)}&filter[platform]=IOS&limit=20`); +const uploadsOf = (c: Asc, appId: string, info: { version: string; build: string }, platform = 'IOS'): Promise => + c.all(`/v1/apps/${appId}/buildUploads?filter[cfBundleShortVersionString]=${encodeURIComponent(info.version)}&filter[cfBundleVersion]=${encodeURIComponent(info.build)}&filter[platform]=${platform}&limit=20`); /** * `xcrun altool --upload-package`: runs in the background with a heartbeat (it prints nothing until it is done) and is @@ -232,25 +234,30 @@ export async function run(ctx: Ctx): Promise { const via = a.str('via', 'api'); if (via !== 'api' && via !== 'altool') throw new Error('--via api | altool'); const want = a.get('build'); - let ipa = a._.find(x => x.endsWith('.ipa')); + const macos = parsePlatform(a.get('platform')) === 'MAC_OS'; + let ipa = a._.find(x => x.endsWith('.ipa') || x.endsWith('.pkg')); if (!ipa) { const dir = join(resolve(config.apple?.build?.out ?? 'build/ios'), 'export'); let found = exportedIpas(dir); if (want) found = found.filter(f => { try { return ipaInfo(f).build === want; } catch { return false; } }); + if (!found[0] && macos) throw new Error('usage: storebound apple upload --platform macos (storebound apple build --platform macos --tauri makes one)'); if (!found[0]) throw new Error(`usage: storebound apple upload (no ${want ? `build ${want} ` : ''}IPA in ${relative(root, dir)}; storebound apple build makes one)`); ipa = found[0]; } else ipa = resolve(ipa); shipPhase('verify', `checking ${relative(root, ipa)}`); - const info = ipaInfo(ipa); + const isPkg = ipa.endsWith('.pkg'); + const platform: 'IOS' | 'MAC_OS' = isPkg ? 'MAC_OS' : 'IOS'; + if (macos && !isPkg) throw new Error('--platform macos uploads a signed .pkg (storebound apple build --platform macos --tauri ), not an .ipa'); + const info: ReturnType = isPkg ? pkgInfo(ipa, bundleId) : ipaInfo(ipa); shipReport({ version: info.version, build: info.build, url: consoleUrl('asc.testflight') }); if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`); if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`); // App Store Connect refuses IPAs from an Xcode / SDK older than its current minimum (cli/lib/store-minimums.ts) - const sdkWhy = appleSdkProblem(info); + const sdkWhy = isPkg ? null : appleSdkProblem(info); if (sdkWhy) throw new Error(`${relative(root, ipa)}: not uploaded, ${sdkWhy}`); // every PrivacyInfo.xcprivacy in the app (root, Frameworks, bundles): Apple refuses the build minutes after the // upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call - const privacy = bundleTrackingIssues(ipaManifests(ipa)); + const privacy = isPkg ? [] : bundleTrackingIssues(ipaManifests(ipa)); if (privacy.length) throw new Error(`${relative(root, ipa)}: not uploaded, Apple would refuse it as Invalid Binary:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest, rebuild (storebound apple build) and upload the new IPA.`); const pre = await runPreCheck({ args: a, config, root, resolve, file: ipa, shown: relative(root, ipa), tag: TAG }); const size = statSync(ipa).size; @@ -263,14 +270,14 @@ export async function run(ctx: Ctx): Promise { const appId = await c.app(); // the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0 // from a project that ignored the intended version otherwise lands in the wrong train - const editable = await c.editableVersion().catch(() => null); + const editable = await c.editableVersion(platform).catch(() => null); const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version'); if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); } - const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`); + const builds = await c.all(`/v1/builds?filter[app]=${appId}${isPkg ? '&filter[preReleaseVersion.platform]=MAC_OS' : ''}&limit=200&fields[builds]=version,processingState`); const numbers = builds.map(b => String(b.attributes.version)); const next = nextBuildNumber(numbers); // the same IPA uploaded by an earlier run: nothing to send again, only the wait - const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined; + const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info, platform).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined; const onAsc = builds.find(b => String(b.attributes.version) === info.build); const decision = existingBuild({ ipa: relative(root, ipa), build: info.build, onAsc: !!onAsc, state: onAsc?.attributes?.processingState, uploadComplete: !!already, allowExisting: a.has('allow-existing'), next }); // the ship-step contract: a build number App Store Connect has already is "nothing to do" (exit 7), never a second upload @@ -291,7 +298,7 @@ export async function run(ctx: Ctx): Promise { stop(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout, waitMessage(info.build, r)); } if (numbers.length && compareBuild(info.build, next) < 0) warn(TAG, `build ${info.build} is lower than the highest uploaded one (next free: ${next}); Apple accepts it only if this version train has no higher build`); - const reqs = buildUploadReqs(appId, info, basename(ipa), size); + const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform); const plan = via === 'api' ? [describeReq(reqs.upload), describeReq(reqs.file('')), `PUT the file parts to the upload URLs Apple returns`, describeReq(reqs.commit('', '')), 'GET /v1/buildUploads/ until COMPLETE'] : [showCmd(altoolArgs(ipa, { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' }), null)]; @@ -309,7 +316,7 @@ export async function run(ctx: Ctx): Promise { shipPhase('upload', `uploading ${mb(size)} via ${via}`, 0); log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build}), ${mb(size)}, via ${via}, timeout ${a.str('timeout', '60m')}`); if (via === 'api') { - try { await apiUpload(c, appId, ipa, info, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } + try { await apiUpload(c, appId, ipa, info, deadline, {}, platform); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } else { try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } diff --git a/cli/lib/mas.ts b/cli/lib/mas.ts new file mode 100644 index 0000000..cc5d15b --- /dev/null +++ b/cli/lib/mas.ts @@ -0,0 +1,299 @@ +// Mac App Store builds of a Tauri app: detection, entitlements, signing identities, path-free build flags, and the +// checks of the built .app and .pkg. Pure except findTauri / scanPathLeaks / readAppInfo (bounded file reads). +// storebound apple build --platform macos --tauri [--path-free] (cli/apple/build-mas.ts runs it) +import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { basename, dirname, join, relative, sep } from 'node:path'; +import { parsePlist, toXmlPlist, type PlistValue } from './plist'; +import * as rt from './rt'; + +export interface TauriProject { + /** Folder with tauri.conf.json (usually src-tauri). */ + dir: string; + confPath: string; + /** Folder `tauri build` runs in: the parent of `dir`. */ + cwd: string; + productName: string; + identifier: string; + version?: string; + category?: string; + minOs?: string; + entitlements?: string; + conf: Record; +} + +const SKIP = new Set(['node_modules', 'target', '.git', 'dist', 'build', '.storebound', 'Pods']); + +/** The Tauri project: `hint` (a folder with tauri.conf.json, or the file itself) or the only tauri.conf.json found. */ +export function findTauri(root: string, hint?: string): TauriProject { + const names = ['tauri.conf.json', 'tauri.conf.json5']; + let confPath: string | undefined; + if (hint && hint !== 'true') { + const p = join(root, hint); + if (/tauri\.conf\.json5?$/.test(p) && existsSync(p)) confPath = p; + else confPath = names.map(n => join(p, n)).find(existsSync); + if (!confPath) throw new Error(`--tauri ${hint}: no tauri.conf.json in it`); + } else { + const hits: string[] = []; + const walk = (dir: string, d: number): void => { + let ns: string[]; try { ns = readdirSync(dir); } catch { return; } + for (const n of ns) if (names.includes(n)) hits.push(join(dir, n)); + if (d <= 0) return; + for (const n of ns) { if (SKIP.has(n) || n.startsWith('.')) continue; const p = join(dir, n); try { if (statSync(p).isDirectory()) walk(p, d - 1); } catch { /* unreadable */ } } + }; + walk(root, 4); + if (hits.length !== 1) throw new Error(hits.length ? `several Tauri projects (${hits.map(h => relative(root, h)).join(', ')}): pass --tauri ` : 'no tauri.conf.json found: pass --tauri '); + confPath = hits[0]!; + } + let conf: Record; + try { conf = JSON.parse(readFileSync(confPath, 'utf8')); } catch (e) { throw new Error(`${confPath}: not valid JSON (${(e as Error).message}); tauri.conf.json5 / Tauri.toml are not read, use tauri.conf.json`); } + const mac = conf.bundle?.macOS ?? {}; + const productName = String(conf.productName ?? ''); + if (!productName) throw new Error(`${confPath}: productName is missing`); + const dir = dirname(confPath); + return { dir, confPath, cwd: dirname(dir), productName, identifier: String(conf.identifier ?? ''), version: typeof conf.version === 'string' ? conf.version : undefined, + category: typeof conf.bundle?.category === 'string' ? conf.bundle.category : undefined, minOs: typeof mac.minimumSystemVersion === 'string' ? mac.minimumSystemVersion : undefined, + entitlements: typeof mac.entitlements === 'string' ? join(dir, mac.entitlements) : undefined, conf }; +} + +/** Tauri's category names (and Apple's public.app-category.* ids) -> the LSApplicationCategoryType Tauri writes; null when unknown. */ +export function categoryId(c: string | undefined): string | null { + if (!c) return null; + if (c.startsWith('public.app-category.')) return c; + const map: Record = { Business: 'business', DeveloperTool: 'developer-tools', Education: 'education', Entertainment: 'entertainment', Finance: 'finance', Game: 'games', + GraphicsAndDesign: 'graphics-design', HealthcareAndFitness: 'healthcare-fitness', Lifestyle: 'lifestyle', Medical: 'medical', Music: 'music', News: 'news', Photography: 'photography', + Productivity: 'productivity', Reference: 'reference', SocialNetworking: 'social-networking', Sports: 'sports', Travel: 'travel', Utility: 'utilities', Video: 'video', Weather: 'weather' }; + return map[c] ? `public.app-category.${map[c]}` : null; +} + +/** Entitlements a Mac App Store build needs: the sandbox plus the application / team identifiers of the profile. */ +export const MAS_REQUIRED = ['com.apple.security.app-sandbox', 'com.apple.application-identifier', 'com.apple.developer.team-identifier'] as const; + +/** The entitlements file: the project's own keys kept, the required ones added (the project's values win for the optional ones). */ +export function masEntitlements(o: { teamId: string; bundleId: string; existing?: Record; network?: boolean }): Record { + return { + ...(o.network === false ? {} : { 'com.apple.security.network.client': true }), + ...(o.existing ?? {}), + 'com.apple.security.app-sandbox': true, + 'com.apple.application-identifier': `${o.teamId}.${o.bundleId}`, + 'com.apple.developer.team-identifier': o.teamId, + }; +} + +export const entitlementsXml = (e: Record): string => toXmlPlist(e); + +/** Keys of an entitlements plist (XML or what `codesign -d --entitlements :-` prints). */ +export function entitlementKeysOf(text: string): Record | null { + const start = text.indexOf('= 0 ? start : text.indexOf(' : null; } catch { return null; } +} + +export interface Identity { sha1: string; name: string } + +/** The signing identities for a Mac App Store build on this team: the app one ("Apple Distribution" or the older + * "3rd Party Mac Developer Application" / "Mac App Distribution") and the installer one ("3rd Party Mac Developer + * Installer" / "Mac Installer Distribution"). `all` is the parsed `security find-identity -v` list. */ +export function pickMasIdentities(all: Identity[], teamId?: string | null): { app: Identity | null; installer: Identity | null } { + const mine = all.filter(i => !teamId || i.name.includes(`(${teamId})`)); + const first = (...prefixes: string[]): Identity | null => { for (const p of prefixes) { const h = mine.find(i => i.name.startsWith(p)); if (h) return h; } return null; }; + return { app: first('3rd Party Mac Developer Application', 'Apple Distribution', 'Mac App Distribution'), installer: first('Mac Installer Distribution', '3rd Party Mac Developer Installer') }; +} + +export interface MasProfile { path: string; uuid: string; name: string; team: string; expires: string } + +/** Mac App Store profiles (.provisionprofile / .mobileprovision) among `files`: macOS platform, no device list, this + * application identifier, not expired; newest expiry first. `parse` reads one profile file. Pure. */ +export function pickMasProfiles(files: { path: string; profile: Record }[], bundleId: string, teamId: string | null, now = Date.now()): MasProfile[] { + const out: MasProfile[] = []; + for (const { path, profile: p } of files) { + const ent = (p.Entitlements ?? {}) as Record; + const team = String((p.TeamIdentifier as string[] | undefined)?.[0] ?? ''); + const appId = String(ent['com.apple.application-identifier'] ?? ent['application-identifier'] ?? ''); + const platforms = (p.Platform as string[] | undefined) ?? []; + if (platforms.length && !platforms.includes('OSX') && !platforms.includes('macOS')) continue; + if (p.ProvisionedDevices || p.ProvisionsAllDevices) continue; + if (appId !== `${team}.${bundleId}` || (teamId && team !== teamId)) continue; + if (Date.parse(String(p.ExpirationDate)) < now) continue; + if (!out.some(x => x.uuid === p.UUID)) out.push({ path, uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate) }); + } + return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires)); +} + +export interface PathFreeInput { home: string; user: string; root: string; cargoHome?: string; rustupHome?: string; rustflags?: string } + +/** Environment of a build that leaves no local path in the binary: rustc remaps the checkout, CARGO_HOME, RUSTUP_HOME + * and the home folder; no debug info; symbols stripped. Pure. */ +export function pathFreeEnv(i: PathFreeInput): Record { + const maps: [string, string][] = [ + [i.root, '/src'], [i.cargoHome ?? `${i.home}/.cargo`, '/cargo'], [i.rustupHome ?? `${i.home}/.rustup`, '/rustup'], [i.home, '/home/build'], + ]; + const seen = new Set(); + const flags = maps.filter(([from]) => from && !seen.has(from) && seen.add(from)).map(([from, to]) => `--remap-path-prefix=${from}=${to}`); + return { + RUSTFLAGS: [i.rustflags ?? '', ...flags].filter(Boolean).join(' '), + CARGO_PROFILE_RELEASE_DEBUG: '0', + CARGO_PROFILE_RELEASE_STRIP: 'symbols', + CARGO_PROFILE_RELEASE_TRIM_PATHS: 'all', + }; +} + +export interface Leak { file: string; needle: string; count: number } + +const MAX_SCAN = 400 * 1048576; + +/** Every file under `dir` that contains one of the needles (home folder, user name as /name/, checkout path). Reads each + * file whole (an .app is some tens of MB). */ +export function scanPathLeaks(dir: string, needles: string[]): Leak[] { + const ns = [...new Set(needles.filter(n => n.length >= 4))].map(n => ({ n, b: Buffer.from(n) })); + const out: Leak[] = []; + const walk = (d: string): void => { + let names: string[]; try { names = readdirSync(d); } catch { return; } + for (const name of names) { + const p = join(d, name); + let st; try { st = statSync(p); } catch { continue; } + if (st.isDirectory()) { walk(p); continue; } + if (!st.isFile() || st.size > MAX_SCAN) continue; + const buf = readFileSync(p); + for (const { n, b } of ns) { + let count = 0; for (let at = buf.indexOf(b); at >= 0; at = buf.indexOf(b, at + b.length)) count++; + if (count) out.push({ file: relative(dir, p).split(sep).join('/'), needle: n, count }); + } + } + }; + walk(dir); + return out; +} + +/** The needles for a leak scan: the home folder, /Users/ and the checkout. */ +export const leakNeedles = (home: string, user: string, root: string): string[] => [home, `/Users/${user}/`, `/home/${user}/`, root]; + +export interface AppFacts { info: Record; entitlements: Record | null; hasProfile: boolean; leaks: Leak[] } + +/** Problems (stop) and warnings of a built .app before it goes into a .pkg. Pure. */ +export function appProblems(f: AppFacts, o: { bundleId: string; version: string; build: string; signed: boolean; pathFree: boolean }): { errors: string[]; warnings: string[] } { + const errors: string[] = [], warnings: string[] = []; + const s = (k: string): string | undefined => (typeof f.info[k] === 'string' ? f.info[k] as string : undefined); + if (s('CFBundleIdentifier') !== o.bundleId) errors.push(`CFBundleIdentifier is ${s('CFBundleIdentifier') ?? 'missing'}, not ${o.bundleId}`); + if (s('CFBundleShortVersionString') !== o.version) errors.push(`CFBundleShortVersionString is ${s('CFBundleShortVersionString') ?? 'missing'}, not ${o.version}`); + if (s('CFBundleVersion') !== o.build) errors.push(`CFBundleVersion is ${s('CFBundleVersion') ?? 'missing'}, not ${o.build} (Tauri: bundle.macOS.bundleVersion)`); + if (!s('LSApplicationCategoryType')) errors.push('LSApplicationCategoryType is missing: set bundle.category in tauri.conf.json (e.g. "Utility", "Productivity")'); + if (!s('LSMinimumSystemVersion')) errors.push('LSMinimumSystemVersion is missing: set bundle.macOS.minimumSystemVersion in tauri.conf.json'); + if (f.info.ITSAppUsesNonExemptEncryption === undefined) warnings.push('ITSAppUsesNonExemptEncryption is not in Info.plist: the build will ask for export compliance in App Store Connect (storebound apple encryption answers it)'); + if (o.signed) { + if (!f.entitlements) errors.push('the app has no entitlements (codesign -d --entitlements): the Mac App Store requires the app sandbox'); + else { + for (const k of MAS_REQUIRED) if (f.entitlements[k] === undefined) errors.push(`entitlement ${k} is missing`); + if (f.entitlements['com.apple.security.app-sandbox'] !== true) errors.push('com.apple.security.app-sandbox is not true'); + if (f.entitlements['com.apple.security.get-task-allow'] === true) errors.push('com.apple.security.get-task-allow is true: a development entitlement, App Store Connect refuses it'); + } + if (!f.hasProfile) errors.push('Contents/embedded.provisionprofile is missing: a Mac App Store app needs its provisioning profile inside (Tauri bundle.macOS.files)'); + } + if (f.leaks.length) { + const lines = f.leaks.slice(0, 8).map(l => ` ${l.file}: ${l.count}x "${l.needle}"`); + (o.pathFree ? errors : warnings).push(`local paths in the app:\n${lines.join('\n')}${f.leaks.length > 8 ? `\n ... ${f.leaks.length - 8} more` : ''}${o.pathFree ? '' : '\n (build with --path-free to remap them)'}`); + } + return { errors, warnings }; +} + +export interface PkgSignature { signed: boolean; trusted: boolean; certificate: string | null; installerCert: boolean } + +/** `pkgutil --check-signature` output -> what matters. Pure. */ +export function parsePkgSignature(out: string): PkgSignature { + if (/no signature/i.test(out)) return { signed: false, trusted: false, certificate: null, installerCert: false }; + const cert = /^\s*1\.\s+(.+)$/m.exec(out)?.[1]?.trim() ?? null; + return { signed: /Status:\s+signed/i.test(out), trusted: /trusted (certificate|timestamp)|signed by a certificate trusted/i.test(out), certificate: cert, + installerCert: !!cert && /(3rd Party Mac Developer Installer|Mac Installer Distribution)/.test(cert) }; +} + +export function pkgProblems(sig: PkgSignature): string[] { + if (!sig.signed) return ['the .pkg is not signed (pkgutil --check-signature): productbuild --sign ""']; + const e: string[] = []; + if (!sig.installerCert) e.push(`the .pkg is signed with "${sig.certificate ?? '?'}", not a "3rd Party Mac Developer Installer" / "Mac Installer Distribution" certificate: the Mac App Store refuses it`); + return e; +} + +/** bundle id, version and build from a component package's PackageInfo (`pkgutil --expand` writes it). Pure. */ +export function parsePackageInfo(xml: string): { bundleId: string; version: string; build: string } | null { + const tag = /]*>/.exec(xml)?.[0]; + if (!tag) return null; + const attr = (n: string): string | undefined => new RegExp(`\\b${n}="([^"]*)"`).exec(tag)?.[1]; + const bundleId = attr('id'), version = attr('CFBundleShortVersionString'), build = attr('CFBundleVersion'); + return bundleId && version && build ? { bundleId, version, build } : null; +} + +export interface MasPlanInput { + tauri: TauriProject; tauriCmd: string[]; target: string; out: string; configFile: string; entitlementsFile: string; profile: string | null; + appIdentity: string | null; installerIdentity: string | null; version: string; build: string; unsigned: boolean; pathFree: boolean; pkg: string; +} + +/** The commands of one build, as shown by --dry and run by build-mas. Pure. */ +export function masSteps(i: MasPlanInput): { tauri: string[]; productbuild: string[]; codesignVerify: string[]; pkgCheck: string[] } { + const appPath = join(i.tauri.cwd, 'target', i.target, 'release', 'bundle', 'macos', `${i.tauri.productName}.app`); + return { + tauri: [...i.tauriCmd, 'build', '--bundles', 'app', '--target', i.target, '--config', i.configFile], + codesignVerify: ['codesign', '--verify', '--deep', '--strict', '--verbose=2', appPath], + productbuild: ['productbuild', '--component', appPath, '/Applications', ...(i.unsigned || !i.installerIdentity ? [] : ['--sign', i.installerIdentity]), i.pkg], + pkgCheck: ['pkgutil', '--check-signature', i.pkg], + }; +} + +/** The tauri.conf.json overlay for the Mac App Store: identity, entitlements, profile, version, build. */ +export function tauriOverlay(i: Pick & { minOs?: string; category?: string }): Record { + return { + version: i.version, + bundle: { + ...(i.category ? { category: i.category } : {}), + macOS: { + bundleVersion: i.build, + ...(i.minOs ? { minimumSystemVersion: i.minOs } : {}), + ...(i.unsigned ? {} : { signingIdentity: i.appIdentity, entitlements: i.entitlementsFile, hardenedRuntime: true, ...(i.profile ? { files: { 'embedded.provisionprofile': i.profile } } : {}) }), + }, + }, + }; +} + +export interface AppInfo { appDir: string; info: Record } + +/** The .app Tauri built: the first *.app in /target//release/bundle/macos (or the workspace's target). */ +export function findBuiltApp(cwd: string, root: string, target: string, productName: string): string | null { + for (const base of [join(cwd, 'target'), join(root, 'target'), join(dirname(cwd), 'target')]) { + const d = join(base, target, 'release', 'bundle', 'macos'); + const p = join(d, `${productName}.app`); + if (existsSync(p)) return p; + try { const any = readdirSync(d).find(f => f.endsWith('.app')); if (any) return join(d, any); } catch { /* none */ } + } + return null; +} + +export function readAppInfo(appDir: string): Record { + const p = join(appDir, 'Contents', 'Info.plist'); + if (!existsSync(p)) throw new Error(`${basename(appDir)}: no Contents/Info.plist`); + return parsePlist(readFileSync(p)) as Record; +} + +/** bundle id, version and build of a .pkg: pkgutil --expand on a Mac (PackageInfo), else the .json next to it that + * `apple build --platform macos` wrote. Refuses an unsigned or wrongly signed .pkg when pkgutil is there. */ +export function pkgInfo(pkg: string, expectBundleId?: string): { bundleId: string; version: string; build: string } { + if (rt.which('pkgutil')) { + const sig = parsePkgSignature(rt.spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe' }).stdout.toString()); + const bad = pkgProblems(sig); + if (bad.length) throw new Error(`${basename(pkg)}: not uploaded:\n${bad.map(x => ` ${x}`).join('\n')}`); + const tmp = join(process.env.TMPDIR ?? '/tmp', `sb-pkg-${process.pid}-${Date.now()}`); + try { + if (rt.spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe' }).exitCode === 0) { + for (const d of readdirSync(tmp)) { + const p = join(tmp, d, 'PackageInfo'); + const info = existsSync(p) ? parsePackageInfo(readFileSync(p, 'utf8')) : null; + if (info && (!expectBundleId || info.bundleId === expectBundleId)) return info; + } + } + } finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* temp */ } } + } + const side = `${pkg}.json`; + if (existsSync(side)) { + const j = JSON.parse(readFileSync(side, 'utf8')) as { bundleId?: string; version?: string; build?: string; unsigned?: boolean }; + if (j.unsigned) throw new Error(`${basename(pkg)} was built with --unsigned: the Mac App Store refuses it`); + if (j.bundleId && j.version && j.build) return { bundleId: j.bundleId, version: j.version, build: j.build }; + } + throw new Error(`${basename(pkg)}: cannot read bundle id / version / build (needs pkgutil on a Mac, or the ${basename(side)} file apple build wrote)`); +} diff --git a/cli/lib/signing.ts b/cli/lib/signing.ts index 8abefab..70019c9 100644 --- a/cli/lib/signing.ts +++ b/cli/lib/signing.ts @@ -20,7 +20,7 @@ export type CapabilityType = typeof CAPABILITY_TYPES[number]; /** Certificate types storebound creates: Apple Distribution (DISTRIBUTION, all platforms), the older iOS Distribution, * and Developer ID Application (Mac apps outside the App Store; Account Holder only). */ -export const CERT_TYPES = ['DISTRIBUTION', 'IOS_DISTRIBUTION', 'DEVELOPER_ID_APPLICATION', 'DEVELOPER_ID_APPLICATION_G2'] as const; +export const CERT_TYPES = ['DISTRIBUTION', 'IOS_DISTRIBUTION', 'DEVELOPER_ID_APPLICATION', 'DEVELOPER_ID_APPLICATION_G2', 'MAC_APP_DISTRIBUTION', 'MAC_INSTALLER_DISTRIBUTION'] as const; export type CertType = typeof CERT_TYPES[number]; /** Profile types for store builds (Profile.profileType). */ @@ -105,7 +105,7 @@ export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: Ap } /** Apple caps distribution certificates per team (Apple Distribution: 3 per team). */ -export const CERT_LIMIT: Partial> = { DISTRIBUTION: 3, IOS_DISTRIBUTION: 3, DEVELOPER_ID_APPLICATION: 5, DEVELOPER_ID_APPLICATION_G2: 5 }; +export const CERT_LIMIT: Partial> = { DISTRIBUTION: 3, IOS_DISTRIBUTION: 3, DEVELOPER_ID_APPLICATION: 5, DEVELOPER_ID_APPLICATION_G2: 5, MAC_APP_DISTRIBUTION: 3, MAC_INSTALLER_DISTRIBUTION: 3 }; export function certificateReq(type: CertType, csr: string): Req { return { method: 'POST', path: '/v1/certificates', note: `create ${type} certificate (key in .storebound/signing, never printed)`, diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index 6d01360..fe0dd52 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -187,13 +187,13 @@ export function ipaVersionProblem(ipaVersion: string, wanted: string | null | un export const UPLOAD_DONE = new Set(['COMPLETE', 'FAILED']); /** Requests of an App Store Connect API build upload (Build Uploads, API 4.1): the upload, then the file reservation. */ -export function buildUploadReqs(appId: string, info: IpaInfo, fileName: string, fileSize: number) { +export function buildUploadReqs(appId: string, info: IpaInfo, fileName: string, fileSize: number, platform: 'IOS' | 'MAC_OS' = 'IOS') { return { upload: { method: 'POST' as const, path: '/v1/buildUploads', note: `build ${info.build} (${info.version})`, - body: { data: { type: 'buildUploads', attributes: { cfBundleShortVersionString: info.version, cfBundleVersion: info.build, platform: 'IOS' }, + body: { data: { type: 'buildUploads', attributes: { cfBundleShortVersionString: info.version, cfBundleVersion: info.build, platform }, relationships: { app: { data: { type: 'apps', id: appId } } } } } }, file: (uploadId: string) => ({ method: 'POST' as const, path: '/v1/buildUploadFiles', note: `${fileName} (${(fileSize / 1048576).toFixed(1)} MB)`, - body: { data: { type: 'buildUploadFiles', attributes: { assetType: 'ASSET', fileName, fileSize, uti: 'com.apple.ipa' }, + body: { data: { type: 'buildUploadFiles', attributes: { assetType: 'ASSET', fileName, fileSize, uti: platform === 'MAC_OS' ? 'com.apple.pkg' : 'com.apple.ipa' }, relationships: { buildUpload: { data: { type: 'buildUploads', id: uploadId } } } } } }), commit: (fileId: string, md5: string) => ({ method: 'PATCH' as const, path: `/v1/buildUploadFiles/${fileId}`, note: 'commit', body: { data: { type: 'buildUploadFiles', id: fileId, attributes: { uploaded: true, sourceFileChecksums: { file: { hash: md5, algorithm: 'MD5' } } } } } }), @@ -202,7 +202,7 @@ export function buildUploadReqs(appId: string, info: IpaInfo, fileName: string, /** altool upload (Xcode's command line uploader) with the API key. */ export const altoolArgs = (ipa: string, auth: AscAuth): string[] => - ['xcrun', 'altool', '--upload-package', ipa, '--api-key', auth.keyId, '--api-issuer', auth.issuerId, '--p8-file-path', auth.keyPath, '--output-format', 'json']; + ['xcrun', 'altool', '--upload-package', ipa, ...(ipa.endsWith('.pkg') ? ['--type', 'macos'] : []), '--api-key', auth.keyId, '--api-issuer', auth.issuerId, '--p8-file-path', auth.keyPath, '--output-format', 'json']; /** Errors and warnings of a build upload's state (StateDetail: code, message). */ export function uploadProblems(state: { errors?: { code?: string; message?: string; description?: string }[]; warnings?: { code?: string; message?: string; description?: string }[] } | undefined): { errors: string[]; warnings: string[] } { diff --git a/tests/mas-build.test.ts b/tests/mas-build.test.ts new file mode 100644 index 0000000..b689b02 --- /dev/null +++ b/tests/mas-build.test.ts @@ -0,0 +1,194 @@ +// Mac App Store build of a Tauri app (cli/lib/mas.ts, cli/apple/build-mas.ts): plan, entitlements, identities, path-free +// flags, leak scan, app / pkg checks, and the pkg variant of the build upload requests. +import { afterAll, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { tauriCommand } from '../cli/apple/build-mas'; +import { appProblems, categoryId, entitlementKeysOf, entitlementsXml, findTauri, masEntitlements, masSteps, parsePackageInfo, parsePkgSignature, pathFreeEnv, pickMasIdentities, pickMasProfiles, pkgProblems, scanPathLeaks, tauriOverlay } from '../cli/lib/mas'; +import { altoolArgs, buildUploadReqs } from '../cli/lib/xcode'; + +const tmp = mkdtempSync(join(tmpdir(), 'sb-mas-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const conf = { productName: 'Demo App', identifier: 'com.example.demo', version: '1.2.3', bundle: { category: 'Utility', macOS: { minimumSystemVersion: '12.0' } } }; +function fixture(): string { + const root = mkdtempSync(join(tmp, 'p-')); + mkdirSync(join(root, 'apps/desktop/src-tauri'), { recursive: true }); + writeFileSync(join(root, 'apps/desktop/src-tauri/tauri.conf.json'), JSON.stringify(conf)); + return root; +} + +describe('tauri project', () => { + test('found by scan and by hint; fields read', () => { + const root = fixture(); + const t = findTauri(root); + expect(t.productName).toBe('Demo App'); + expect(t.identifier).toBe('com.example.demo'); + expect(t.cwd).toBe(join(root, 'apps/desktop')); + expect(t.minOs).toBe('12.0'); + expect(findTauri(root, 'apps/desktop/src-tauri').dir).toBe(t.dir); + expect(() => findTauri(root, 'nope')).toThrow('no tauri.conf.json'); + }); + test('none or several found', () => { + const empty = mkdtempSync(join(tmp, 'e-')); + expect(() => findTauri(empty)).toThrow('no tauri.conf.json found'); + const two = fixture(); + mkdirSync(join(two, 'other/src-tauri'), { recursive: true }); + writeFileSync(join(two, 'other/src-tauri/tauri.conf.json'), JSON.stringify(conf)); + expect(() => findTauri(two)).toThrow('several Tauri projects'); + }); + test('tauri command: project binary, cargo-tauri, else bunx', () => { + expect(tauriCommand('/a/b', p => /a.node_modules..bin.tauri$/.test(p), () => null)).toHaveLength(1); + expect(tauriCommand('/a/b', () => false, c => (c === 'cargo-tauri' ? '/x' : null))).toEqual(['cargo', 'tauri']); + expect(tauriCommand('/a/b', () => false, () => null)[0]).toBe('bunx'); + }); + test('category ids', () => { + expect(categoryId('Utility')).toBe('public.app-category.utilities'); + expect(categoryId('public.app-category.games')).toBe('public.app-category.games'); + expect(categoryId('Nope')).toBeNull(); + }); +}); + +describe('entitlements', () => { + test('sandbox and identifiers forced, project keys kept', () => { + const e = masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo', existing: { 'com.apple.security.app-sandbox': false, 'com.apple.security.files.user-selected.read-write': true } }); + expect(e['com.apple.security.app-sandbox']).toBe(true); + expect(e['com.apple.application-identifier']).toBe('ABCDE12345.com.example.demo'); + expect(e['com.apple.developer.team-identifier']).toBe('ABCDE12345'); + expect(e['com.apple.security.files.user-selected.read-write']).toBe(true); + expect(e['com.apple.security.network.client']).toBe(true); + }); + test('xml round trip', () => { + const e = masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo' }); + expect(entitlementKeysOf(entitlementsXml(e))?.['com.apple.security.app-sandbox']).toBe(true); + expect(entitlementKeysOf('garbage')).toBeNull(); + }); +}); + +describe('identities and profiles', () => { + const ids = [ + { sha1: 'A', name: 'Apple Development: X (AAAAAAAAAA)' }, + { sha1: 'B', name: 'Apple Distribution: Co (ABCDE12345)' }, + { sha1: 'C', name: '3rd Party Mac Developer Installer: Co (ABCDE12345)' }, + { sha1: 'D', name: 'Apple Distribution: Other (ZZZZZZZZZZ)' }, + ]; + test('app and installer identity of the team', () => { + const p = pickMasIdentities(ids, 'ABCDE12345'); + expect(p.app?.sha1).toBe('B'); + expect(p.installer?.sha1).toBe('C'); + expect(pickMasIdentities(ids, 'QQQQQQQQQQ')).toEqual({ app: null, installer: null }); + expect(pickMasIdentities([{ sha1: 'E', name: '3rd Party Mac Developer Application: Co (ABCDE12345)' }, ids[1]!], 'ABCDE12345').app?.sha1).toBe('E'); + expect(pickMasIdentities([{ sha1: 'F', name: 'Mac Installer Distribution: Co (ABCDE12345)' }, ids[2]!], 'ABCDE12345').installer?.sha1).toBe('F'); + }); + const prof = (o: Record) => ({ path: '/p', profile: { UUID: 'u1', Name: 'n', TeamIdentifier: ['ABCDE12345'], Platform: ['OSX'], ExpirationDate: '2099-01-01T00:00:00Z', + Entitlements: { 'com.apple.application-identifier': 'ABCDE12345.com.example.demo' }, ...o } as never }); + test('Mac App Store profile: macOS, no devices, matching id, not expired', () => { + expect(pickMasProfiles([prof({})], 'com.example.demo', 'ABCDE12345')).toHaveLength(1); + expect(pickMasProfiles([prof({ Platform: ['iOS'] })], 'com.example.demo', null)).toHaveLength(0); + expect(pickMasProfiles([prof({ ProvisionedDevices: ['x'] })], 'com.example.demo', null)).toHaveLength(0); + expect(pickMasProfiles([prof({})], 'com.example.other', null)).toHaveLength(0); + expect(pickMasProfiles([prof({ ExpirationDate: '2000-01-01T00:00:00Z' })], 'com.example.demo', null)).toHaveLength(0); + }); +}); + +describe('path-free', () => { + test('remaps checkout, cargo, rustup, home; no debug info', () => { + const e = pathFreeEnv({ home: '/Users/dev', user: 'dev', root: '/Users/dev/work/app', rustflags: '-C target-cpu=native' }); + expect(e.RUSTFLAGS).toContain('-C target-cpu=native'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/work/app=/src'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.cargo=/cargo'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.rustup=/rustup'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev=/home/build'); + expect(e.CARGO_PROFILE_RELEASE_DEBUG).toBe('0'); + + }); + test('leak scan finds the home folder in a binary and a plist', () => { + const app = mkdtempSync(join(tmp, 'app-')); + mkdirSync(join(app, 'Contents/MacOS'), { recursive: true }); + writeFileSync(join(app, 'Contents/MacOS/bin'), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from('panic at /Users/dev/.cargo/registry/x.rs'), Buffer.from([0])])); + writeFileSync(join(app, 'Contents/Info.plist'), 'clean'); + const leaks = scanPathLeaks(app, ['/Users/dev', '/Users/dev/']); + expect(leaks.map(l => `${l.file}:${l.needle}`)).toEqual(['Contents/MacOS/bin:/Users/dev', 'Contents/MacOS/bin:/Users/dev/']); + expect(scanPathLeaks(app, ['/Users/nobody'])).toEqual([]); + }); +}); + +describe('app and pkg checks', () => { + const good = { info: { CFBundleIdentifier: 'com.example.demo', CFBundleShortVersionString: '1.2.3', CFBundleVersion: '7', LSApplicationCategoryType: 'public.app-category.utilities', LSMinimumSystemVersion: '12.0', ITSAppUsesNonExemptEncryption: false }, + entitlements: masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo' }), hasProfile: true, leaks: [] }; + const o = { bundleId: 'com.example.demo', version: '1.2.3', build: '7', signed: true, pathFree: true }; + test('a good app passes', () => { expect(appProblems(good, o)).toEqual({ errors: [], warnings: [] }); }); + test('missing sandbox, category, min OS, profile, wrong build', () => { + const bad = { ...good, info: { ...good.info, CFBundleVersion: '6', LSApplicationCategoryType: undefined, LSMinimumSystemVersion: undefined } as never, entitlements: { 'com.apple.security.app-sandbox': false }, hasProfile: false }; + const e = appProblems(bad, o).errors.join('\n'); + for (const s of ['CFBundleVersion is 6', 'LSApplicationCategoryType', 'LSMinimumSystemVersion', 'app-sandbox is not true', 'application-identifier', 'embedded.provisionprofile']) expect(e).toContain(s); + expect(appProblems({ ...good, entitlements: null }, o).errors[0]).toContain('no entitlements'); + }); + test('get-task-allow refused; unsigned skips entitlement checks', () => { + expect(appProblems({ ...good, entitlements: { ...good.entitlements!, 'com.apple.security.get-task-allow': true } }, o).errors.join()).toContain('get-task-allow'); + expect(appProblems({ ...good, entitlements: null, hasProfile: false }, { ...o, signed: false }).errors).toEqual([]); + }); + test('path leaks are errors with --path-free, warnings without', () => { + const leaks = [{ file: 'Contents/MacOS/bin', needle: '/Users/dev', count: 3 }]; + expect(appProblems({ ...good, leaks }, o).errors[0]).toContain('local paths'); + const w = appProblems({ ...good, leaks }, { ...o, pathFree: false }); + expect(w.errors).toEqual([]); + expect(w.warnings.join()).toContain('--path-free'); + }); + test('pkgutil --check-signature', () => { + const ok = parsePkgSignature('Package "a.pkg":\n Status: signed by a certificate trusted by macOS\n Certificate Chain:\n 1. 3rd Party Mac Developer Installer: Co (ABCDE12345)\n Expires: 2027'); + expect(ok).toMatchObject({ signed: true, installerCert: true, certificate: '3rd Party Mac Developer Installer: Co (ABCDE12345)' }); + expect(pkgProblems(ok)).toEqual([]); + expect(pkgProblems(parsePkgSignature('Package "a.pkg":\n Status: no signature'))[0]).toContain('not signed'); + const dev = parsePkgSignature(' Status: signed by a certificate trusted by macOS\n 1. Developer ID Installer: Co (ABCDE12345)'); + expect(pkgProblems(dev)[0]).toContain('not a "3rd Party Mac Developer Installer"'); + }); + test('PackageInfo', () => { + const x = ''; + expect(parsePackageInfo(x)).toEqual({ bundleId: 'com.example.demo', version: '1.2.3', build: '7' }); + expect(parsePackageInfo('')).toBeNull(); + }); +}); + +describe('plan', () => { + const t = findTauri(fixture()); + const base = { tauri: t, tauriCmd: ['cargo', 'tauri'], target: 'universal-apple-darwin', out: '/o', configFile: '/o/c.json', entitlementsFile: '/o/e', profile: '/o/p', appIdentity: 'Apple Distribution: Co (ABCDE12345)', + installerIdentity: '3rd Party Mac Developer Installer: Co (ABCDE12345)', version: '1.2.3', build: '7', unsigned: false, pathFree: true, pkg: '/o/Demo App.pkg' }; + test('signed steps: tauri app bundle, productbuild with the installer identity', () => { + const s = masSteps(base); + expect(s.tauri).toEqual(['cargo', 'tauri', 'build', '--bundles', 'app', '--target', 'universal-apple-darwin', '--config', '/o/c.json']); + expect(s.productbuild.slice(0, 2)).toEqual(['productbuild', '--component']); + expect(s.productbuild).toContain('--sign'); + expect(s.productbuild.at(-1)).toBe('/o/Demo App.pkg'); + expect(s.pkgCheck).toEqual(['pkgutil', '--check-signature', '/o/Demo App.pkg']); + }); + test('unsigned: no --sign', () => { expect(masSteps({ ...base, unsigned: true }).productbuild).not.toContain('--sign'); }); + test('tauri overlay: identity, entitlements, embedded profile, bundleVersion', () => { + const o = tauriOverlay({ ...base, minOs: '12.0' }) as { version: string; bundle: { macOS: Record } }; + expect(o.version).toBe('1.2.3'); + expect(o.bundle.macOS.bundleVersion).toBe('7'); + expect(o.bundle.macOS.signingIdentity).toBe(base.appIdentity); + expect(o.bundle.macOS.hardenedRuntime).toBe(true); + expect(o.bundle.macOS.files).toEqual({ 'embedded.provisionprofile': '/o/p' }); + const u = tauriOverlay({ ...base, unsigned: true }) as { bundle: { macOS: Record } }; + expect(u.bundle.macOS.signingIdentity).toBeUndefined(); + }); +}); + +describe('build upload of a pkg', () => { + const info = { bundleId: 'com.example.demo', version: '1.2.3', build: '7' }; + test('platform MAC_OS and the pkg uti; iOS unchanged', () => { + const m = buildUploadReqs('app1', info, 'Demo.pkg', 10, 'MAC_OS'); + expect((m.upload.body.data.attributes as Record).platform).toBe('MAC_OS'); + expect((m.file('u1').body.data.attributes as Record).uti).toBe('com.apple.pkg'); + const i = buildUploadReqs('app1', info, 'Demo.ipa', 10); + expect((i.upload.body.data.attributes as Record).platform).toBe('IOS'); + expect((i.file('u1').body.data.attributes as Record).uti).toBe('com.apple.ipa'); + }); + test('altool gets --type macos for a pkg', () => { + const k = { keyPath: 'k', keyId: 'i', issuerId: 's' }; + expect(altoolArgs('/o/Demo.pkg', k)).toContain('macos'); + expect(altoolArgs('/o/Demo.ipa', k)).not.toContain('--type'); + }); +}); From 5fc15dd77359fafd216d5f27e36f4eecc37c7627 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:11:14 +0800 Subject: [PATCH 010/126] apple build/upload: flags and help for the macOS Tauri path --- cli/apple/build.ts | 2 ++ cli/apple/upload.ts | 1 + cli/commands.ts | 4 ++-- cli/data/flags.json | 16 +++++++++++++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cli/apple/build.ts b/cli/apple/build.ts index b5df488..74f0056 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -1,4 +1,6 @@ // Archive and export an App Store IPA with xcodebuild, no Xcode UI. +// storebound apple build --platform macos --tauri [--path-free] [--unsigned] [--target T] [--profile F] [--version x.y.z] [--build-number N] [--out D] +// (Mac App Store build of a Tauri app: cli/apple/build-mas.ts) // storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P] // [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json] // First the prebuild (build.prebuild / apple.prebuild: e.g. the web build + npx cap sync; once per flow run), then a diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 03b19c6..114d989 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -1,5 +1,6 @@ // Upload an IPA to App Store Connect and wait until Apple has processed the build. // storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] +// storebound apple upload --platform macos [...] (Mac App Store .pkg from apple build --platform macos --tauri; platform MAC_OS, uti com.apple.pkg; altool gets --type macos; no SDK / privacy-manifest check) // Default IPA: the newest in /export (and its -/ folders), or with // --build N the one with that build number. The bundle id, version and build number come from the IPA's Info.plist. // An IPA whose build number is already on App Store Connect fails with exit 1 (a stale IPA left by a failed diff --git a/cli/commands.ts b/cli/commands.ts index e4df348..01665de 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -91,8 +91,8 @@ export const COMMANDS: Record Promise, string]> = { 'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'], 'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple signing': [() => import('./apple/signing'), 'status | ensure: bundle id capabilities, distribution certificate (key kept in .storebound/signing), App Store profiles installed for Xcode; --dry, asks first'], - 'apple build': [() => import('./apple/build'), 'xcodebuild archive + exportArchive (ExportOptions.plist generated, next build number, API key signing); Capacitor, React Native, Flutter, Unity; --dry'], - 'apple upload': [() => import('./apple/upload'), 'upload an IPA (App Store Connect API build upload, or --via altool), build number checked, waits for processing; --dry, asks first'], + 'apple build': [() => import('./apple/build'), 'xcodebuild archive + exportArchive (ExportOptions.plist generated, next build number, API key signing); Capacitor, React Native, Flutter, Unity; --platform macos --tauri [--path-free] builds a Tauri app for the Mac App Store (signed .app, sandbox entitlements, profile, productbuild .pkg); --dry'], + 'apple upload': [() => import('./apple/upload'), 'upload an IPA, or with --platform macos a signed .pkg (App Store Connect API build upload, or --via altool), build number checked, waits for processing; --dry, asks first'], 'apple adhoc': [() => import('./apple/adhoc'), 'ad-hoc / release-testing: devices (list) | register (dry run, asks first) | options (ExportOptions.plist) | export --archive '], 'apple next-build': [() => import('./apple/next-build'), 'next safe build number (prints the number)'], 'apple game-center': [() => import('./apple/game-center'), '[status] Game Center from games: the app\'s Game Center detail, the version switch, leaderboards + achievements with localizations (upsert by vendor id); App ID capability via signing ensure; --dry, asks first'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 9707cf8..db3f259 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -77,10 +77,17 @@ "no-install", "no-prebuild", "out", + "path-free", + "platform", + "profile", "progress", "project", "result-file", + "scan-paths", "scheme", + "target", + "tauri", + "unsigned", "version", "version-from", "workspace" @@ -338,6 +345,7 @@ "locale", "no-wait", "older-than", + "platform", "pre-check", "progress", "remove", @@ -1259,6 +1267,7 @@ "originality", "out", "param", + "path-free", "paths", "phased", "platform", @@ -1289,6 +1298,7 @@ "resume", "review-sent", "root", + "scan-paths", "scenes", "scheme", "sdk-dirs", @@ -1313,6 +1323,7 @@ "storefronts", "submit-review", "target", + "tauri", "term", "territories", "text", @@ -1329,6 +1340,7 @@ "unanswered", "undo", "unity", + "unsigned", "update-bigmac", "update-desktop-database", "upload", @@ -1398,6 +1410,7 @@ "storebound apple appinfo [--dry] [--json] [--allow-price-changes] [--allow-availability-changes]" ], "apple build": [ + "storebound apple build --platform macos --tauri [--path-free] [--unsigned] [--target T] [--profile F] [--version x.y.z] [--build-number N] [--out D]", "storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]" ], "apple bundle": [ @@ -1504,7 +1517,8 @@ "storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json]" ], "apple upload": [ - "storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]" + "storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]", + "storebound apple upload --platform macos [...] (Mac App Store .pkg from apple build --platform macos --tauri; platform MAC_OS, uti com.apple.pkg; altool gets --type macos; no SDK / privacy-manifest check)" ], "apple win-back": [ "storebound apple win-back [--only sub1,sub2] [--dry] [--yes] [--json]" From 0263f624439e4317d1f098d3aba510d3f7859333 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:11:56 +0800 Subject: [PATCH 011/126] mas build: cargo target folder is src-tauri/target --- cli/apple/build-mas.ts | 4 ++-- cli/lib/mas.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/apple/build-mas.ts b/cli/apple/build-mas.ts index d1bb17c..82beef3 100644 --- a/cli/apple/build-mas.ts +++ b/cli/apple/build-mas.ts @@ -164,8 +164,8 @@ export async function run(ctx: Ctx): Promise { writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`); shipPhase('build', `tauri build ${target}, build ${buildNo}`); await runLogged(steps.tauri, tauri.cwd, join(out, 'tauri-build.log'), showCmd(steps.tauri, null), env); - const appDir = findBuiltApp(tauri.cwd, root, target, tauri.productName); - if (!appDir) throw new Error(`tauri build exited 0 but no ${tauri.productName}.app is under ${join(tauri.cwd, 'target', target, 'release/bundle/macos')} (see ${join(out, 'tauri-build.log')})`); + const appDir = findBuiltApp(tauri.dir, root, target, tauri.productName); + if (!appDir) throw new Error(`tauri build exited 0 but no ${tauri.productName}.app is under ${join(tauri.dir, 'target', target, 'release/bundle/macos')} (see ${join(out, 'tauri-build.log')})`); shipPhase('verify', `checking ${basenameOf(appDir)}`); if (!unsigned) { const v = run1(steps.codesignVerify.map(x => (x.endsWith('.app') ? appDir : x))); diff --git a/cli/lib/mas.ts b/cli/lib/mas.ts index cc5d15b..c870b5d 100644 --- a/cli/lib/mas.ts +++ b/cli/lib/mas.ts @@ -228,7 +228,7 @@ export interface MasPlanInput { /** The commands of one build, as shown by --dry and run by build-mas. Pure. */ export function masSteps(i: MasPlanInput): { tauri: string[]; productbuild: string[]; codesignVerify: string[]; pkgCheck: string[] } { - const appPath = join(i.tauri.cwd, 'target', i.target, 'release', 'bundle', 'macos', `${i.tauri.productName}.app`); + const appPath = join(i.tauri.dir, 'target', i.target, 'release', 'bundle', 'macos', `${i.tauri.productName}.app`); return { tauri: [...i.tauriCmd, 'build', '--bundles', 'app', '--target', i.target, '--config', i.configFile], codesignVerify: ['codesign', '--verify', '--deep', '--strict', '--verbose=2', appPath], From f588f4e1aa549cf0c505fe07b365919b4808d049 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:12:37 +0800 Subject: [PATCH 012/126] path-free: no cargo trim-paths (nightly only) --- cli/lib/mas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/lib/mas.ts b/cli/lib/mas.ts index c870b5d..a5365bb 100644 --- a/cli/lib/mas.ts +++ b/cli/lib/mas.ts @@ -132,8 +132,8 @@ export function pathFreeEnv(i: PathFreeInput): Record { return { RUSTFLAGS: [i.rustflags ?? '', ...flags].filter(Boolean).join(' '), CARGO_PROFILE_RELEASE_DEBUG: '0', + // no CARGO_PROFILE_RELEASE_TRIM_PATHS: still nightly-only in cargo 1.98 ("feature trim-paths is required"); the remaps do the job CARGO_PROFILE_RELEASE_STRIP: 'symbols', - CARGO_PROFILE_RELEASE_TRIM_PATHS: 'all', }; } From 33a7f73ab97e843841b3a2b27b0364fecf117938 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:16:19 +0800 Subject: [PATCH 013/126] docs: build-and-sign Mac App Store Tauri section, pitfall 114, changelog --- CHANGELOG.md | 2 ++ skills/build-and-sign/SKILL.md | 26 ++++++++++++++++++++++++++ skills/store-pitfalls/SKILL.md | 26 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f174cf..703ba22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate ""`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language. - `apple game-center --locale-map en=en-GB` (config `games.localeMap`) maps a config language to the live Game Center locale; the dry run suggests it when the live Game Center has exactly one variant of the language. - `play games`: a 403 SERVICE_DISABLED / accessNotConfigured names the Google Play Game Services Publishing API, the enable URL for the project and `console plan play-games-services`. + +- `apple build --platform macos --tauri [--path-free] [--unsigned]`: Mac App Store build of a Tauri app. Generated sandbox entitlements, Mac App Store profile (installed, `--profile` or from the API), Apple Distribution / Mac Installer Distribution identities, `tauri build --bundles app`, checks of the .app, `productbuild` to a signed .pkg, `pkgutil --check-signature`; `--path-free` remaps local paths in rustc and fails on any leak of the home folder or user name. `apple upload --platform macos` uploads it (platform MAC_OS). New certificate types MAC_APP_DISTRIBUTION and MAC_INSTALLER_DISTRIBUTION. Pitfall 119. - Desktop dev update channel: Settings > Updates offers Stable (the npm releases) and Dev (every push to main). Each push to main builds Windows and Linux bundles stamped `-dev.` (above the current stable, below the next one); the VDS signs them and serves `storebound.dev/desktop/dev/latest.json` (newest 5 kept). The app checks its channel at start, every 15 minutes, after sleep and right after a channel switch, installs a strictly newer build quietly (deferred while the window is in use), never downgrades (dev back to stable waits for a newer stable), rewrites its logon autostart on every start, and the tray card shows version and channel. No macOS dev builds (Developer ID signing needs the Mac's GUI session). - `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `///.png` (feature: `/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`. - `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID. diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index 274ab8e..e54b8e9 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -85,6 +85,32 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ 6. **Next.** `storebound apple encryption --build N`, `storebound apple testflight distribute --build N --group Team --dry` (`testflight` skill), `storebound apple listing --version x.y.z --build N` (`appstore-first-release`). +## Mac App Store: a Tauri app (.pkg) + +`storebound apple build --platform macos --tauri apps/desktop/src-tauri --path-free --dry` prints the plan; without +`--dry` it runs in the Mac's logged-in GUI session (not over SSH: write a .command file and `open -a Terminal` it): + +1. Version from `--version` or tauri.conf.json, build number from `--build-number` or the next free one on App Store + Connect for macOS (CFBundleVersion must increase). `tauri.conf.json` `identifier` must equal `apple.bundleId`. +2. Identities from the login keychain: "Apple Distribution" (or "3rd Party Mac Developer Application") and "Mac + Installer Distribution" (or "3rd Party Mac Developer Installer"). The installer one is its own certificate type + (`apple.signing.certificateType: 'MAC_INSTALLER_DISTRIBUTION'`, `apple signing ensure`). +3. Provisioning profile: `--profile`, else an installed one, else the active `MAC_APP_STORE` profile from the API + (`apple.signing.profileType: 'MAC_APP_STORE'`). +4. `tauri build --bundles app --target universal-apple-darwin --config `: the overlay sets the signing identity, + the generated entitlements (app sandbox, application / team identifier, your own keys kept), the embedded profile, + hardened runtime and `bundleVersion`. Set `bundle.category` and `bundle.macOS.minimumSystemVersion` in + tauri.conf.json (the build stops without LSApplicationCategoryType / LSMinimumSystemVersion). +5. Checks of the .app (sandbox, no get-task-allow, profile inside, version / build), `codesign --verify`, then + `productbuild --component /Applications --sign ` and `pkgutil --check-signature`. +6. `--path-free`: rustc remaps the checkout, CARGO_HOME, RUSTUP_HOME and the home folder, no debug info, symbols stripped; + the build fails when the home folder or user name is still in the app. `--unsigned` runs the pipeline without + identities (not uploadable). Result: `/export/-/.pkg` (`--out`, default `build/macos`). + +Then `storebound apple upload --platform macos --dry` and without `--dry` (App Store Connect build upload, +platform MAC_OS, or `--via altool`). The .pkg is checked first (installer signature); the build shows up in the macOS +version's build list once processed. Details and fixes: `store-pitfalls` 119. + ## Android 1. **Upload key.** Generate once (`keytool -genkeypair -v -storetype PKCS12 -keystore upload.p12 -alias upload diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 70aa528..1e06369 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1415,3 +1415,29 @@ that fails without the fix (see CONTRIBUTING.md). `storebound shots copy reviewed --lang de`. Captions over the limit (eyebrow 30, title line 28, sub 90, badge 18) are not kept. - **Guard:** `cli/shots/copy.ts`, `cli/lib/shots-copy-translate.ts`, `tests/shots-copy-translate.test.ts`. + +## 119. A Tauri Mac App Store build was refused (no sandbox, no profile, wrong installer certificate) or carried the builder's home folder + +- **What happens:** a plain `tauri build` makes a Developer ID app: no `com.apple.security.app-sandbox`, no + `embedded.provisionprofile`, signed with the wrong certificate, packed as a .dmg. Packed by hand into a .pkg it fails at + upload with ITMS-90xxx (sandbox, missing profile, CFBundleVersion not above the last one, no LSApplicationCategoryType) or + the .pkg is signed with an application certificate. Separately, rustc panic messages and debug info carry the + checkout, `~/.cargo` and the user name (`/Users//...`, hundreds of times in a release binary), which ends up + in a public download. +- **Do:** `storebound apple build --platform macos --tauri --path-free --dry`, then the same without `--dry` + in the Mac's logged-in GUI session (codesign over SSH fails with errSecInternalComponent). It needs, on the Mac: an + "Apple Distribution" (or "3rd Party Mac Developer Application") and a "Mac Installer Distribution" (or "3rd Party Mac + Developer Installer") identity in the login keychain, and an active MAC_APP_STORE profile + (`apple.signing.profileType: 'MAC_APP_STORE'`, `apple signing ensure`, or `--profile `). The installer certificate + is a separate certificate type (`MAC_INSTALLER_DISTRIBUTION`). `--unsigned` checks the whole pipeline without them + (never uploadable). A universal build needs both Rust targets (rustup); with Homebrew Rust only the host + target exists: pass `--target aarch64-apple-darwin`. +- **Guard:** `cli/lib/mas.ts` / `cli/apple/build-mas.ts`: entitlements generated with sandbox and application / team + identifier (the project's own keys kept), profile embedded through a tauri.conf overlay, bundleVersion from App Store + Connect (platform MAC_OS) so it increases, the built app checked (sandbox entitlement, no get-task-allow, category, + minimum OS, profile inside, version / build), `--path-free` remaps checkout / CARGO_HOME / RUSTUP_HOME / home in rustc, + drops debug info and symbols and fails when the home folder or user name is still in the app (without the flag the + same scan warns). `cargo trim-paths` is not used: it is nightly-only (cargo 1.98 refuses it). `apple upload + --platform macos` refuses an unsigned .pkg or one not signed with an installer certificate before any network call + (`tests/mas-build.test.ts`). The `com.apple.pkg` file type of the API build upload is taken from Apple's schema and has not + been exercised against the live API yet; `--via altool` (`--type macos`) is the fallback. From 634d5dd71dcfa7ecb605e6ccc71522d521cbbc1d Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:18:12 +0800 Subject: [PATCH 014/126] generated API tables refreshed for the macOS build upload --- cli/api/wrapped.gen.ts | 4 ++-- docs/api-coverage.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index 1b484d8..d361d9a 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -131,7 +131,7 @@ export const WRAPPED: { apple: Record; play: Record; play: Record Date: Sat, 24 Oct 2026 12:19:27 +0800 Subject: [PATCH 015/126] mas: placeholders the scrub accepts --- cli/lib/mas.ts | 2 +- tests/mas-build.test.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cli/lib/mas.ts b/cli/lib/mas.ts index a5365bb..0a9d650 100644 --- a/cli/lib/mas.ts +++ b/cli/lib/mas.ts @@ -125,7 +125,7 @@ export interface PathFreeInput { home: string; user: string; root: string; cargo * and the home folder; no debug info; symbols stripped. Pure. */ export function pathFreeEnv(i: PathFreeInput): Record { const maps: [string, string][] = [ - [i.root, '/src'], [i.cargoHome ?? `${i.home}/.cargo`, '/cargo'], [i.rustupHome ?? `${i.home}/.rustup`, '/rustup'], [i.home, '/home/build'], + [i.root, '/src'], [i.cargoHome ?? `${i.home}/.cargo`, '/cargo'], [i.rustupHome ?? `${i.home}/.rustup`, '/rustup'], [i.home, '/build-home'], ]; const seen = new Set(); const flags = maps.filter(([from]) => from && !seen.has(from) && seen.add(from)).map(([from, to]) => `--remap-path-prefix=${from}=${to}`); diff --git a/tests/mas-build.test.ts b/tests/mas-build.test.ts index b689b02..1fd738c 100644 --- a/tests/mas-build.test.ts +++ b/tests/mas-build.test.ts @@ -94,23 +94,23 @@ describe('identities and profiles', () => { describe('path-free', () => { test('remaps checkout, cargo, rustup, home; no debug info', () => { - const e = pathFreeEnv({ home: '/Users/dev', user: 'dev', root: '/Users/dev/work/app', rustflags: '-C target-cpu=native' }); + const e = pathFreeEnv({ home: '/Users/you', user: 'you', root: '/Users/you/work/app', rustflags: '-C target-cpu=native' }); expect(e.RUSTFLAGS).toContain('-C target-cpu=native'); - expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/work/app=/src'); - expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.cargo=/cargo'); - expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.rustup=/rustup'); - expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev=/home/build'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/you/work/app=/src'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/you/.cargo=/cargo'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/you/.rustup=/rustup'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/you=/build-home'); expect(e.CARGO_PROFILE_RELEASE_DEBUG).toBe('0'); }); test('leak scan finds the home folder in a binary and a plist', () => { const app = mkdtempSync(join(tmp, 'app-')); mkdirSync(join(app, 'Contents/MacOS'), { recursive: true }); - writeFileSync(join(app, 'Contents/MacOS/bin'), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from('panic at /Users/dev/.cargo/registry/x.rs'), Buffer.from([0])])); + writeFileSync(join(app, 'Contents/MacOS/bin'), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from('panic at /Users/you/.cargo/registry/x.rs'), Buffer.from([0])])); writeFileSync(join(app, 'Contents/Info.plist'), 'clean'); - const leaks = scanPathLeaks(app, ['/Users/dev', '/Users/dev/']); - expect(leaks.map(l => `${l.file}:${l.needle}`)).toEqual(['Contents/MacOS/bin:/Users/dev', 'Contents/MacOS/bin:/Users/dev/']); - expect(scanPathLeaks(app, ['/Users/nobody'])).toEqual([]); + const leaks = scanPathLeaks(app, ['/Users/you', '/Users/you/']); + expect(leaks.map(l => `${l.file}:${l.needle}`)).toEqual(['Contents/MacOS/bin:/Users/you', 'Contents/MacOS/bin:/Users/you/']); + expect(scanPathLeaks(app, ['/Users/me'])).toEqual([]); }); }); @@ -130,7 +130,7 @@ describe('app and pkg checks', () => { expect(appProblems({ ...good, entitlements: null, hasProfile: false }, { ...o, signed: false }).errors).toEqual([]); }); test('path leaks are errors with --path-free, warnings without', () => { - const leaks = [{ file: 'Contents/MacOS/bin', needle: '/Users/dev', count: 3 }]; + const leaks = [{ file: 'Contents/MacOS/bin', needle: '/Users/you', count: 3 }]; expect(appProblems({ ...good, leaks }, o).errors[0]).toContain('local paths'); const w = appProblems({ ...good, leaks }, { ...o, pathFree: false }); expect(w.errors).toEqual([]); From 61b5fa062370187c6ede41c6f07f4e77e1fafc29 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:00:48 +0800 Subject: [PATCH 016/126] shots: feature rotate, per-language frame fallback, layout-aware capture selectors --- CHANGELOG.md | 3 + cli/lib/config.ts | 17 ++++++ cli/lib/embedded.ts | 2 +- cli/shots/capture-web.ts | 31 ++++++++++ cli/shots/capture.ts | 27 +++++++-- cli/shots/check.ts | 10 ++- cli/shots/devices.ts | 5 ++ cli/shots/feature-bleed.html | 6 ++ cli/shots/fonts.ts | 7 ++- cli/shots/lib.ts | 28 ++++++++- cli/shots/render.ts | 13 ++-- cli/shots/selectors.ts | 79 ++++++++++++++++++++++++ skills/store-pitfalls/SKILL.md | 13 ++++ tests/browser/feature-fit.browser.ts | 40 ++++++++++++ tests/shots-gaps.test.ts | 91 ++++++++++++++++++++++++++++ 15 files changed, 355 insertions(+), 17 deletions(-) create mode 100644 cli/shots/selectors.ts create mode 100644 tests/shots-gaps.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 703ba22..1a36300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased +- `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas. +- `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them). +- `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size. - Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch). - `apple upload` / `play upload` `--pre-check ""` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence ` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`. - New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate ""`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language. diff --git a/cli/lib/config.ts b/cli/lib/config.ts index f300e23..e0a52fb 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -167,6 +167,10 @@ export interface ShotsConfig { copy: string; /** Raw frames: ///.png */ frames: string; + /** true: every language needs its own frames (//...). Default false: a language with copy but no frames + * folder uses the default language's frames (play.defaultLanguage, else the first language); `shots check` / `render` + * say so as an info line. */ + perLanguageFrames?: boolean; /** Output: ///NN.png and feature/1024x500.png */ out: string; /** Optional logo image for the hero scene and the feature graphic. */ @@ -183,6 +187,9 @@ export interface ShotsConfig { * bottom edge on purpose (shots check then allows it). `scale` (0.5-1.5, default 1) multiplies the phone's width * (still clamped to the canvas with 'contain'). */ feature?: { frame: string; palette?: string | Palette; fit?: 'contain' | 'bleed'; scale?: number; + /** Degrees added to the phone's tilt (layout "bleed": the frame image is turned), like a scene frame's `rotate`: -90 stands a + * landscape game frame upright in a phone lying on its side. Default 0. */ + rotate?: number; /** layout "bleed": the name on the feature graphic when there is no logo (default app.name). */ title?: string; /** Photo background for the feature graphic (path relative to the config, or { image, overlay }); wins over the @@ -207,7 +214,16 @@ export interface ShotsConfig { } /** One raw frame. Web: `path` (+ query). Simulator / emulator: `url` deep link and / or `args` / `extras`. */ +/** Web capture: a selector, candidates tried in order, or per layout { phone, tablet, any } (own list first). Prefer data + * attributes (`[data-shot="shop-row"]`); `text=Shop` and `role=button[name="Shop"]` work as fallbacks. */ +export type ShotSelector = string | string[] | { phone?: string | string[]; tablet?: string | string[]; any?: string | string[] }; + export interface ShotSpec { + /** Web: wait (up to the waitFor timeout) until one of these matches, before waitFor; a miss names the selector and the size. */ + waitSelector?: ShotSelector; + /** Web: element(s) to click after waitSelector, in order (a list of selectors is ONE click with fallbacks; use + * [{...}, {...}] objects for several clicks). */ + click?: ShotSelector | ShotSelector[]; /** Web: URL path after baseUrl, "{lang}" replaced. */ path?: string; /** ios-sim / android-emu: deep link opened after launch, e.g. "mygame://shot/{shot}?lang={lang}". */ @@ -1259,6 +1275,7 @@ export function validateConfig(c: unknown): string[] { if (s.feature !== undefined) { if (!s.feature || typeof s.feature.frame !== 'string' || !s.feature.frame) e.push('shots.feature.frame: a frame name'); if (s.feature?.fit !== undefined && s.feature.fit !== 'contain' && s.feature.fit !== 'bleed') e.push("shots.feature.fit: 'contain' | 'bleed'"); + if (s.feature?.rotate !== undefined && !(typeof s.feature.rotate === 'number' && Math.abs(s.feature.rotate) <= 360)) e.push('shots.feature.rotate: a number of degrees from -360 to 360'); if (s.feature?.scale !== undefined && !(typeof s.feature.scale === 'number' && s.feature.scale >= 0.5 && s.feature.scale <= 1.5)) e.push('shots.feature.scale: a number from 0.5 to 1.5'); e.push(...featureBackgroundIssues(s.feature?.background)); if (s.feature?.palette !== undefined) e.push(...paletteIssues(s.feature.palette, 'shots.feature.palette')); diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index f392f2f..715eb69 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -39,7 +39,7 @@ export const TEMPLATE_TEXT: Record = { export const SHOTS_PAGES: Record = { "caption.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", "feature-app.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", - "feature-bleed.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", + "feature-bleed.html": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n\r\n\r\n\r\n", "feature.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", "template-app.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", "template-bleed.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", diff --git a/cli/shots/capture-web.ts b/cli/shots/capture-web.ts index 516c798..0229eeb 100644 --- a/cli/shots/capture-web.ts +++ b/cli/shots/capture-web.ts @@ -24,6 +24,7 @@ import type { ShotSpec, ShotsCapture } from '../lib/config'; import { log, warn } from '../lib/log'; import { type AuthPlan, expiredHint, landedOnLogin, maskSecrets, safeUrl } from './auth'; import { shotsFor } from './devices'; +import { applySelectors, candidates, probe } from './selectors'; import type { FrameDims } from './lib'; type Device = 'phone' | 'tablet'; @@ -166,6 +167,7 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[] if (!expired.has(safeUrl(s.page.url()))) { expired.add(safeUrl(s.page.url())); warn('shots', `${j.lang}/${j.device}/${j.name}: ${mask(expiredHint(s.page.url()))}`); } throw new Error(`landed on the sign-in page ${safeUrl(s.page.url())}`); } + await applySelectors(s.page, j.spec, { device: j.device, w: o.dims[j.device].w, h: o.dims[j.device].h }, waitTimeout); // waitSelector, click: a miss names the selector and the size if (j.spec.waitFor) await s.page.waitForFunction(j.spec.waitFor, null, { timeout: waitTimeout, polling: 50 }); t.waitFor = Date.now() - t0; t0 = Date.now(); for (const js of j.spec.eval ?? []) await s.page.evaluate(js); @@ -245,3 +247,32 @@ export function writeCaptureState(root: string, report: CaptureReport, cache: Ca writeFileSync(cachePath(root), JSON.stringify(Object.fromEntries(Object.entries(cache).sort()), null, 1) + '\n'); writeFileSync(join(root, '.storebound', 'capture-report.json'), JSON.stringify(report, null, 1) + '\n'); } + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** `shots capture --dry --probe`: opens each page (no screenshot), runs the shot's waitFor, and reports per size which + * selector candidates match. Returns the log lines; `missed` counts shots where no candidate of a list matched. */ +export async function probeSelectors(browser: any, jobs: WebJob[], o: { dims: Record; auth?: AuthPlan; timeoutMs?: number }): Promise<{ lines: string[]; missed: number }> { + const lines: string[] = []; let missed = 0; + for (const j of jobs) { + const lists = [['waitSelector', candidates(j.spec.waitSelector, j.device)], ...(Array.isArray(j.spec.click) && j.spec.click.some(c => typeof c !== 'string') ? j.spec.click : j.spec.click === undefined ? [] : [j.spec.click]) + .map((c, i) => [`click[${i}]`, candidates(c as any, j.device)] as const)].filter(([, l]) => l.length) as [string, string[]][]; + if (!lists.length) continue; + const d = o.dims[j.device]; + const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: 1, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang, ...(o.auth?.contextOptions ?? {}) }); + try { + if (o.auth) await o.auth.prepare(c); + const page = await c.newPage(); + await page.goto(j.url, { waitUntil: 'domcontentloaded', timeout: 90000 }); + if (j.spec.waitFor) await page.waitForFunction(j.spec.waitFor, null, { timeout: o.timeoutMs ?? 20000, polling: 50 }).catch(() => lines.push(`${j.frame} ${d.w}x${d.h}: waitFor did not become true in ${(o.timeoutMs ?? 20000) / 1000}s`)); + for (const [what, list] of lists) { + await page.waitForTimeout(500); + const res = await probe(page, list); + const hit = res.find(r => r.matches > 0); + if (!hit) missed++; + lines.push(`${j.frame} ${j.device} ${d.w}x${d.h} ${what}: ${hit ? `ok via ${hit.selector}` : 'MISS'} (${res.map(r => `${r.selector} -> ${r.matches < 0 ? 'invalid' : r.matches}`).join('; ')})`); + } + } catch (e) { missed++; lines.push(`${j.frame}: probe failed: ${(e as Error).message.split('\n')[0]!.slice(0, 200)}`); } finally { await c.close(); } + } + return { lines, missed }; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/cli/shots/capture.ts b/cli/shots/capture.ts index 1267e7c..c5b6d24 100644 --- a/cli/shots/capture.ts +++ b/cli/shots/capture.ts @@ -1,6 +1,6 @@ // Raw frames from a running build of your app or game, from any engine. // storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder] -// [--dry] [--keep-booted] [--concurrency N] [--only-missing | --force] +// [--dry [--probe]] [--keep-booted] [--concurrency N] [--only-missing | --force] // Frames go to ///.png: the screen BELOW the status bar (the template draws one). // shots.capture.source (default web): // web headless Chromium (playwright), mobile viewport + touch at phone / tablet size. Each shot: @@ -18,7 +18,9 @@ // folder your own frames (any tool, any engine): only checks that every frame the scenes use exists with the // right aspect ratio and resolution. // A simulator storebound booted itself is shut down at the end (--keep-booted keeps it); one that was already running is -// left running with its status bar restored. --dry prints the simctl / adb commands without running them. +// left running with its status bar restored. Web shots may list waitSelector / click candidates per layout +// ({ phone, tablet, any }, data attributes first, text= fallback); --dry prints them per size, --dry --probe opens the +// pages and reports which candidate matched or MISSed on which size. --dry prints the simctl / adb commands without running them. // Simulator / emulator screenshots are full screen: cropTop rows (the status bar) are cut so the frame fits the template // (default per device class from the frame dims: phone 162 px of a 6.9" iPhone, tablet 48 px of a 13" iPad with apple.ipad). // The app or game needs switches for this: language, scene, fixed seed, no ads / tutorial, freeze. See the store-screenshots skill. @@ -26,7 +28,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node import { homedir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; -import type { ShipConfig, ShotsCapture } from '../lib/config'; +import type { ShipConfig, ShotSelector, ShotsCapture } from '../lib/config'; +import { candidates } from './selectors'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, step, warn } from '../lib/log'; @@ -36,7 +39,7 @@ import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, a cropArgs, cropTopFor, fill, pickSimulator, shotsFor, simBootArgs, simBootStatusArgs, simInstallArgs, simLaunchArgs, simOpenUrlArgs, simScreenshotArgs, simShutdownArgs, simStatusBarArgs, simStatusBarClearArgs, simTarget, SOURCES, type Source, TOOL_HINTS, validateCapture } from './devices'; import { type CopyFile, copyText, type FrameDims, frameDims } from './lib'; -import { captureWeb, defaultConcurrency, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web'; +import { captureWeb, defaultConcurrency, probeSelectors, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web'; import { imageInfo } from './spec'; import { authPlan, safeUrl } from './auth'; import * as rt from '../lib/rt'; @@ -122,7 +125,21 @@ export async function run(ctx: Ctx): Promise { if (source === 'web') { if (dry) { - for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`); + for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) { + progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`); + const sel: [string, string[]][] = [['waitSelector', candidates(s.waitSelector, device)], ...(Array.isArray(s.click) && s.click.some(c => typeof c !== 'string') ? s.click : s.click === undefined ? [] : [s.click]).map((c, k) => [`click[${k}]`, candidates(c as ShotSelector, device)] as [string, string[]])]; + for (const [what, list] of sel) if (list.length) log('shots', ` ${what} on ${device} ${dims[device].w}x${dims[device].h}: ${list.join(' | ')}`); + } + if (a.has('probe')) { // open the pages and test every candidate on each size (needs the app running at baseUrl) + need(cap.baseUrl, 'shots.capture.baseUrl'); + const auth = cap.auth ? authPlan(cap.auth, { root, baseUrl: cap.baseUrl! }) : undefined; + const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build: '', fonts: '' }); + const browser = await launch(root, { headless: true, args: ['--mute-audio'] }); + let r; + try { r = await probeSelectors(browser, jobs, { dims, ...(auth ? { auth } : {}) }); } finally { await closeBrowser(browser); } + for (const l of r.lines) log('shots', l); + if (r.missed) throw new Error(`${r.missed} selector lists matched nothing (see MISS lines above: the size and every candidate with its match count)`); + } if (cap.auth) log('shots', cap.auth.storageState ? `would load the signed-in state from ${cap.auth.storageState} into every context` : `would sign every context in first: ${(cap.auth.request?.method ?? 'POST').toUpperCase()} ${safeUrl(cap.auth.request?.url ?? '')} (values from .storebound/credentials.json / env, never shown)`); return done('shots', `capture planned (dry run): ${total} frames from web`); } diff --git a/cli/shots/check.ts b/cli/shots/check.ts index 7f322ee..2697d8f 100644 --- a/cli/shots/check.ts +++ b/cli/shots/check.ts @@ -15,7 +15,7 @@ import type { ShipConfig } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { log, printJson } from '../lib/log'; import { checkFrames, type FrameIssue } from './devices'; -import { defaultSizes, frameDims, renderDims, SIZES } from './lib'; +import { defaultSizes, frameDims, frameLangs, langHasFrames, renderDims, SIZES } from './lib'; import type { LocalIssue, RendersCheck } from '../lib/validate-rules'; import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders, storeOf } from './report'; import { imageInfo } from './spec'; @@ -125,9 +125,13 @@ export async function run(ctx: Ctx): Promise { const tablets = resolvedTablets(config, resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders')); const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue })), ...backgroundIssues(config, resolve, tablets)]; - const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs, tablets) : []; + // languages with copy but no frames use the default language's (shots.perLanguageFrames: true requires them per language) + const framesRoot = resolve(cap?.folder ?? config.shots!.frames), def = config.play?.defaultLanguage ?? config.languages[0]!.id; + const fl = frameLangs(scan.langs, l => langHasFrames(framesRoot, l), def, config.shots?.perLanguageFrames); + const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, framesRoot, scan.langs.filter(l => !fl.borrowed.includes(l)), tablets) : []; + if (fl.borrowed.length && !ctx.args.has('json')) console.log(`info frames: no frames for ${fl.borrowed.join(', ')}; they use the ${def} frames (shots.perLanguageFrames: true requires per-language frames)`); if (ctx.args.has('json')) { - printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues }); + printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues, info: fl.borrowed.map(l => ({ lang: l, framesFrom: def })) }); } else { for (const { where, issue } of issues) console.log(`${issue.level === 'error' ? 'ERROR' : 'warn '} ${where}: ${issue.msg}`); for (const f of frameIssues) console.log(`ERROR frames/${f.file}: ${f.msg}`); diff --git a/cli/shots/devices.ts b/cli/shots/devices.ts index fecf941..1fa9f20 100644 --- a/cli/shots/devices.ts +++ b/cli/shots/devices.ts @@ -6,6 +6,7 @@ // android-emu adb: demo-mode status bar, per-app language (Android 13+), am start with deep link / extras, // exec-out screencap. Emulator or a USB device. // folder bring your own frames: ///.png, checked for size. +import { validateSelector } from './selectors'; import type { ShotsCapture, ShotSpec, VideoRecord } from '../lib/config'; import { validateAuth } from './auth'; @@ -37,6 +38,10 @@ export function validateCapture(c: ShotsCapture | undefined): string[] { if (src !== 'folder' && (!c.shots || !Object.keys(c.shots).length)) e.push('shots.capture.shots: at least one shot'); for (const [name, s] of Object.entries(c.shots ?? {})) { if (!/^[A-Za-z0-9._-]+$/.test(name)) e.push(`shots.capture.shots.${name}: name must be a file name (letters, digits, . _ -)`); + if (src === 'web') { + if (s.waitSelector !== undefined) e.push(...validateSelector(`shots.capture.shots.${name}.waitSelector`, s.waitSelector)); + if (s.click !== undefined) for (const [i, c] of (Array.isArray(s.click) && s.click.some(x => typeof x !== 'string') ? s.click : [s.click]).entries()) e.push(...validateSelector(`shots.capture.shots.${name}.click[${i}]`, c)); + } else if (s.waitSelector !== undefined || s.click !== undefined) e.push(`shots.capture.shots.${name}: waitSelector / click only apply to source web`); if (s.navigate && !['spa', 'reload'].includes(s.navigate)) e.push(`shots.capture.shots.${name}.navigate: spa | reload`); if (s.device && !['phone', 'tablet', 'both'].includes(s.device)) e.push(`shots.capture.shots.${name}.device: phone | tablet | both`); if (src === 'web' && s.path === undefined) e.push(`shots.capture.shots.${name}.path: URL path (source web)`); diff --git a/cli/shots/feature-bleed.html b/cli/shots/feature-bleed.html index 848715d..aeebd9c 100644 --- a/cli/shots/feature-bleed.html +++ b/cli/shots/feature-bleed.html @@ -37,6 +37,12 @@ await document.fonts.ready; await Promise.all([...document.images].map(i => i.decode().catch(() => {}))); const overflow = []; const img = c.querySelector('img.frame'); +// shots.feature.rotate: turn the frame like a scene frame's rotate; a quarter turn swaps the box so the image still covers the canvas +const rot = Number(job.rotate) || 0; +if (rot) { + const quarter = Math.abs(rot % 180) === 90, W = quarter ? 500 : 1024, H = quarter ? 1024 : 500; + Object.assign(img.style, { inset: 'auto', left: (512 - W / 2) + 'px', top: (250 - H / 2) + 'px', width: W + 'px', height: H + 'px', transform: `rotate(${rot}deg)` }); +} if (!img.naturalWidth) overflow.push(`feature frame ${job.frame} did not load`); else if (img.naturalWidth < 1024) overflow.push(`feature frame ${img.naturalWidth}x${img.naturalHeight} is upscaled: capture it at least 1024 px wide`); const t = c.querySelector('.name, .logo').getBoundingClientRect(); diff --git a/cli/shots/fonts.ts b/cli/shots/fonts.ts index 2f24be8..09ea404 100644 --- a/cli/shots/fonts.ts +++ b/cli/shots/fonts.ts @@ -6,7 +6,7 @@ // sublines, badge labels; plus the Noto face of a non-Latin script). A character the stack lacks gets an automatic // Noto fallback (logged; `shots check` errors on renders that used it unless shots.fonts.fallback lists it); a // character no fallback covers fails with the language and the characters. -// storebound shots fonts [--json] (re-run after editing the copy or the fonts) +// storebound shots fonts [--lang en,tr] [--json] (--lang limits the report to those languages; the manifest keeps all) (re-run after editing the copy or the fonts) // Out: /.storebound/fonts/*.woff2 + fonts.css + fonts.json (faces and the per-language stacks, read by render). // The Google fonts are SIL Open Font License 1.1. A local font file in shots.fonts (local-fonts.ts) gets the same cmap // check, is read in place (never copied) and recorded with its path, sha256 and license note. @@ -107,7 +107,10 @@ export async function run(ctx: Ctx): Promise { } for (const f of Object.values(local)) if (!f.license) warn('shots', `local font ${f.path} has no license note: add license ('OFL-1.1', 'proprietary', ...) to its shots.fonts entry`); let auto = 0; - for (const [lang, l] of Object.entries(manifest.langs)) for (const f of l.fallbacks) { + const only = ctx.args.list('lang'); + const unknown = only.filter(l => !(l in manifest.langs)); + if (unknown.length) throw new Error(`--lang: no copy or config language ${unknown.join(', ')} (known: ${Object.keys(manifest.langs).join(', ')})`); + for (const [lang, l] of Object.entries(manifest.langs)) for (const f of only.length && !only.includes(lang) ? [] : l.fallbacks) { const ok = manifest.faces[f.face]?.chosen; if (!ok) auto++; const base = f.role === 'title' ? title : body; diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index 5b882ab..f20964c 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -1,4 +1,6 @@ // Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data. +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; import { specFamily } from './local-fonts'; import { bgIssue } from './palette'; import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config'; @@ -280,8 +282,9 @@ export interface FeatureLayout { left: number; top: number; width: number; heigh * side, centred vertically; `scale` (default 1) multiplies the design width before that clamp. fit 'bleed': the design * width x scale at the old fixed top, cropped by the canvas edge on purpose. */ export function featureLayout(style: 'game' | 'app', dev: { w: number; h: number; bar: number }, - o: { fit?: 'contain' | 'bleed'; scale?: number } = {}): FeatureLayout { - const d = FEATURE_DEVICE[style], C = FEATURE_CANVAS, fit = o.fit ?? 'contain'; + o: { fit?: 'contain' | 'bleed'; scale?: number; rotate?: number } = {}): FeatureLayout { + const base = FEATURE_DEVICE[style], C = FEATURE_CANVAS, fit = o.fit ?? 'contain'; + const d = { ...base, angle: base.angle + (o.rotate ?? 0) }; // shots.feature.rotate adds to the template's tilt, like a scene frame's rotate const k = (dev.h + dev.bar) / dev.w; let width = d.width * (o.scale ?? 1); if (fit === 'contain') { @@ -329,3 +332,24 @@ export function framesForRender(scenes: { frames: { src: string }[] }[], sizes: } return [...out].sort(); } + +/** Which language's frames each language loads (shots.perLanguageFrames false, the default): its own when + * / holds any file, else the default language's. `hasFrames` is injected so tests need no disk. */ +export function frameLangs(langs: string[], hasFrames: (lang: string) => boolean, def: string, perLanguage = false): { map: Record; borrowed: string[] } { + const map: Record = {}, borrowed: string[] = []; + for (const l of langs) { + if (perLanguage || l === def || hasFrames(l)) map[l] = l; + else if (hasFrames(def)) { map[l] = def; borrowed.push(l); } + else map[l] = l; // nothing anywhere: the normal missing-frames error names it + } + return { map, borrowed }; +} + +/** true when / holds at least one file (any depth up to 3). */ +export function langHasFrames(dir: string, lang: string): boolean { + const walk = (d: string, depth: number): boolean => { + let ents; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return false; } + return ents.some(e => e.isFile() || (depth < 3 && e.isDirectory() && walk(join(d, e.name), depth + 1))); + }; + return walk(join(dir, lang), 0); +} diff --git a/cli/shots/render.ts b/cli/shots/render.ts index 2ff3ef1..8895ecf 100644 --- a/cli/shots/render.ts +++ b/cli/shots/render.ts @@ -28,7 +28,7 @@ import { done, log, warn } from '../lib/log'; import { closeBrowser, launch } from './browser'; import { defaultConcurrency, pool, progressLine } from './capture-web'; import { shotsStyle } from '../lib/config'; -import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, framesForRender, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib'; +import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, framesForRender, frameLangs, langHasFrames, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib'; import { bgCss, bgTone, type ContrastSample, contrastIssues, parseColour, photoBackground, photoCss, type RGB, worstContrast } from './palette'; import { type WeightSample, weightIssues } from './weights'; import type { Palette } from '../lib/config'; @@ -74,9 +74,11 @@ export async function run(ctx: Ctx): Promise { } const framesDir = resolve(shots.frames), outDir = resolve(shots.out); // every frame the scenes load must exist: a missing one renders a store image with no app or game in it + const fl = frameLangs(langs, l => langHasFrames(framesDir, l), config.play?.defaultLanguage ?? config.languages[0]!.id, shots.perLanguageFrames); + if (fl.borrowed.length) log('shots', `info: no frames for ${fl.borrowed.join(', ')}; using the ${config.play?.defaultLanguage ?? config.languages[0]!.id} frames (shots.perLanguageFrames: true requires them per language)`); const gone = [...jobs.values()].flatMap(job => { const sel = a.list('scenes').length ? job.scenes.filter(s => a.list('scenes').includes(s.id)) : job.scenes; - return framesForRender(sel, sizes, langs, shots); + return framesForRender(sel, sizes, langs.map(l => fl.map[l]!), shots); }).filter((f, i, all) => all.indexOf(f) === i && !existsSync(join(framesDir, f))); if (gone.length) throw new Error(`shots render: ${gone.length} frame file${gone.length > 1 ? 's' : ''} missing under ${relative(root, framesDir) || '.'} (run \`storebound shots capture\` first, or restore the frames folder; it is often git-ignored):\n ${gone.slice(0, 20).join('\n ')}${gone.length > 20 ? `\n ... ${gone.length - 20} more` : ''}`); if (a.has('dry')) { @@ -164,7 +166,7 @@ export async function run(ctx: Ctx): Promise { fonts: { ...fonts, script: st.script, titleStack: st.title, bodyStack: st.body } }; if (url.searchParams.has('feature')) return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src, featureTitle: shots.feature?.title, - device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale }), + device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale, rotate: shots.feature?.rotate }), rotate: shots.feature?.rotate ?? 0, palette: look(palette(shots.feature?.palette ?? scenes[0]!.palette, style), style === 'app' ? 135 : 160, style === 'app' ? 55 : 50, shots.feature?.background) }); const sid = url.searchParams.get('s') ?? ''; const scene = scenes.find(s => s.id === sid)!; @@ -174,7 +176,10 @@ export async function run(ctx: Ctx): Promise { let file = ''; if (p === '/logo' && shots.logo) file = resolve(shots.logo); else if (p.startsWith('/bgimg/')) file = photos[Number(p.slice(7))] ?? ''; - else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8)); + else if (p.startsWith('/frames/')) { + const [fl0 = '', ...rest] = p.slice(8).split('/'); + file = join(framesDir, [fl.map[fl0] ?? fl0, ...rest].join('/')); + } else if (p.startsWith('/fonts/')) file = fontsRoute(p.slice(7), fontsDir, root, manifest) ?? ''; else if (p.startsWith('/out/')) file = join(outDir, p.slice(5)); else if (/^\/(template|feature)(-app|-bleed)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } }); diff --git a/cli/shots/selectors.ts b/cli/shots/selectors.ts new file mode 100644 index 0000000..1efb778 --- /dev/null +++ b/cli/shots/selectors.ts @@ -0,0 +1,79 @@ +// Layout-aware element selectors for web capture shots (shots.capture.shots..waitSelector / .click): +// a list of candidates tried in order, with a per-device list first ("phone", "tablet"). Data attributes are preferred +// (`[data-shot="shop-row"]`), then CSS, then Playwright text (`text=Shop`, `role=button[name="Shop"]`). A shop row that is +// a list on the phone and a grid at 1180x820 needs different selectors; the miss message names the selector and the size. +import type { ShotSelector } from '../lib/config'; + +type Device = 'phone' | 'tablet'; + +/** Candidates for one device: its own list first, then the shared ones, without repeats. */ +export function candidates(sel: ShotSelector | undefined, device: Device): string[] { + if (sel === undefined) return []; + const list = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]); + const all = typeof sel === 'string' || Array.isArray(sel) ? list(sel) : [...list(sel[device]), ...list(sel.any)]; + return [...new Set(all)]; +} + +export function validateSelector(where: string, sel: unknown): string[] { + const ok = (v: unknown): boolean => typeof v === 'string' && v.trim() !== ''; + const okList = (v: unknown): boolean => ok(v) || (Array.isArray(v) && v.length > 0 && v.every(ok)); + if (okList(sel)) return []; + if (sel && typeof sel === 'object' && !Array.isArray(sel)) { + const o = sel as Record, bad = Object.keys(o).filter(k => !['phone', 'tablet', 'any'].includes(k)); + if (bad.length) return [`${where}: unknown key ${bad.join(', ')} (phone | tablet | any)`]; + if (!Object.keys(o).length) return [`${where}: at least one of phone | tablet | any`]; + return Object.entries(o).filter(([, v]) => !okList(v)).map(([k]) => `${where}.${k}: a selector or a list of selectors`); + } + return [`${where}: a selector, a list of selectors, or { phone, tablet, any }`]; +} + +/** Minimal Playwright surface used here, so tests can fake a page. */ +export interface SelPage { + locator(sel: string): { count(): Promise; first(): { click(o?: { timeout?: number }): Promise } }; + waitForTimeout(ms: number): Promise; +} + +export interface SelResult { selector: string; matches: number } + +/** Match counts of every candidate. */ +export async function probe(page: SelPage, list: string[]): Promise { + const out: SelResult[] = []; + for (const selector of list) { + let matches = 0; + try { matches = await page.locator(selector).count(); } catch { matches = -1; } // -1: not a valid selector + out.push({ selector, matches }); + } + return out; +} + +/** The miss message: which selector, on which size, what each candidate matched. */ +export function missMessage(what: string, where: { device: Device; w: number; h: number }, res: SelResult[]): string { + const rows = res.map(r => `${r.selector} ${r.matches < 0 ? 'is not a valid selector' : `matched ${r.matches}`}`).join('; '); + return `${what} selector missed on ${where.device} ${where.w}x${where.h}: ${rows}. Add a candidate for this layout (waitSelector/click: { ${where.device}: [...] }), data attributes first`; +} + +/** Waits (polling) until one candidate matches; returns it. Throws the miss message on timeout. */ +export async function firstMatch(page: SelPage, list: string[], what: string, where: { device: Device; w: number; h: number }, timeoutMs: number): Promise { + const end = Date.now() + timeoutMs; + let res: SelResult[] = []; + for (;;) { + res = await probe(page, list); + const hit = res.find(r => r.matches > 0); + if (hit) return hit.selector; + if (Date.now() >= end) throw new Error(missMessage(what, where, res)); + await page.waitForTimeout(100); + } +} + +/** waitSelector, then click, for one shot on one device. No-ops without either. */ +export async function applySelectors(page: SelPage, spec: { waitSelector?: ShotSelector; click?: ShotSelector | ShotSelector[] }, where: { device: Device; w: number; h: number }, timeoutMs: number): Promise { + const w = candidates(spec.waitSelector, where.device); + if (w.length) await firstMatch(page, w, 'waitSelector', where, timeoutMs); + const clicks = spec.click === undefined ? [] : Array.isArray(spec.click) && spec.click.some(c => typeof c !== 'string') ? spec.click : [spec.click as ShotSelector]; + for (const c of clicks) { + const list = candidates(c, where.device); + if (!list.length) continue; + const hit = await firstMatch(page, list, 'click', where, timeoutMs); + await page.locator(hit).first().click({ timeout: timeoutMs }); + } +} diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 1e06369..15a874c 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1441,3 +1441,16 @@ that fails without the fix (see CONTRIBUTING.md). --platform macos` refuses an unsigned .pkg or one not signed with an installer certificate before any network call (`tests/mas-build.test.ts`). The `com.apple.pkg` file type of the API build upload is taken from Apple's schema and has not been exercised against the live API yet; `--via altool` (`--type macos`) is the fallback. + +## 120. Play feature graphic shows a landscape game sideways; a language without frames fails the whole check + +- **Symptom:** the 1024x500 feature graphic showed a landscape game frame sideways inside the portrait phone (the + feature path ignored `rotate`); `shots check` without `--lang` failed on missing frames of languages that only have + copy; a capture click on a shop row matched on the phone layout and silently missed at tablet / desktop sizes. +- **Do:** set `shots.feature.rotate` (degrees, added to the template tilt; `-90` stands a landscape frame upright in a + phone lying on its side; with layout "bleed" the frame image itself is turned). Frames of a language that has none + come from the default language (info line; `shots.perLanguageFrames: true` requires them per language). In web + capture list `waitSelector` / `click` candidates per layout (`{ phone: [...], tablet: [...], any: [...] }`, data + attributes first, `text=` last); `shots capture --dry` prints them per size, `--dry --probe` opens the pages and + names the MISS with the size and every candidate's match count. +- **Guard:** `tests/shots-gaps.test.ts`, `tests/browser/feature-fit.browser.ts` (rotate), `cli/shots/selectors.ts`. diff --git a/tests/browser/feature-fit.browser.ts b/tests/browser/feature-fit.browser.ts index 82aea79..a546755 100644 --- a/tests/browser/feature-fit.browser.ts +++ b/tests/browser/feature-fit.browser.ts @@ -63,6 +63,8 @@ describe('shots.feature config', () => { expect(errs({ frame: 'a.png' })).toEqual([]); expect(errs({ frame: 'a.png', fit: 'bleed', scale: 1.2 })).toEqual([]); expect(errs({ frame: 'a.png', fit: 'cover' })).toEqual(["shots.feature.fit: 'contain' | 'bleed'"]); + expect(errs({ frame: 'a.png', rotate: -90 })).toEqual([]); + expect(errs({ frame: 'a.png', rotate: 'left' })).toEqual(['shots.feature.rotate: a number of degrees from -360 to 360']); expect(errs({ frame: 'a.png', scale: 3 })).toEqual(['shots.feature.scale: a number from 0.5 to 1.5']); }); }); @@ -106,3 +108,41 @@ describe.skipIf(!browser)('feature graphic templates at 1024 x 500 (DOM layout b } finally { await page.close(); } }, scaled(30_000)); }); + +describe.skipIf(!browser)('shots.feature.rotate', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const route = (page: any, html: string, name: string, job: unknown) => page.route('http://t.test/**', (r: { request(): { url(): string }; fulfill(o: unknown): Promise }) => { + const u = new URL(r.request().url()); + if (u.pathname === `/${name}`) return r.fulfill({ contentType: 'text/html', body: html }); + if (u.pathname === '/job.json') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(job) }); + if (u.pathname.startsWith('/frames/')) return r.fulfill({ contentType: 'image/png', body: PNG }); + return r.fulfill({ contentType: 'text/css', body: '' }); + }); + const pal = { bg: ['#123456', '#345678', '#567890'], glow: '#ffffff', ink: '#000000', shadow: 'rgba(0,0,0,.4)' }; + test('game template: a -90 phone lies on its side and still fits', async () => { + const page = await browser.newPage({ viewport: { width: 1024, height: 500 } }); + try { + const device = featureLayout('game', DEFAULT_PHONE, { rotate: -90 }); + expect(device.angle).toBe(-83); + await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature.html'), 'utf8'), 'feature.html', + { style: 'game', phone: DEFAULT_PHONE, logo: false, appName: 'Demo', fonts: { title: 'X' }, frame: 'a.png', device, palette: pal }); + await page.goto('http://t.test/feature.html?lang=en'); + await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 }); + const d = (JSON.parse(await page.evaluate('document.body.dataset.devices')) as { x: number; y: number; w: number; h: number }[])[0]!; + expect(d.w).toBeGreaterThan(d.h); // landscape outline + expect(boxOutside(d, 1024, 500)).toEqual([]); + } finally { await page.close(); } + }, scaled(30_000)); + test('bleed template: the frame image is turned and still covers the canvas', async () => { + const page = await browser.newPage({ viewport: { width: 1024, height: 500 } }); + try { + await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature-bleed.html'), 'utf8'), 'feature-bleed.html', + { fonts: { title: 'X' }, frame: 'a.png', appName: 'Demo', rotate: -90 }); + await page.goto('http://t.test/feature-bleed.html?lang=en'); + await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 }); + const r = await page.evaluate("(() => { const b = document.querySelector('img.frame').getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height }; })()") as { x: number; y: number; w: number; h: number }; + expect(Math.round(r.w)).toBe(1024); expect(Math.round(r.h)).toBe(500); expect(Math.round(r.x)).toBe(0); expect(Math.round(r.y)).toBe(0); + expect(await page.evaluate("document.querySelector('img.frame').style.transform")).toBe('rotate(-90deg)'); + } finally { await page.close(); } + }, scaled(30_000)); +}); diff --git a/tests/shots-gaps.test.ts b/tests/shots-gaps.test.ts new file mode 100644 index 0000000..3bc8045 --- /dev/null +++ b/tests/shots-gaps.test.ts @@ -0,0 +1,91 @@ +// Real-use gaps in `shots`: feature rotate, check/render frame fallback per language, layout-aware capture selectors. +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DEFAULT_PHONE, featureLayout, frameLangs, langHasFrames } from '../cli/shots/lib'; +import { applySelectors, candidates, missMessage, probe, validateSelector, type SelPage } from '../cli/shots/selectors'; +import { validateCapture } from '../cli/shots/devices'; + +describe('feature rotate', () => { + test('adds to the template tilt and still fits', () => { + const a = featureLayout('game', DEFAULT_PHONE, {}), b = featureLayout('game', DEFAULT_PHONE, { rotate: -90 }); + expect(a.angle).toBe(7); expect(b.angle).toBe(-83); + expect(b.box.w).toBeGreaterThan(b.box.h); + expect(b.box.x).toBeGreaterThanOrEqual(23.99); expect(b.box.x + b.box.w).toBeLessThanOrEqual(1024 - 23.99); + expect(b.box.y).toBeGreaterThanOrEqual(23.99); expect(b.box.y + b.box.h).toBeLessThanOrEqual(500 - 23.99); + }); +}); + +describe('frame languages', () => { + const has = (set: string[]) => (l: string) => set.includes(l); + test('a language without frames borrows the default language', () => { + expect(frameLangs(['en', 'tr', 'de'], has(['en', 'de']), 'en')).toEqual({ map: { en: 'en', tr: 'en', de: 'de' }, borrowed: ['tr'] }); + }); + test('perLanguage requires its own', () => { + expect(frameLangs(['en', 'tr'], has(['en']), 'en', true)).toEqual({ map: { en: 'en', tr: 'tr' }, borrowed: [] }); + }); + test('no frames anywhere: nothing borrowed, the missing error names the language', () => { + expect(frameLangs(['en', 'tr'], has([]), 'en').borrowed).toEqual([]); + }); + test('langHasFrames reads the folder', () => { + const d = mkdtempSync(join(tmpdir(), 'sb-fl-')); + try { + mkdirSync(join(d, 'en', 'phone'), { recursive: true }); writeFileSync(join(d, 'en', 'phone', 'a.png'), 'x'); + mkdirSync(join(d, 'tr'), { recursive: true }); + expect(langHasFrames(d, 'en')).toBe(true); + expect(langHasFrames(d, 'tr')).toBe(false); + expect(langHasFrames(d, 'de')).toBe(false); + } finally { rmSync(d, { recursive: true, force: true }); } + }); +}); + +/** Fake page: a DOM of selector -> count; text= selectors are just keys. */ +function fake(dom: Record, clicks: string[] = []): SelPage { + return { + locator: (s: string) => ({ count: async () => { if (s.startsWith('!')) throw new Error('bad'); return dom[s] ?? 0; }, first: () => ({ click: async () => { clicks.push(s); } }) }), + waitForTimeout: async () => {}, + }; +} + +describe('capture selectors', () => { + test('per-device list first, then shared, no repeats', () => { + const s = { phone: ['[data-shot="row"]'], tablet: ['[data-shot="tile"]', 'text=Shop'], any: ['text=Shop'] }; + expect(candidates(s, 'tablet')).toEqual(['[data-shot="tile"]', 'text=Shop']); + expect(candidates(s, 'phone')).toEqual(['[data-shot="row"]', 'text=Shop']); + expect(candidates('a', 'phone')).toEqual(['a']); + expect(candidates(undefined, 'phone')).toEqual([]); + }); + test('validation', () => { + expect(validateSelector('x', 'a')).toEqual([]); + expect(validateSelector('x', { tablet: ['a'] })).toEqual([]); + expect(validateSelector('x', { desktop: 'a' })[0]).toContain('unknown key desktop'); + expect(validateSelector('x', '')).not.toEqual([]); + expect(validateSelector('x', {})).not.toEqual([]); + }); + test('the click lands on the first candidate that matches', async () => { + const clicks: string[] = []; + await applySelectors(fake({ 'text=Shop': 1 }, clicks), { waitSelector: ['[data-shot="row"]', 'text=Shop'], click: { tablet: ['[data-shot="tile"]', 'text=Shop'] } }, { device: 'tablet', w: 1180, h: 820 }, 50); + expect(clicks).toEqual(['text=Shop']); + }); + test('several clicks in order', async () => { + const clicks: string[] = []; + await applySelectors(fake({ a: 1, b: 1 }, clicks), { click: [{ any: 'a' }, 'b'] }, { device: 'phone', w: 440, h: 902 }, 50); + expect(clicks).toEqual(['a', 'b']); + }); + test('a miss names the selector, the size and every match count', async () => { + const err = await applySelectors(fake({ '[data-shot="row"]': 3 }), { click: { tablet: ['[data-shot="tile"]', 'text=Shop'] } }, { device: 'tablet', w: 1180, h: 820 }, 30).then(() => '', e => (e as Error).message); + expect(err).toContain('click selector missed on tablet 1180x820'); + expect(err).toContain('[data-shot="tile"] matched 0'); + expect(err).toContain('text=Shop matched 0'); + expect(missMessage('click', { device: 'phone', w: 1, h: 2 }, [{ selector: '!x', matches: -1 }])).toContain('is not a valid selector'); + }); + test('probe counts every candidate', async () => { + expect(await probe(fake({ a: 2 }), ['a', 'b'])).toEqual([{ selector: 'a', matches: 2 }, { selector: 'b', matches: 0 }]); + }); + test('validateCapture accepts and rejects', () => { + const cap = (shot: object) => ({ source: 'web', baseUrl: 'http://127.0.0.1:1', shots: { shop: { path: '/', ...shot } } }) as never; + expect(validateCapture(cap({ waitSelector: { tablet: 'a' }, click: ['a', 'b'] }))).toEqual([]); + expect(validateCapture(cap({ click: { desktop: 'a' } }))[0]).toContain('unknown key desktop'); + }); +}); From b059e83b90f7aa3c858bbcbdb870dccd49efc78d Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:01:32 +0800 Subject: [PATCH 017/126] shots fonts usage line --- cli/shots/fonts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/shots/fonts.ts b/cli/shots/fonts.ts index 09ea404..cb0a9e0 100644 --- a/cli/shots/fonts.ts +++ b/cli/shots/fonts.ts @@ -6,7 +6,7 @@ // sublines, badge labels; plus the Noto face of a non-Latin script). A character the stack lacks gets an automatic // Noto fallback (logged; `shots check` errors on renders that used it unless shots.fonts.fallback lists it); a // character no fallback covers fails with the language and the characters. -// storebound shots fonts [--lang en,tr] [--json] (--lang limits the report to those languages; the manifest keeps all) (re-run after editing the copy or the fonts) +// storebound shots fonts [--lang en,tr] [--json] (--lang limits the report; the manifest keeps all languages) (re-run after editing the copy or the fonts) // Out: /.storebound/fonts/*.woff2 + fonts.css + fonts.json (faces and the per-language stacks, read by render). // The Google fonts are SIL Open Font License 1.1. A local font file in shots.fonts (local-fonts.ts) gets the same cmap // check, is read in place (never copied) and recorded with its path, sha256 and license note. From 66247f35d94a196abedbf3b8e3dc40ce0f05ae40 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:01:45 +0800 Subject: [PATCH 018/126] flags: shots fonts --lang, capture --probe --- cli/data/flags.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cli/data/flags.json b/cli/data/flags.json index db3f259..7473ef4 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -939,6 +939,7 @@ "keep-booted", "lang", "only-missing", + "probe", "source", "src", "variants" @@ -956,7 +957,9 @@ "to", "translate" ], - "shots fonts": [], + "shots fonts": [ + "lang" + ], "shots gallery": [ "lang", "no-variants", @@ -985,6 +988,7 @@ "no-captions", "only-missing", "port", + "probe", "seconds", "source", "src", @@ -1276,6 +1280,7 @@ "port", "poster", "pre-check", + "probe", "production", "profile", "progress", @@ -1721,7 +1726,7 @@ "storebound shots copy reviewed --lang de[,es] | --all [--dry] a human read the copy: remove its marker" ], "shots fonts": [ - "storebound shots fonts [--json] (re-run after editing the copy or the fonts)" + "storebound shots fonts [--lang en,tr] [--json] (--lang limits the report; the manifest keeps all languages) (re-run after editing the copy or the fonts)" ], "shots gallery": [ "storebound shots gallery [--lang en,tr] [--src store/renders] [--out ] [--no-variants] [--json]" From 98cd1d3c926eed767c2060f2105ace800595d7ca Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:02:02 +0800 Subject: [PATCH 019/126] embedded: regenerated --- cli/lib/embedded.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index 715eb69..c963dee 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -39,7 +39,7 @@ export const TEMPLATE_TEXT: Record = { export const SHOTS_PAGES: Record = { "caption.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", "feature-app.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", - "feature-bleed.html": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n\r\n\r\n\r\n", + "feature-bleed.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", "feature.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", "template-app.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", "template-bleed.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", From 0ef27cf2e46ad400639e4d315c34b5540ee3605b Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:01:50 +0800 Subject: [PATCH 020/126] desktop: five sections by job (Release, Store page, Revenue, Players, Health), Game services view, sidebar by attention - app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the context menu list sections and sub views; old tab ids, section and section/sub links all resolve - Players > Game services (games only): both stores, one row per leaderboard / achievement with its languages and each store's state, Game Center locale map, Play API-disabled state with the console plan - games status --json: items, localeMap, playApiDisabled; inspect summary: games - sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio filter, workspace as a footer icon row - boot check opens places like a person (section, then sub view) --- apps/desktop/src/App.tsx | 147 +++++++++++++--------- apps/desktop/src/design.css | 42 +++++++ apps/desktop/src/dev/fixtures.ts | 10 ++ apps/desktop/src/i18n/en.ts | 102 +++++++++++++++ apps/desktop/src/i18n/tr.ts | 102 +++++++++++++++ apps/desktop/src/lib/model.ts | 47 ++++++- apps/desktop/src/lib/sections.ts | 94 ++++++++++++++ apps/desktop/src/nav.ts | 49 ++++++-- apps/desktop/src/screens/AppView.tsx | 26 ++-- apps/desktop/src/screens/SectionHead.tsx | 60 +++++++++ apps/desktop/src/screens/tabs/AppInfo.tsx | 2 - apps/desktop/src/screens/tabs/Games.tsx | 90 +++++++++++++ apps/desktop/src/store.tsx | 18 +++ apps/desktop/src/ui/GameServicesCard.tsx | 16 ++- apps/desktop/src/ui/app-menu.ts | 10 +- apps/desktop/src/ui/ds.tsx | 5 +- apps/desktop/tests/sections.test.ts | 79 ++++++++++++ cli/games-status.ts | 46 ++++++- cli/inspect.ts | 1 + cli/lib/desktop-protocol.ts | 2 + tests/game-services.test.ts | 14 ++- tools/desktop-boot-check.ts | 71 ++++++++--- 22 files changed, 918 insertions(+), 115 deletions(-) create mode 100644 apps/desktop/src/lib/sections.ts create mode 100644 apps/desktop/src/screens/SectionHead.tsx create mode 100644 apps/desktop/src/screens/tabs/Games.tsx create mode 100644 apps/desktop/tests/sections.test.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b1b01fe..4cfa717 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,5 @@ -// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh), +// Shell: sidebar (apps grouped by what they need: attention, in progress, live, not on a store yet; one status line +// each; a studio filter; the workspace as a compact footer row), top bar (breadcrumbs, last read, refresh), // the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide. import { DryRunPreview } from './ui/DryRunPreview'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -6,8 +7,8 @@ import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api'; import { devSwitch } from './dev/switches'; import { type Key, t } from './i18n'; import { ago, num } from './lib/format'; -import { groupByStudio, type UnifiedApp } from './lib/identity'; -import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; +import type { UnifiedApp } from './lib/identity'; +import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, SECTION_ICON, SECTION_KEY, sectionOf, sectionsFor, subsOf, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; import { usePrefs } from './prefs-ctx'; import { Accounts } from './screens/Accounts'; import { AppFolders } from './screens/AppFolders'; @@ -28,16 +29,18 @@ import { Toasts } from './ui/Toasts'; import { useContextMenu } from './ui/ContextMenu'; import { appMenu } from './ui/app-menu'; import { isEditable, isItem, type MenuInput } from './ui/menu-logic'; -import { worstTone } from './ui/ds'; -import { attention } from './lib/model'; +import { attention, isGameSummary, SIDE_GROUPS, type SideGroup, sideStatus } from './lib/model'; const WS_ICON: Record = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' }; export const WS_KEY: Record = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' }; const SIDE_KEY = 'storebound.side.v1'; +const STUDIO_KEY = 'storebound.side.studio.v1'; function loadCollapsed(): string[] { try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; } } +function loadStudio(): string | null { try { return localStorage.getItem(STUDIO_KEY); } catch { return null; } } +const GROUP_KEY: Record = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' }; export function App({ onTheme }: { onTheme: () => void }) { const d = useData(); @@ -47,6 +50,8 @@ export function App({ onTheme }: { onTheme: () => void }) { const [welcome, setWelcome] = useState(false); const [started, setStarted] = useState(false); const [collapsed, setCollapsed] = useState(loadCollapsed); + const [studioFilter, setStudioFilterState] = useState(loadStudio); + const setStudioFilter = (v: string | null) => { setStudioFilterState(v); try { if (v) localStorage.setItem(STUDIO_KEY, v); else localStorage.removeItem(STUDIO_KEY); } catch { /* private mode */ } }; const exports = useExportItems(); const runExport = useRunExport(); @@ -56,16 +61,27 @@ export function App({ onTheme }: { onTheme: () => void }) { const allReads = useReadState({ tags: ['all'] }); const [showIgnored, setShowIgnored] = useShowIgnored(); const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]); - const listed = showIgnored ? [...visible, ...ignoredApps] : visible; - const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }]; - // Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the tab when the other app has it) - const sideOrder = groups.flatMap(x => x.rows.map(r => r.app)); + const gameOf = useCallback((a: UnifiedApp) => isGameSummary(d.statusFor(a)?.summary), [d]); + const tabsOf = useCallback((a: UnifiedApp) => tabsFor(!!a.project, gameOf(a)), [gameOf]); + // the sidebar: apps by what they need (attention, in progress, live, not on a store yet), one status line each + const studioOn = studioFilter && (studioFilter === 'none' || d.studios.some(s => s.id === studioFilter)) ? studioFilter : null; + const inStudio = useCallback((a: UnifiedApp) => !studioOn || (studioOn === 'none' ? !a.studio : a.studio === studioOn), [studioOn]); + const sideRows = useMemo(() => visible.filter(inStudio).map(a => { + const e = d.statusFor(a), l = d.liveFor(a); + return { app: a, st: sideStatus({ att: attention(a.key, a.name, e, l), all: l?.release.all ?? [], read: !!l || e?.release.source === 'live', missing: !!a.project?.missing, storeOnly: !a.project }) }; + }), [visible, d, inStudio]); + const groups: { id: SideGroup | 'ignored'; rows: typeof sideRows }[] = [ + ...SIDE_GROUPS.map(g => ({ id: g, rows: sideRows.filter(r => r.st.group === g) })).filter(g => g.rows.length), + ...(showIgnored && ignoredApps.length ? [{ id: 'ignored' as const, rows: ignoredApps.filter(inStudio).map(a => ({ app: a, st: { group: 'draft' as SideGroup, text: t('side.ignored'), tone: 'neutral' as const } })) }] : []), + ]; + // Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the place when the other app has it) + const sideOrder = groups.flatMap(x => (collapsed.includes(`g:${x.id}`) ? [] : x.rows.map(r => r.app))); const stepApp = useCallback((dir: 1 | -1) => { if (!sideOrder.length) return; const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1; const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!; - go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsFor(!!next.project).includes(route.tab) ? route.tab : 'releases' }); - }, [sideOrder, route, go]); + go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsOf(next).includes(route.tab) ? route.tab : 'releases' }); + }, [sideOrder, route, go, tabsOf]); const toggleGroup = (id: string) => setCollapsed(c => { const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id]; try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ } @@ -94,9 +110,12 @@ export function App({ onTheme }: { onTheme: () => void }) { }, [go, d.reload]); const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined; - const tabs = app ? tabsFor(!!app.project) : []; + const game = app ? gameOf(app) : false; + const tabs = app ? tabsOf(app) : []; + const sections = app ? sectionsFor(!!app.project, game) : []; - // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs + // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, 1-5 (no modifier) the app's sections, + // Cmd/Ctrl+Shift+[ ] the previous / next section useEffect(() => { const onKey = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey; @@ -109,15 +128,20 @@ export function App({ onTheme }: { onTheme: () => void }) { go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! }); return; } - if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && tabs.length) { + if (!mod && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key) && route.screen === 'app' && app && !isEditable(e.target as HTMLInputElement) && !document.querySelector('.ds-menu, .ds-dialog')) { + const sec = sections[Number(e.key) - 1]; + if (sec) { e.preventDefault(); go({ ...route, tab: subsOf(sec, !!app.project, game)[0]! }); } + return; + } + if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && app && sections.length) { e.preventDefault(); - const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1); - go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! }); + const i = sections.indexOf(sectionOf(route.tab)) + (e.key === ']' || e.key === '}' ? 1 : -1); + go({ ...route, tab: subsOf(sections[(i + sections.length) % sections.length]!, !!app.project, game)[0]! }); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [palette, welcome, route, go, tabs, stepApp]); + }, [palette, welcome, route, go, app, sections, game, stepApp]); const closeWelcome = useCallback(() => { setWelcome(false); @@ -131,13 +155,18 @@ export function App({ onTheme }: { onTheme: () => void }) { const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') }; const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name; for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) }, - run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) }); + run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' }) }); c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) }); c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) }); c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) }); WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) })); for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) }); - if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); + // every section of the open app (1-5) and every sub view in it + if (app) sections.forEach((sec, i) => { + const subs = subsOf(sec, !!app.project, game); + c.push({ id: `sec-${sec}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])}`, icon: SECTION_ICON[sec], hint: String(i + 1), keywords: subs.map(x => t(TAB_KEY[x])).join(' '), run: () => go({ screen: 'app', appId: app.key, tab: subs[0]! }) }); + if (subs.length > 1) for (const tab of subs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); + }); // the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is // also one Ctrl+K away; opening a tab is already above if (app) { @@ -166,10 +195,10 @@ export function App({ onTheme }: { onTheme: () => void }) { c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme }); c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) }); return c; - }, [d, visible, app, tabs, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update]); + }, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]); const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }] - : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }] + : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(SECTION_KEY[sectionOf(route.tab)]), to: app ? { screen: 'app', appId: app.key, tab: subsOf(sectionOf(route.tab), !!app.project, game)[0] ?? route.tab } : undefined }, ...(app && subsOf(sectionOf(route.tab), !!app.project, game).length > 1 ? [{ label: t(TAB_KEY[route.tab]) }] : [])] : [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }]; const lastRead = d.live?.at ?? null; // the app on screen is read first by the background reader @@ -180,26 +209,19 @@ export function App({ onTheme }: { onTheme: () => void }) { const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length; const menu = useContextMenu(); - const appItem = (a: UnifiedApp) => { - const e = d.statusFor(a); - const missing = !!a.project?.missing; - const tone = missing ? 'bad' : (worstTone(attention(a.key, a.name, e, d.liveFor(a))) ?? (e ? 'ok' : null)); - const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0); - return ( -
- -
- ); - }; + const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => ( +
+ +
+ ); + const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE]; return ( @@ -213,36 +235,41 @@ export function App({ onTheme }: { onTheme: () => void }) {
- -
{d.studios.length ? t('nav.studiosLabel') : t('nav.appsLabel')} +
{t('nav.appsLabel')} {visible.length}
+ {d.studios.length > 1 && ( +
+ + {d.studios.map(s => )} +
+ )} {groups.map(g => { - const s = d.studios.find(x => x.id === g.studio) ?? null; - const gid = g.studio ?? 'none'; + const gid = `g:${g.id}`; const open = !collapsed.includes(gid); - if (!d.studios.length) return
{g.rows.map(r => appItem(r.app))}
; return ( -
- - {open &&
{g.rows.map(r => appItem(r.app))}
} + {open &&
{g.rows.map(r => appItem(r.app, r.st))}
}
); })} {!visible.length && !showIgnored &&

{t('nav.noApps')}

} {ignoredApps.length > 0 && } -
{t('nav.workspace')}
- {WORKSPACE.map((s, i) => ( - - ))} +
+
+ {WS_ROW.map((s, i) => { + const label = s === 'portfolio' ? t('nav.portfolio') : t(WS_KEY[s]); + const n = s === 'accounts' ? d.accounts.length : s === 'ignored' ? ignoredCount : 0; + return ( + + ); + })}
diff --git a/apps/desktop/src/design.css b/apps/desktop/src/design.css index fc782ca..adfc21f 100644 --- a/apps/desktop/src/design.css +++ b/apps/desktop/src/design.css @@ -245,3 +245,45 @@ .table-wrap .table tbody tr:hover > :first-child, .ds-table-wrap .table tbody tr:hover > :first-child { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--ds-surface, var(--surface))); } /* Settings > Appearance > Tables: compact */ :root[data-density="compact"] .table th, :root[data-density="compact"] .table td { padding-top: 5px; padding-bottom: 5px; padding-left: 8px; padding-right: 8px; font-size: 12.5px; } + +/* ---------- App sections (docs IA): section header, next step, sub views ---------- */ +.ds-tab-k { font: 600 10px/1 var(--mono, ui-monospace, monospace); min-width: 16px; height: 16px; display: inline-grid; place-items: center; border-radius: 4px; color: var(--muted); border: 1px solid var(--ds-stroke); background: var(--ds-surface); margin-left: 2px; opacity: .75; } +.ds-tab[aria-selected="true"] .ds-tab-k { opacity: 1; } +.sec-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "t next" "subs subs"; gap: 10px 16px; align-items: center; padding: 2px 4px 4px; } +.sec-head-t { grid-area: t; min-width: 0; } +.sec-head-name { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--ds-t-h2, 18px); font-weight: 700; letter-spacing: -.01em; } +.sec-head-name svg { width: 18px; height: 18px; color: var(--tint, var(--accent-solid)); } +.sec-head-for { margin: 2px 0 0; color: var(--muted); font-size: var(--t-meta); } +.sec-next { grid-area: next; display: inline-flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; min-height: 32px; box-sizing: border-box; border-radius: var(--ds-r); border: 1px solid var(--ds-stroke); background: var(--ds-surface); font-weight: 600; font-size: var(--t-meta); max-width: 560px; } +.sec-next > svg { width: 15px; height: 15px; flex: none; } +.sec-next-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sec-next:not(:has(.btn)) { padding-right: 12px; } +.sec-next.tone-bad { background: var(--bad-bg); color: var(--bad-ink); border-color: transparent; } +.sec-next.tone-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: transparent; } +.sec-next.tone-ok > svg { color: var(--ok-solid); } +.sec-next.tone-info > svg { color: var(--accent-solid); } +.sec-next .btn { flex: none; } +.sec-subs { grid-area: subs; justify-self: start; } +.sec-subs .ed-dot { margin-left: 2px; } +@media (max-width: 1100px) { .sec-head { grid-template-columns: 1fr; grid-template-areas: "t" "next" "subs"; } .sec-next { justify-self: start; } } + +/* ---------- Sidebar: attention groups, one status line per app, workspace footer ---------- */ +.nav-app.has-line { height: auto; min-height: 40px; padding-top: 4px; padding-bottom: 4px; align-items: center; } +.nav-app .side-t { display: grid; min-width: 0; line-height: 1.2; } +.nav-app .side-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-app .side-s { font-size: 11px; font-weight: 500; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-app .side-s.bad { color: var(--bad-ink); } .nav-app .side-s.warn { color: var(--warn-ink); } .nav-app .side-s.ok { color: var(--ok-ink); } +:root[data-theme="dark"] .nav-app .side-s.bad { color: color-mix(in srgb, var(--bad-solid) 70%, white); } +:root[data-theme="dark"] .nav-app .side-s.warn { color: color-mix(in srgb, var(--warn-solid) 70%, white); } +:root[data-theme="dark"] .nav-app .side-s.ok { color: color-mix(in srgb, var(--ok-solid) 70%, white); } +.side-studios { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 6px 2px; } +.side-studios button { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 99px; font-size: 11px; font-weight: 600; color: var(--muted); border: 1px solid var(--line); } +.side-studios button[aria-pressed="true"] { color: var(--ink); background: var(--surface); box-shadow: var(--shadow-1); } +.side-studios button:focus-visible { outline: 2px solid var(--focus); } +.side-ws { display: flex; gap: 2px; padding: 6px 8px; border-top: 1px solid var(--line); } +.side-ws button { all: unset; cursor: pointer; position: relative; flex: 1; height: 30px; display: grid; place-items: center; border-radius: 7px; color: var(--muted); } +.side-ws button:hover { background: var(--hover); color: var(--ink); } +.side-ws button[aria-current="page"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1); } +.side-ws button:focus-visible { outline: 2px solid var(--focus); } +.side-ws svg { width: 17px; height: 17px; } +.side-ws .ws-n { position: absolute; top: 2px; right: 4px; font-size: 9.5px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; } diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 4ba99d4..7e7be9e 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -430,6 +430,16 @@ const GAMES_PRISM = { play: { appId: '123456789012', resource: '123456789012 (android/app/src/main/res/values/games-ids.xml)', leaderboards: 2, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: [] }, findings: [{ store: 'apple', level: 'ok', id: 'gc.entitlement', msg: 'com.apple.developer.game-center in ios/App/App/App.entitlements' }, { store: 'play', level: 'ok', id: 'pgs.app-id', msg: 'games_APP_ID 123456789012 matches the config' }], links: [{ label: 'Play Console: Play Games Services configuration', url: 'https://play.google.com/console/u/0/developers/4444/app/9876/play-games-services/setup' }], + items: [ + { kind: 'leaderboard', id: 'com.example.prismpuddle.best', name: 'Best score', locales: ['en', 'tr', 'ja', 'ar'], ios: true, android: true, + apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }, { locale: 'tr', action: 'keep' }, { locale: 'ja', action: 'keep' }, { locale: 'ar-SA', action: 'keep' }] }, play: { action: 'keep', changed: [] } }, + { kind: 'leaderboard', id: 'com.example.prismpuddle.daily', name: 'Daily puddle', locales: ['en', 'tr'], ios: true, android: true, + apple: { action: 'create', changed: ['referenceName'], locales: [{ locale: 'en-US', action: 'create' }, { locale: 'tr', action: 'create' }] }, play: { action: 'keep', changed: [] } }, + ...['first-drop', 'ten-levels', 'no-hints', 'rainbow', 'speedrun'].map((k, i) => ({ kind: 'achievement', id: `com.example.prismpuddle.${k}`, name: ['First drop', 'Ten levels', 'No hints', 'Rainbow', 'Speedrun'][i]!, locales: ['en', 'tr', 'ja', 'ar'], points: [10, 20, 30, 20, 20][i], hidden: k === 'speedrun', ios: true, android: true, + apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }] }, play: { action: i === 2 ? 'update' : 'keep', changed: i === 2 ? ['draft.description'] : [] } })), + ], + localeMap: { configured: { ar: 'ar-SA' }, suggested: [{ id: 'en', from: 'en-US', to: 'en-GB' }] }, + playApiDisabled: null, }; const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data }); diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3e3d6ba..c4f37ed 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2116,4 +2116,106 @@ export const en = { 'cm.copyPrincipal': "Service account / key", 'cm.copyProfile': "Profile name", 'cm.openAccounts': "Open Studios & accounts", + "sec.release": "Release", + "sec.store": "Store page", + "sec.revenue": "Revenue", + "sec.players": "Players", + "sec.health": "Health", + "sec.release.for": "Get a version to players on both stores: builds, TestFlight, review and rollout.", + "sec.store.for": "What people see on the store: texts per language, screenshots and app details.", + "sec.revenue.for": "What the app sells and for how much: in-app products, subscriptions and prices.", + "sec.players.for": "What players say, and for games what they can earn: reviews, Game Center, Play Games.", + "sec.health.for": "Is the app well and ready: crashes and ANRs, growth, checks to fix first, more tools.", + "sec.views": "{section} views", + "tab.games": "Game services", + "next.open": "Open {where}", + "next.rejected": "{version} was rejected on {store}", + "next.halted": "{version} is halted on {store}", + "next.blockers": "{count} blockers before submit", + "next.blockers_one": "1 blocker before submit", + "next.rollout": "{version} is rolling out at {share}", + "next.approved": "{version} is approved: release it", + "next.submit": "Submit {version} for review", + "next.review": "{version} is in review", + "next.managed": "{version} waits in Publishing overview (managed publishing)", + "next.upload": "Upload a first build", + "next.live": "Live: {version}", + "next.notRead": "Stores not read yet", + "next.storeLive": "The live store page, read from the stores", + "next.listingProblems": "{count} listing problems", + "next.listingProblems_one": "1 listing problem", + "next.unsaved": "{count} unsaved edits", + "next.unsaved_one": "1 unsaved edit", + "next.listingsUnread": "Listings not checked yet", + "next.storeOk": "Store page in step with the config", + "next.catalogDiff": "{count} products differ between the stores", + "next.catalogDiff_one": "1 product differs between the stores", + "next.noProducts": "No products yet: add one", + "next.products": "{products} products · {subs} subscriptions", + "next.reviews": "{count} reviews without a reply", + "next.reviews_one": "1 review without a reply", + "next.reviewsUnread": "Reviews not read yet", + "next.reviewsOk": "All reviews answered", + "next.gamesApiOff": "Play Games Services API is off", + "next.gamesBlockers": "{count} game services blockers", + "next.gamesBlockers_one": "1 game services blocker", + "next.gamesSync": "Game services: {count} changes to sync", + "next.gamesSync_one": "Game services: 1 change to sync", + "next.fixFirst": "{count} problems to fix first", + "next.fixFirst_one": "1 problem to fix first", + "next.warnings": "{count} warnings to look at", + "next.warnings_one": "1 warning to look at", + "next.healthOk": "No problems found", + "side.gAttention": "Needs attention", + "side.gProgress": "In progress", + "side.gLive": "Live", + "side.gDraft": "Not on a store yet", + "side.missing": "Folder missing", + "side.rejected": "Rejected · {what}", + "side.halted": "Halted · {what}", + "side.blockers": "{count} blockers", + "side.blockers_one": "1 blocker", + "side.rollout": "Rollout {share} · {what}", + "side.review": "In review · {what}", + "side.approved": "Approved · {what}", + "side.draft": "Draft · {what}", + "side.live": "Live · {version}", + "side.liveWarn": "Live · {version} · {count} warnings", + "side.liveWarn_one": "Live · {version} · 1 warning", + "side.storeOnly": "Store only · not read yet", + "side.notRead": "Not read yet", + "side.warnings": "{count} warnings", + "side.warnings_one": "1 warning", + "side.notOnStore": "No store version yet", + "side.ignored": "Ignored", + "side.studioFilter": "Show apps of a studio", + "side.allStudios": "All", + "games.keep": "In step", + "games.update": "Differs", + "games.create": "Missing", + "games.notOnStore": "Not on this store", + "games.notListed": "Not planned", + "games.notRead": "Not read", + "games.item": "Item", + "games.name": "Name", + "games.points": "Points", + "games.languages": "Languages", + "games.leaderboard": "Leaderboard", + "games.achievement": "Achievement", + "games.hidden": "hidden until earned", + "games.localeChanges": "Game Center languages: {list}", + "games.checkAgain": "Check again", + "games.apiOffTitle": "Play Games Services API is off.", + "games.apiOffText": "Turn on {api} in the service account's Google Cloud project, wait a minute, then check again.", + "games.itemsTitle": "Leaderboards and achievements", + "games.itemsSub": "One list in the config, mapped to both stores: each store's state against the config.", + "games.refresh": "Read again", + "games.noItems": "No leaderboards or achievements in the config yet (games.leaderboards, games.achievements).", + "games.notReadYet": "Reading game services…", + "games.images": "Images of leaderboards and achievements are added in the consoles; on Play the existing icon is kept on every sync.", + "games.localeMap": "Game Center locale map", + "games.localeMapSub": "Config language to the locale the live Game Center uses (games.localeMap).", + "games.mapSet": "Set", + "games.mapSuggested": "Suggested", + "games.mapHint": "Add to games.localeMap: {entries}", } as const; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 8901d14..e19a2e7 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -2118,4 +2118,106 @@ export const tr: Record = { 'cm.copyPrincipal': "Hizmet hesabı / anahtar", 'cm.copyProfile': "Profil adı", 'cm.openAccounts': "Stüdyolar ve hesapları aç", + "sec.release": "Yayın", + "sec.store": "Mağaza sayfası", + "sec.revenue": "Gelir", + "sec.players": "Oyuncular", + "sec.health": "Sağlık", + "sec.release.for": "Bir sürümü iki mağazada oyunculara ulaştır: build, TestFlight, inceleme ve dağıtım.", + "sec.store.for": "Mağazada insanların gördüğü: dile göre metinler, ekran görüntüleri ve uygulama bilgileri.", + "sec.revenue.for": "Uygulamanın sattıkları ve fiyatları: uygulama içi ürünler, abonelikler ve fiyatlar.", + "sec.players.for": "Oyuncuların söyledikleri ve oyunlarda kazanabilecekleri: yorumlar, Game Center, Play Games.", + "sec.health.for": "Uygulama sağlıklı ve hazır mı: çökmeler ve ANR, büyüme, önce düzeltilecek kontroller, diğer araçlar.", + "sec.views": "{section} görünümleri", + "tab.games": "Oyun servisleri", + "next.open": "{where} aç", + "next.rejected": "{version}, {store} tarafından reddedildi", + "next.halted": "{version}, {store} üzerinde durduruldu", + "next.blockers": "Göndermeden önce {count} engel", + "next.blockers_one": "Göndermeden önce 1 engel", + "next.rollout": "{version} dağıtımı {share} seviyesinde", + "next.approved": "{version} onaylandı: yayınla", + "next.submit": "{version} sürümünü incelemeye gönder", + "next.review": "{version} incelemede", + "next.managed": "{version} Yayınlama özeti'nde bekliyor (yönetilen yayınlama)", + "next.upload": "İlk build'i yükle", + "next.live": "Yayında: {version}", + "next.notRead": "Mağazalar henüz okunmadı", + "next.storeLive": "Mağazalardan okunan canlı mağaza sayfası", + "next.listingProblems": "{count} mağaza metni sorunu", + "next.listingProblems_one": "1 mağaza metni sorunu", + "next.unsaved": "{count} kaydedilmemiş düzenleme", + "next.unsaved_one": "1 kaydedilmemiş düzenleme", + "next.listingsUnread": "Mağaza metinleri henüz kontrol edilmedi", + "next.storeOk": "Mağaza sayfası yapılandırmayla uyumlu", + "next.catalogDiff": "{count} ürün mağazalar arasında farklı", + "next.catalogDiff_one": "1 ürün mağazalar arasında farklı", + "next.noProducts": "Henüz ürün yok: bir tane ekle", + "next.products": "{products} ürün · {subs} abonelik", + "next.reviews": "{count} yorum yanıtsız", + "next.reviews_one": "1 yorum yanıtsız", + "next.reviewsUnread": "Yorumlar henüz okunmadı", + "next.reviewsOk": "Tüm yorumlar yanıtlandı", + "next.gamesApiOff": "Play Games Services API kapalı", + "next.gamesBlockers": "{count} oyun servisi engeli", + "next.gamesBlockers_one": "1 oyun servisi engeli", + "next.gamesSync": "Oyun servisleri: eşitlenecek {count} değişiklik", + "next.gamesSync_one": "Oyun servisleri: eşitlenecek 1 değişiklik", + "next.fixFirst": "Önce düzeltilecek {count} sorun", + "next.fixFirst_one": "Önce düzeltilecek 1 sorun", + "next.warnings": "Bakılacak {count} uyarı", + "next.warnings_one": "Bakılacak 1 uyarı", + "next.healthOk": "Sorun bulunmadı", + "side.gAttention": "İlgi bekliyor", + "side.gProgress": "Sürüyor", + "side.gLive": "Yayında", + "side.gDraft": "Henüz mağazada değil", + "side.missing": "Klasör bulunamadı", + "side.rejected": "Reddedildi · {what}", + "side.halted": "Durduruldu · {what}", + "side.blockers": "{count} engel", + "side.blockers_one": "1 engel", + "side.rollout": "Dağıtım {share} · {what}", + "side.review": "İncelemede · {what}", + "side.approved": "Onaylandı · {what}", + "side.draft": "Taslak · {what}", + "side.live": "Yayında · {version}", + "side.liveWarn": "Yayında · {version} · {count} uyarı", + "side.liveWarn_one": "Yayında · {version} · 1 uyarı", + "side.storeOnly": "Yalnız mağaza · henüz okunmadı", + "side.notRead": "Henüz okunmadı", + "side.warnings": "{count} uyarı", + "side.warnings_one": "1 uyarı", + "side.notOnStore": "Henüz mağaza sürümü yok", + "side.ignored": "Yok sayıldı", + "side.studioFilter": "Bir stüdyonun uygulamalarını göster", + "side.allStudios": "Tümü", + "games.keep": "Uyumlu", + "games.update": "Farklı", + "games.create": "Eksik", + "games.notOnStore": "Bu mağazada değil", + "games.notListed": "Planlanmadı", + "games.notRead": "Okunmadı", + "games.item": "Öğe", + "games.name": "Ad", + "games.points": "Puan", + "games.languages": "Diller", + "games.leaderboard": "Skor tablosu", + "games.achievement": "Başarım", + "games.hidden": "kazanılana kadar gizli", + "games.localeChanges": "Game Center dilleri: {list}", + "games.checkAgain": "Yeniden kontrol et", + "games.apiOffTitle": "Play Games Services API kapalı.", + "games.apiOffText": "Hizmet hesabının Google Cloud projesinde {api} açın, bir dakika bekleyin, sonra yeniden kontrol edin.", + "games.itemsTitle": "Skor tabloları ve başarımlar", + "games.itemsSub": "Yapılandırmada tek liste, iki mağazaya eşlenir: her mağazanın yapılandırmaya göre durumu.", + "games.refresh": "Yeniden oku", + "games.noItems": "Yapılandırmada henüz skor tablosu veya başarım yok (games.leaderboards, games.achievements).", + "games.notReadYet": "Oyun servisleri okunuyor…", + "games.images": "Skor tablosu ve başarım görselleri konsollarda eklenir; Play'de mevcut simge her eşitlemede korunur.", + "games.localeMap": "Game Center dil eşlemesi", + "games.localeMapSub": "Yapılandırma dilinden canlı Game Center'ın kullandığı dile (games.localeMap).", + "games.mapSet": "Ayarlı", + "games.mapSuggested": "Önerilen", + "games.mapHint": "games.localeMap'e ekleyin: {entries}", }; diff --git a/apps/desktop/src/lib/model.ts b/apps/desktop/src/lib/model.ts index e1979c0..a104d02 100644 --- a/apps/desktop/src/lib/model.ts +++ b/apps/desktop/src/lib/model.ts @@ -2,7 +2,7 @@ // inbox stats, catalog parity matrix, the portfolio's "needs attention" list, and the command palette matcher. // "Unknown, never false": every derivation keeps "not read" apart from "none" / "ok". import { plainError } from '../../../../cli/lib/google-errors'; -import type { AppStatus, ProjectSummary } from '../api'; +import type { AppStatus, ProjectSummary, StoreRelease } from '../api'; import { t } from '../i18n'; import type { Tone } from '../../../../cli/lib/train'; @@ -138,7 +138,7 @@ export function parityMatrix(s: ProjectSummary | null, diff: CatalogDiff | null) // ---- Portfolio: needs attention ---- -export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' } +export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' | 'games' } export function attention(projectId: string, name: string, e: AppStatus | undefined, live: AppStatus | undefined, health?: HealthSnapshot | null): Attention[] { const out: Attention[] = []; @@ -203,3 +203,46 @@ export function hueOf(name: string): number { for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h % 360; } + +// ---- App kind and the sidebar ---- + +const PLAY_GAME_CATS = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing', 'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word']; +/** A game: profile game, a games section in the config, or a Play game category. */ +export function isGameSummary(s: ProjectSummary | null | undefined): boolean { + if (!s) return false; + return s.profile === 'game' || !!s.games || PLAY_GAME_CATS.includes(s.playRelease?.category ?? ''); +} + +export type SideGroup = 'attention' | 'progress' | 'live' | 'draft'; +export interface SideStatus { group: SideGroup; text: string; tone: Tone } + +/** One sidebar row's group and status line: the one thing worth knowing about the app (docs IA). */ +export function sideStatus(o: { att: Attention[]; all: StoreRelease[]; read: boolean; missing: boolean; storeOnly: boolean }): SideStatus { + const sn = (r: StoreRelease) => `${r.store === 'ios' ? 'iOS' : 'Play'} ${r.version ?? ''}`.trim(); + if (o.missing) return { group: 'attention', text: t('side.missing'), tone: 'bad' }; + const rej = o.all.find(r => r.phase === 'rejected'); + if (rej) return { group: 'attention', text: t('side.rejected', { what: sn(rej) }), tone: 'bad' }; + const halted = o.all.find(r => r.phase === 'halted'); + if (halted) return { group: 'attention', text: t('side.halted', { what: sn(halted) }), tone: 'bad' }; + const bad = o.att.filter(a => a.tone === 'bad').length; + if (bad) return { group: 'attention', text: t('side.blockers', { count: bad }), tone: 'bad' }; + const crash = o.att.find(a => a.tab === 'health'); + if (crash) return { group: 'attention', text: crash.text, tone: crash.tone }; + const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { group: 'progress', text: t('side.rollout', { share: `${Math.round((rolling.fraction ?? 0) * 100)} %`, what: sn(rolling) }), tone: 'info' }; + const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { group: 'progress', text: t('side.review', { what: sn(review) }), tone: 'info' }; + const approved = o.all.find(r => r.phase === 'approved'); + if (approved) return { group: 'progress', text: t('side.approved', { what: sn(approved) }), tone: 'info' }; + const live = o.all.filter(r => r.phase === 'live' || r.phase === 'released' || r.phase === 'rollout'); + const draft = o.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (draft && !live.length) return { group: 'progress', text: t('side.draft', { what: sn(draft) }), tone: 'neutral' }; + const warn = o.att.filter(a => a.tone === 'warn').length; + if (live.length) { + const vs = [...new Set(live.map(r => r.version).filter(Boolean))]; + return { group: 'live', text: warn ? t('side.liveWarn', { version: vs.join(' / '), count: warn }) : t('side.live', { version: vs.join(' / ') }), tone: warn ? 'warn' : 'ok' }; + } + if (!o.read) return { group: o.storeOnly ? 'live' : 'draft', text: o.storeOnly ? t('side.storeOnly') : t('side.notRead'), tone: 'neutral' }; + return { group: 'draft', text: warn ? t('side.warnings', { count: warn }) : t('side.notOnStore'), tone: warn ? 'warn' : 'neutral' }; +} +export const SIDE_GROUPS: SideGroup[] = ['attention', 'progress', 'live', 'draft']; diff --git a/apps/desktop/src/lib/sections.ts b/apps/desktop/src/lib/sections.ts new file mode 100644 index 0000000..5cad08e --- /dev/null +++ b/apps/desktop/src/lib/sections.ts @@ -0,0 +1,94 @@ +// The "what's next" line of each app section (docs IA): one sentence and the sub view it opens, from state that is +// already read (status, live releases, attention, cached reads). Pure, so tests pin the rules; a section never starts +// a read to fill it. +import type { CatalogParity, ListingsResult, StoreRelease } from '../api'; +import { t } from '../i18n'; +import type { AppSection, AppTab } from '../nav'; +import type { IconName } from '../ui/icons'; +import { type Attention, isGameSummary } from './model'; +import { share } from './format'; +import type { ProjectSummary } from '../api'; + +export type NextTone = 'bad' | 'warn' | 'ok' | 'info' | 'neutral'; +export interface SectionNext { text: string; tone: NextTone; tab: AppTab | null; icon: IconName } + +export interface GamesNext { changes: number; blockers: number; apiOff: boolean } +export interface SectionInput { + project: boolean; + att: Attention[]; + all: StoreRelease[]; + next: string | null; + summary: ProjectSummary | null; + catalog: CatalogParity | null; + /** Unsaved edits of the section's editors. */ + unsaved: number; + /** Cached reads (null: not read yet). */ + listings: ListingsResult | null; + reviews: { reply?: unknown }[] | null; + games: GamesNext | null; + /** Blockers of the last store readiness read (validate), null when never read. */ + readiness?: number | null; +} + +const storeName = (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Google Play'); + +/** Problems of a listings read: file problems plus required fields missing or over the limit. */ +export function listingProblems(l: ListingsResult | null): number | null { + if (!l) return null; + let n = l.problems.length; + for (const loc of l.locales) for (const s of [loc.play, loc.apple]) for (const f of s?.fields ?? []) if (f.state === 'missing' || f.state === 'over') n++; + return n; +} + +export function sectionNext(section: AppSection, i: SectionInput): SectionNext { + const bad = i.att.filter(a => a.tone === 'bad'); + if (section === 'release') { + const rej = i.all.find(r => r.phase === 'rejected' || r.phase === 'halted'); + if (rej) return { text: t(rej.phase === 'rejected' ? 'next.rejected' : 'next.halted', { version: rej.version ?? '', store: storeName(rej.store) }), tone: 'bad', tab: 'releases', icon: 'alertCircle' }; + const checks = bad.filter(a => a.tab === 'checks').length + (i.readiness ?? 0); + if (checks) return { text: t('next.blockers', { count: checks }), tone: 'bad', tab: 'checks', icon: 'alertCircle' }; + const rolling = i.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { text: t('next.rollout', { share: share(rolling.fraction ?? 0), version: rolling.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' }; + const approved = i.all.find(r => r.phase === 'approved'); + if (approved) return { text: t('next.approved', { version: approved.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' }; + const ready = i.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (ready) return { text: t('next.submit', { version: ready.version ?? '' }), tone: 'info', tab: 'releases', icon: 'send' }; + const review = i.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { text: review.managed ? t('next.managed', { version: review.version ?? '' }) : t('next.review', { version: review.version ?? '' }), tone: 'neutral', tab: 'releases', icon: 'clock' }; + if (i.project && !i.next && !i.all.length) return { text: t('next.upload'), tone: 'neutral', tab: 'releases', icon: 'upload' }; + const live = i.all.find(r => r.phase === 'live' || r.phase === 'released'); + return live ? { text: t('next.live', { version: live.version ?? '' }), tone: 'ok', tab: null, icon: 'check' } : { text: t('next.notRead'), tone: 'neutral', tab: null, icon: 'clock' }; + } + if (section === 'store') { + if (!i.project) return { text: t('next.storeLive'), tone: 'neutral', tab: null, icon: 'cloud' }; + const n = listingProblems(i.listings); + if (n) return { text: t('next.listingProblems', { count: n }), tone: 'warn', tab: 'listings', icon: 'alert' }; + if (i.unsaved) return { text: t('next.unsaved', { count: i.unsaved }), tone: 'warn', tab: null, icon: 'edit' }; + return n === null ? { text: t('next.listingsUnread'), tone: 'neutral', tab: 'listings', icon: 'text' } : { text: t('next.storeOk'), tone: 'ok', tab: null, icon: 'check' }; + } + if (section === 'revenue') { + const c = i.catalog; + if (c && c.checked === 'live' && !c.ok) return { text: t('next.catalogDiff', { count: c.missingIos.length + c.missingAndroid.length + c.onlyIos.length + c.onlyAndroid.length }), tone: 'warn', tab: 'catalog', icon: 'alert' }; + const p = i.summary?.products ?? 0, s = i.summary?.subscriptions ?? 0; + if (!p && !s) return { text: t('next.noProducts'), tone: 'neutral', tab: 'catalog', icon: 'plus' }; + return { text: t('next.products', { products: p, subs: s }), tone: 'ok', tab: null, icon: 'check' }; + } + if (section === 'players') { + const open = i.reviews ? i.reviews.filter(r => !r.reply).length : null; + if (open) return { text: t('next.reviews', { count: open }), tone: 'warn', tab: 'reviews', icon: 'message' }; + if (i.games?.apiOff) return { text: t('next.gamesApiOff'), tone: 'bad', tab: 'games', icon: 'alertCircle' }; + if (i.games?.blockers) return { text: t('next.gamesBlockers', { count: i.games.blockers }), tone: 'bad', tab: 'games', icon: 'alertCircle' }; + if (i.games?.changes) return { text: t('next.gamesSync', { count: i.games.changes }), tone: 'warn', tab: 'games', icon: 'upload' }; + return open === null ? { text: t('next.reviewsUnread'), tone: 'neutral', tab: 'reviews', icon: 'message' } : { text: t('next.reviewsOk'), tone: 'ok', tab: null, icon: 'check' }; + } + const crash = i.att.find(a => a.tab === 'health'); + if (crash) return { text: crash.text, tone: crash.tone === 'bad' ? 'bad' : 'warn', tab: 'health', icon: 'activity' }; + const problems = i.att.filter(a => a.tab === 'checks'); + const fix = problems.filter(a => a.tone === 'bad').length + (i.readiness ?? 0); + if (fix) return { text: t('next.fixFirst', { count: fix }), tone: 'bad', tab: 'checks', icon: 'shield' }; + if (problems.length) return { text: t('next.warnings', { count: problems.length }), tone: 'warn', tab: 'checks', icon: 'shield' }; + return { text: t('next.healthOk'), tone: 'ok', tab: null, icon: 'check' }; +} + +/** A game: profile game, a games section in the config, or a Play game category. */ +export const isGame = (s: ProjectSummary | null | undefined): boolean => isGameSummary(s); diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index a6429e1..b442265 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -4,31 +4,58 @@ import { createContext, useContext } from 'react'; import type { Key } from './i18n'; import type { IconName } from './ui/icons'; -export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks', 'tools'] as const; +export const APP_TABS = ['releases', 'testflight', 'listings', 'art', 'appinfo', 'catalog', 'prices', 'reviews', 'games', 'health', 'growth', 'checks', 'tools'] as const; export type AppTab = (typeof APP_TABS)[number]; -/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and - * store art are the live ones; Growth shows the Product Page Optimization tests only). */ -export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth']; -export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS); + +/** The five sections of an app, by job (docs IA): each holds sub views (the old tabs). The route keeps the sub id. */ +export const SECTIONS = ['release', 'store', 'revenue', 'players', 'health'] as const; +export type AppSection = (typeof SECTIONS)[number]; +export const SECTION_TABS: Record = { + release: ['releases', 'testflight'], store: ['listings', 'art', 'appinfo'], revenue: ['catalog', 'prices'], players: ['reviews', 'games'], health: ['health', 'growth', 'checks', 'tools'], +}; +export const sectionOf = (tab: AppTab): AppSection => SECTIONS.find(s => SECTION_TABS[s].includes(tab)) ?? 'release'; +export const SECTION_KEY: Record = { release: 'sec.release', store: 'sec.store', revenue: 'sec.revenue', players: 'sec.players', health: 'sec.health' }; +export const SECTION_ICON: Record = { release: 'rocket', store: 'image', revenue: 'coins', players: 'users', health: 'activity' }; + +/** Sub views of a store-only app: store reads only (catalog, prices, app info, checks and tools need the app folder; + * listings and store art are the live ones; Growth shows the Product Page Optimization tests only). */ +export const STORE_TABS: readonly AppTab[] = ['releases', 'testflight', 'listings', 'art', 'reviews', 'health', 'growth']; +/** The sub views an app has: the folder decides most; Game services only for games. */ +export const tabsFor = (hasFolder: boolean, game = false): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS).filter(x => x !== 'games' || (game && hasFolder)); +/** The sections an app has (a section with no sub view is left out: Revenue for a store-only app). */ +export const sectionsFor = (hasFolder: boolean, game = false): AppSection[] => { const tabs = tabsFor(hasFolder, game); return SECTIONS.filter(s => SECTION_TABS[s].some(x => tabs.includes(x))); }; +export const subsOf = (s: AppSection, hasFolder: boolean, game = false): AppTab[] => { const tabs = tabsFor(hasFolder, game); return SECTION_TABS[s].filter(x => tabs.includes(x)); }; export const TAB_KEY: Record = { releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews', - testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', + testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', games: 'tab.games', }; export const TAB_ICON: Record = { releases: 'rocket', catalog: 'tag', prices: 'coins', listings: 'text', art: 'grid', health: 'activity', reviews: 'message', testflight: 'plane', growth: 'chart', - appinfo: 'clipboard', checks: 'shield', tools: 'sliders', + appinfo: 'clipboard', checks: 'shield', tools: 'sliders', games: 'sparkle', }; + +/** A place in an app from a link: an old tab id ("prices"), a section ("revenue", its first sub view) or + * "section/sub" ("store/art"). Unknown: Release. */ +export function placeOf(v: string | undefined | null): AppTab { + if (!v) return 'releases'; + const [a, b] = v.split('/'); + if (b && (SECTIONS as readonly string[]).includes(a!) && SECTION_TABS[a as AppSection].includes(b as AppTab)) return b as AppTab; + if (APP_TABS.includes(a as AppTab)) return a as AppTab; + if ((SECTIONS as readonly string[]).includes(a!)) return SECTION_TABS[a as AppSection][0]!; + return 'releases'; +} + export const WORKSPACE = ['apps', 'accounts', 'ignored', 'dependencies', 'settings'] as const; export type WorkspaceScreen = (typeof WORKSPACE)[number]; export type Route = { screen: 'portfolio' } | { screen: 'app'; appId: string; tab: AppTab } | { screen: WorkspaceScreen; section?: string }; -/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health tab; - * "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ +/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health section; + * "app:store/art" = a sub view; "app:prices" = an old tab id; "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ export function parseStart(v: string, apps: string[]): Route | null { const [screen, tab, n] = v.split(':'); const app = apps[Math.max(0, (Number(n) || 1) - 1)]; - if (screen === 'app' && app) return { screen: 'app', appId: app, tab: APP_TABS.includes(tab as AppTab) ? tab as AppTab : 'releases' }; + if (screen === 'app' && app) return { screen: 'app', appId: app, tab: placeOf(tab) }; if (screen === 'portfolio') return { screen: 'portfolio' }; if (!(WORKSPACE as readonly string[]).includes(screen ?? '')) return null; return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen }; @@ -41,7 +68,7 @@ export function parseRouteJson(v: string): Route | null { if (!o || typeof o !== 'object') return null; const r = o as Record; if (r.screen === 'portfolio') return { screen: 'portfolio' }; - if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: APP_TABS.includes(r.tab as AppTab) ? r.tab as AppTab : 'releases' } : null; + if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: placeOf(typeof r.tab === 'string' ? r.tab : null) } : null; if (typeof r.screen === 'string' && (WORKSPACE as readonly string[]).includes(r.screen)) return typeof r.section === 'string' ? { screen: r.screen as WorkspaceScreen, section: r.section } : { screen: r.screen as WorkspaceScreen }; return null; } diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 1736d8d..1a8424d 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -1,16 +1,20 @@ -// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no +// One app across both stores: header (icon, studio, ids, store state, pairing), then five sections by job (Release, +// Store page, Revenue, Players, Health; docs IA), each with a header (what it is for, the one next step) and its sub views. A store-only app (no // app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and // "Start an app folder here" to make it a full app. import { useContextMenu, useMenuButton } from '../ui/ContextMenu'; import { appMenu, playUrlFor } from '../ui/app-menu'; import { useShowIgnored } from '../lib/show-ignored'; import { useState } from 'react'; +import { GamesTab } from './tabs/Games'; +import { SectionHead } from './SectionHead'; import { api, errorText } from '../api'; import { type Key, t } from '../i18n'; import { pairCandidates, type UnifiedApp, withLink, withPair, withStudio } from '../lib/identity'; import { attention, storeNow } from '../lib/model'; import { Hero, nextStep, Tabs } from '../ui/ds'; -import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav'; +import { isGameSummary } from '../lib/model'; +import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, SECTION_TABS, sectionOf, sectionsFor, subsOf, useNav } from '../nav'; import { useData } from '../store'; import { Icon } from '../ui/icons'; import { ErrorBoundary } from '../ui/ErrorBoundary'; @@ -107,11 +111,15 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { const ios = e?.app?.platforms.ios ?? (app.ios ? { bundleId: app.ios.app.bundleId, ascAppId: app.ios.app.id } : null); const android = e?.app?.platforms.android ?? (app.android ? { packageName: app.android.app.id } : null); const name = (app.project && e?.app?.name) || app.name; - const tabs = tabsFor(!!app.project); + const game = isGameSummary(e?.summary); + const sections = sectionsFor(!!app.project, game); + const section = sectionOf(tab); const ed = useEdits(); useUnsavedGuard(); const studio = d.studios.find(s => s.id === app.studio) ?? null; const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x }); + const setSection = (x: AppSection) => setTab(subsOf(x, !!app.project, game)[0] ?? SECTION_TABS[x][0]!); + const editCount = (x: AppTab) => (EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0); const all = l?.release.all ?? []; const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null; const att = attention(app.key, app.name, e, l); @@ -137,19 +145,20 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {android && } } all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null} - compact={tab !== 'releases'} + compact={section !== 'release' || tab !== 'releases'} cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} /> {!app.project && } {app.project?.storeboundPin && {t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}} {app.project?.missing ? : <>
- { - const n = EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0; - return { id: x, label: t(TAB_KEY[x]), icon: TAB_ICON[x], count: att.filter(a => a.tab === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; + { + const n = subsOf(x, !!app.project, game).reduce((a, s2) => a + editCount(s2), 0); + return { id: x, label: t(SECTION_KEY[x]), icon: SECTION_ICON[x], hint: String(i + 1), count: att.filter(a => a.tab && sectionOf(a.tab) === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; })} />
-
+ a + editCount(s2), 0)} editCount={editCount} onTab={setTab} /> +
{tab === 'releases' && } {tab === 'catalog' && app.project && } @@ -160,6 +169,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'art' && !app.project && } {tab === 'health' && } {tab === 'reviews' && } + {tab === 'games' && app.project && } {tab === 'testflight' && } {tab === 'growth' && } {tab === 'appinfo' && app.project && } diff --git a/apps/desktop/src/screens/SectionHead.tsx b/apps/desktop/src/screens/SectionHead.tsx new file mode 100644 index 0000000..75f04c1 --- /dev/null +++ b/apps/desktop/src/screens/SectionHead.tsx @@ -0,0 +1,60 @@ +// The header of one app section (docs IA): the section's name, one line on what it is for, the single next step +// derived from state that is already read (lib/sections.ts), and the sub views as a segmented control (only when the +// section has more than one). +import type { ListingsResult, StoreRelease } from '../api'; +import { t } from '../i18n'; +import type { UnifiedApp } from '../lib/identity'; +import type { Attention } from '../lib/model'; +import { sectionNext } from '../lib/sections'; +import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, TAB_ICON, TAB_KEY } from '../nav'; +import { useCachedRead, useData } from '../store'; +import type { GameServicesStatus } from '../ui/GameServicesCard'; +import { Icon } from '../ui/icons'; + +const PURPOSE: Record = { + release: 'sec.release.for', store: 'sec.store.for', revenue: 'sec.revenue.for', players: 'sec.players.for', health: 'sec.health.for', +}; + +export function SectionHead({ app, section, tab, subs, att, all, next, unsaved, editCount, onTab }: { + app: UnifiedApp; section: AppSection; tab: AppTab; subs: AppTab[]; att: Attention[]; all: StoreRelease[]; next: string | null; unsaved: number; + editCount: (t: AppTab) => number; onTab: (t: AppTab) => void; +}) { + const d = useData(); + const e = d.statusFor(app); + const pid = app.project && !app.project.missing ? app.project.id : null; + // cached reads only: the header never starts a store call + const listings = useCachedRead(section === 'store' ? pid : null, 'project.listings'); + const reviews = useCachedRead<{ reply?: unknown }[]>(section === 'players' ? app.target : null, 'reviews.list', { days: 30 }); + const games = useCachedRead(section === 'players' && subs.includes('games') ? pid : null, 'games.status'); + const validate = useCachedRead<{ findings?: { level: string }[] }>(section === 'health' || section === 'release' ? pid : null, 'validate'); + const n = sectionNext(section, { + project: !!app.project, att, all, next, summary: e?.summary ?? null, catalog: (d.liveFor(app) ?? e)?.catalog ?? null, unsaved, listings, + reviews: Array.isArray(reviews) ? reviews : null, readiness: validate?.findings ? validate.findings.filter(f => f.level === 'blocker').length : null, + games: games ? { changes: (games.apple?.changes.length ?? 0) + (games.play?.changes.length ?? 0), blockers: games.findings.filter(f => f.level === 'error').length, apiOff: !!games.playApiDisabled } : null, + }); + return ( +
+
+

{t(SECTION_KEY[section])}

+

{t(PURPOSE[section])}

+
+
+ + {n.text} + {n.tab && n.tab !== tab && } +
+ {subs.length > 1 && ( +
+ {subs.map(x => { + const c = editCount(x); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/screens/tabs/AppInfo.tsx b/apps/desktop/src/screens/tabs/AppInfo.tsx index 7e6b1c3..8666d46 100644 --- a/apps/desktop/src/screens/tabs/AppInfo.tsx +++ b/apps/desktop/src/screens/tabs/AppInfo.tsx @@ -19,7 +19,6 @@ import { Bar, Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; import { EmptyState, Section } from '../../ui/ds'; import '../../styles/appinfo.css'; import { WriteButton } from '../../ui/WriteButton'; -import { GameServicesCard } from '../../ui/GameServicesCard'; import { ascUrl } from '../AppView'; const AREA: Record = { dataSafety: 'ai.aDataSafety', appPrivacy: 'ai.aAppPrivacy', appInfo: 'ai.aAppInfo', encryption: 'ai.aEncryption', review: 'ai.aReview' }; @@ -258,7 +257,6 @@ export function AppInfoTab({ project }: { project: Project }) {
)} -

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

diff --git a/apps/desktop/src/screens/tabs/Games.tsx b/apps/desktop/src/screens/tabs/Games.tsx new file mode 100644 index 0000000..26950fd --- /dev/null +++ b/apps/desktop/src/screens/tabs/Games.tsx @@ -0,0 +1,90 @@ +// Players > Game services: Game Center (App Store) and Play Games Services (Google Play) for a game, from +// `games status --json` (cli/games-status.ts). The two store panels (switches, ids, counts, sync writes with dry run + +// confirm) are the GameServicesCard; below it one row per leaderboard / achievement with its languages and each +// store's state against the config, the Game Center locale map with suggestions, and the Play API-disabled state with +// the enable link and the console plan. +import { Fragment } from 'react'; +import type { Project } from '../../api'; +import { t } from '../../i18n'; +import { useRead } from '../../store'; +import { ConsolePlanButton } from '../../ui/ConsolePlan'; +import { EmptyState, Section } from '../../ui/ds'; +import { type GameAction, type GameItemRow, GameServicesCard, type GameServicesStatus } from '../../ui/GameServicesCard'; +import { Icon, StoreGlyph } from '../../ui/icons'; +import { Chip, Notice } from '../../ui/kit'; +import { OpenLink } from '../../ui/OpenLink'; + +const ACTION_TONE: Record = { keep: 'ok', update: 'warn', create: 'info' }; +const ACTION_KEY = { keep: 'games.keep', update: 'games.update', create: 'games.create' } as const; + +function StoreState({ on, plan, read }: { on: boolean; plan: { action: GameAction; changed: string[] } | null; read: boolean }) { + if (!on) return {t('games.notOnStore')}; + if (!plan) return {read ? t('games.notListed') : t('games.notRead')}; + return {t(ACTION_KEY[plan.action])}; +} + +function ItemsTable({ rows, appleRead, playRead }: { rows: GameItemRow[]; appleRead: boolean; playRead: boolean }) { + return ( +
+ + + + + + + + {rows.map(r => { + const locs = r.apple?.locales.filter(l => l.action !== 'keep') ?? []; + return ( + + + + + + + + + ); + })} + +
{t('games.item')}{t('games.name')}{t('games.points')}{t('games.languages')}{t('gs.gameCenter')}{t('gs.playGames')}
{r.id} +
{t(r.kind === 'leaderboard' ? 'games.leaderboard' : 'games.achievement')}{r.hidden ? ` · ${t('games.hidden')}` : ''}
{r.name}{r.points ?? '–'}{r.locales.join(', ')}{locs.length > 0 &&
{t('games.localeChanges', { list: locs.map(l => `${l.locale} ${t(ACTION_KEY[l.action])}`).join(', ') })}
}
+
+ ); +} + +export function GamesTab({ project }: { project: Project }) { + const r = useRead(project.id, 'games.status', { network: true, title: t('gs.title') }); + const s = r.data; + const rows = s?.items ?? []; + const appleRead = !!s?.apple && !s.apple.error, playRead = !!s?.play && !s.play.error; + const map = s?.localeMap; + return ( + <> + {s?.playApiDisabled && ( + + + +
}> + {t('games.apiOffTitle')} {t('games.apiOffText', { api: s.playApiDisabled.title })} + + )} + +
r.refresh()} disabled={r.loading} aria-busy={r.loading}>{t('games.refresh')}}> + {rows.length ? + : {s ? t('games.noItems') : t('games.notReadYet')}} +

{t('games.images')}

+
+ {map && (Object.keys(map.configured).length > 0 || map.suggested.length > 0) && ( +
+
+ {Object.entries(map.configured).map(([k, v]) =>
{k}
{v} {t('games.mapSet')}
)} + {map.suggested.map(x =>
{x.id}
{x.from} → {x.to} {t('games.mapSuggested')}
)} +
+ {map.suggested.length > 0 &&

{t('games.mapHint', { entries: map.suggested.map(x => `${x.id}: '${x.to}'`).join(', ') })}

} +
+ )} + + ); +} diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index 877b9ca..cfe6af2 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -750,3 +750,21 @@ export function useRead(target: string, method: string, o: { network: boolean return { ...s, loading: !o.skip && (s.loading || st.busy), justNow: st.justNow, pausedUntil: st.pausedUntil, refresh }; } + +/** The cached value of one read (and every later result of it), without ever starting a read: for summaries such as a + * section's "what's next" line that must not cost a store call. Null until the read ran once. */ +export function useCachedRead(target: string | null, method: string, args: Record = {}): T | null { + const d = useData(); + const argsKey = JSON.stringify(args); + const [v, set] = useState(null); + useEffect(() => { + set(null); + if (!target) return; + let alive = true; + const a = JSON.parse(argsKey) as Record; + api.cached(method, target, a).then(c => { if (alive && c && c.value.data !== undefined && usable(method, c.value)) set(c.value.data as T); }, () => {}); + const un = d.onRead(readKey(target, method, a), e => { if (e.kind === 'done' && usable(method, e.result)) set(e.result.data as T); }); + return () => { alive = false; un(); }; + }, [target, method, argsKey]); // eslint-disable-line react-hooks/exhaustive-deps + return v; +} diff --git a/apps/desktop/src/ui/GameServicesCard.tsx b/apps/desktop/src/ui/GameServicesCard.tsx index 3aa77cd..50ae13f 100644 --- a/apps/desktop/src/ui/GameServicesCard.tsx +++ b/apps/desktop/src/ui/GameServicesCard.tsx @@ -4,7 +4,7 @@ // (dry run -> native dialog -> apply) of `apple game-center` and `play games`; the PGS setup is a console plan. import type { Project } from '../api'; import { t } from '../i18n'; -import { useRead, useWrite } from '../store'; +import { type ReadState, useRead, useWrite } from '../store'; import { ConsolePlanButton } from './ConsolePlan'; import { StoreGlyph } from './icons'; import { Card, Chip, ErrorNotice, Skeleton } from './kit'; @@ -18,13 +18,23 @@ export interface GameServicesStatus { play: null | { appId: string | null; resource: string | null; config: Counts; changes: string[]; error?: string } & Counts; findings: Finding[]; links: { label: string; url: string }[]; + items?: GameItemRow[]; + localeMap?: { configured: Record; suggested: { id: string; from: string; to: string }[] }; + playApiDisabled?: { service: string; title: string; url: string } | null; +} +export type GameAction = 'create' | 'update' | 'keep'; +export interface GameItemRow { + kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean; ios: boolean; android: boolean; + apple: { action: GameAction; changed: string[]; locales: { locale: string; action: GameAction }[] } | null; + play: { action: GameAction; changed: string[] } | null; } const tone = (l: Finding['level']) => (l === 'error' ? 'bad' : l === 'warn' ? 'warn' : 'ok'); const yesNo = (v: boolean | null | undefined) => (v === null || v === undefined ? {t('gs.unknown')} : {v ? t('gs.yes') : t('gs.no')}); -export function GameServicesCard({ project }: { project: Project }) { - const r = useRead(project.id, 'games.status', { network: true, title: t('gs.title') }); +export function GameServicesCard({ project, read }: { project: Project; read?: ReadState }) { + const own = useRead(project.id, 'games.status', { network: true, title: t('gs.title'), skip: !!read }); + const r = read ?? own; const w = useWrite(project.id); if (!r.data) return r.error ? : ; const s = r.data; diff --git a/apps/desktop/src/ui/app-menu.ts b/apps/desktop/src/ui/app-menu.ts index d82ba47..144e0aa 100644 --- a/apps/desktop/src/ui/app-menu.ts +++ b/apps/desktop/src/ui/app-menu.ts @@ -5,7 +5,8 @@ import { t } from '../i18n'; import { copyText } from '../lib/crash'; import { withIgnored, withSideIgnored, withUnpair, type UnifiedApp } from '../lib/identity'; import { setShowIgnored } from '../lib/show-ignored'; -import { type AppTab, type Nav, TAB_ICON, TAB_KEY, tabsFor } from '../nav'; +import { type AppTab, type Nav, SECTION_ICON, SECTION_KEY, sectionsFor, subsOf, TAB_ICON, TAB_KEY } from '../nav'; +import { isGameSummary } from '../lib/model'; import type { Data } from '../store'; import type { MenuInput, MenuItem } from './menu-logic'; import { pickConsoleId, playAppUrl } from '../../../../cli/lib/console-links'; @@ -36,7 +37,12 @@ export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored: const storeName = (k: 'ios' | 'android') => (k === 'ios' ? 'App Store' : 'Google Play'); return [ { label: t('cm.open'), icon: 'arrowRight', onSelect: () => go('releases') }, - { label: t('cm.openTab'), icon: 'grid', submenu: tabsFor(!!p).map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) }, + { label: t('cm.openTab'), icon: 'grid', submenu: sectionsFor(!!p, isGameSummary(d.statusFor(app)?.summary)).map((sec, i) => { + const subs = subsOf(sec, !!p, isGameSummary(d.statusFor(app)?.summary)); + return subs.length > 1 + ? { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), submenu: subs.map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) } + : { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), onSelect: () => go(subs[0]!) }; + }) }, 'sep', (ios || android) && { label: t('cm.openConsole'), icon: 'external', submenu: [ ios && { label: 'App Store Connect', icon: 'external', onSelect: () => openUrl(d, ASC_APP(ios.id)) }, diff --git a/apps/desktop/src/ui/ds.tsx b/apps/desktop/src/ui/ds.tsx index b38aa8b..6460ba2 100644 --- a/apps/desktop/src/ui/ds.tsx +++ b/apps/desktop/src/ui/ds.tsx @@ -275,7 +275,7 @@ export function Toolbar({ label, children }: { label: string; children: ReactNod return
{children}
; } -export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string } +export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string; /** A keyboard hint shown in the tab ("1"). */ hint?: string } /** Pill tabs with icons and counts; arrows / Home / End move between them (roving tabindex). One row always: tabs that * do not fit go into a "More" menu at the end, the active tab always stays in the row. */ @@ -323,11 +323,12 @@ export function Tabs({ items, value, onChange, label, panelId return (
{items.map((x, i) => ( - ))} {hidden.length > 0 && ( diff --git a/apps/desktop/tests/sections.test.ts b/apps/desktop/tests/sections.test.ts new file mode 100644 index 0000000..e5a6494 --- /dev/null +++ b/apps/desktop/tests/sections.test.ts @@ -0,0 +1,79 @@ +// App sections (docs IA): old tab ids and section links resolve to one place, the sections an app has, the "what's +// next" line of each section and the sidebar's status line. +import { describe, expect, test } from 'bun:test'; +import type { StoreRelease } from '../src/api'; +import type { Attention } from '../src/lib/model'; +import { isGameSummary, sideStatus } from '../src/lib/model'; +import { listingProblems, type SectionInput, sectionNext } from '../src/lib/sections'; +import { APP_TABS, parseRouteJson, parseStart, placeOf, SECTION_TABS, SECTIONS, sectionOf, sectionsFor, subsOf } from '../src/nav'; + +const rel = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase'], o: Partial = {}): StoreRelease => + ({ store, version, phase, fraction: null, track: null, ...o }) as StoreRelease; +const base: SectionInput = { project: true, att: [], all: [], next: null, summary: null, catalog: null, unsaved: 0, listings: null, reviews: null, games: null }; +const att = (tone: Attention['tone'], tab: Attention['tab'], text = 'x'): Attention => ({ app: 'A', projectId: 'a', tone, text, tab }); + +describe('places', () => { + test('every old tab id is in exactly one section', () => { + for (const tab of APP_TABS) expect(SECTIONS.filter(s => SECTION_TABS[s].includes(tab))).toHaveLength(1); + }); + test('old tab ids, sections and section/sub links', () => { + expect(placeOf('prices')).toBe('prices'); + expect(placeOf('revenue')).toBe('catalog'); + expect(placeOf('store/art')).toBe('art'); + expect(placeOf('store/prices')).toBe('listings'); + expect(placeOf('health')).toBe('health'); + expect(placeOf('nope')).toBe('releases'); + expect(parseStart('app:players/games:2', ['a', 'b'])).toEqual({ screen: 'app', appId: 'b', tab: 'games' }); + expect(parseStart('app:release', ['a'])).toEqual({ screen: 'app', appId: 'a', tab: 'releases' }); + expect(parseRouteJson('{"screen":"app","appId":"k","tab":"store/appinfo"}')).toEqual({ screen: 'app', appId: 'k', tab: 'appinfo' }); + expect(sectionOf('testflight')).toBe('release'); + }); + test('store-only apps have no Revenue; Game services only for games with a folder', () => { + expect(sectionsFor(false)).toEqual(['release', 'store', 'players', 'health']); + expect(subsOf('players', true, false)).toEqual(['reviews']); + expect(subsOf('players', true, true)).toEqual(['reviews', 'games']); + expect(subsOf('players', false, true)).toEqual(['reviews']); + expect(subsOf('store', false)).toEqual(['listings', 'art']); + }); + test('a game: profile, games config or a Play game category', () => { + expect(isGameSummary({ profile: 'app' } as never)).toBe(false); + expect(isGameSummary({ profile: 'game' } as never)).toBe(true); + expect(isGameSummary({ profile: 'app', games: { gameCenter: true, playGames: false, leaderboards: 0, achievements: 0 } } as never)).toBe(true); + expect(isGameSummary({ profile: 'app', playRelease: { managedPublishing: null, countries: null, category: 'Puzzle' } } as never)).toBe(true); + }); +}); + +describe('next step per section', () => { + test('release: rejected first, then blockers, rollout, review (managed), upload, live', () => { + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'rejected')], att: [att('bad', 'checks')] })).toMatchObject({ tone: 'bad', tab: 'releases' }); + expect(sectionNext('release', { ...base, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ text: '2 blockers before submit', tab: 'checks' }); + expect(sectionNext('release', { ...base, all: [rel('android', '3.0', 'rollout', { fraction: 0.2 })] }).text).toContain('20'); + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'review', { managed: true })] }).text).toContain('Publishing overview'); + expect(sectionNext('release', base)).toMatchObject({ tab: 'releases', text: 'Upload a first build' }); + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'live')] })).toMatchObject({ tone: 'ok', tab: null }); + }); + test('store, revenue, players, health', () => { + const listings = { files: { play: null, apple: null }, problems: [], locales: [{ lang: 'en', rtl: false, screenshots: null, complete: 0.5, apple: null, play: { code: 'en-US', fields: [{ field: 'title', length: 0, limit: 30, required: true, state: 'missing' }] } }] } as never; + expect(listingProblems(listings)).toBe(1); + expect(sectionNext('store', { ...base, listings })).toMatchObject({ text: '1 listing problem', tab: 'listings' }); + expect(sectionNext('revenue', base).tab).toBe('catalog'); + expect(sectionNext('revenue', { ...base, catalog: { checked: 'live', ok: false, products: 2, subscriptions: 0, onlyIos: ['a'], onlyAndroid: [], missingIos: [], missingAndroid: ['b'] } })).toMatchObject({ tone: 'warn', text: '2 products differ between the stores' }); + expect(sectionNext('players', { ...base, reviews: [{}, { reply: { text: 'hi' } }, {}] })).toMatchObject({ text: '2 reviews without a reply', tab: 'reviews' }); + expect(sectionNext('players', { ...base, reviews: [], games: { changes: 0, blockers: 0, apiOff: true } })).toMatchObject({ tone: 'bad', tab: 'games' }); + expect(sectionNext('players', { ...base, reviews: [] }).tone).toBe('ok'); + expect(sectionNext('health', { ...base, att: [att('bad', 'health', 'Crash rate over the line')] })).toMatchObject({ text: 'Crash rate over the line', tab: 'health' }); + expect(sectionNext('health', { ...base, att: [att('bad', 'checks')] })).toMatchObject({ text: '1 problem to fix first', tab: 'checks' }); + }); +}); + +describe('sidebar status line', () => { + const o = { att: [] as Attention[], all: [] as StoreRelease[], read: true, missing: false, storeOnly: false }; + test('groups and lines', () => { + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'rejected')] })).toEqual({ group: 'attention', text: 'Rejected · iOS 2.1', tone: 'bad' }); + expect(sideStatus({ ...o, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ group: 'attention', text: '2 blockers' }); + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'review'), rel('android', '2.0', 'live')] })).toMatchObject({ group: 'progress', text: 'In review · iOS 2.1' }); + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'live'), rel('android', '2.1', 'live')] })).toEqual({ group: 'live', text: 'Live · 2.1', tone: 'ok' }); + expect(sideStatus({ ...o, read: false })).toMatchObject({ group: 'draft', text: 'Not read yet' }); + expect(sideStatus({ ...o, missing: true })).toMatchObject({ group: 'attention', text: 'Folder missing' }); + }); +}); diff --git a/cli/games-status.ts b/cli/games-status.ts index 5a06489..814732c 100644 --- a/cli/games-status.ts +++ b/cli/games-status.ts @@ -7,7 +7,8 @@ import { ascClient } from './apple/asc'; import { readProjectEntitlements } from './apple/signing'; import type { Loaded } from './lib/config'; import { consoleUrl } from './lib/console-links'; -import { type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, planGameCenter, planPlayGames, pgsFindings, readPgsProject, usesPlayGames } from './lib/game-services'; +import { type Action, type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, type ItemPlan, liveGcLocales, onStore, planGameCenter, planPlayGames, pgsFindings, readPgsProject, suggestLocaleMap, usesPlayGames } from './lib/game-services'; +import { parseGoogleError } from './lib/google-errors'; import type { Finding as VFinding } from './lib/validate-rules'; import { gamesConfigClient, playDefaultLocale, readPlayGames } from './play/games'; @@ -18,6 +19,33 @@ export interface GameServicesStatus { config: { leaderboards: number; achievements: number }; changes: string[]; error?: string }; findings: (Finding & { store: 'apple' | 'play' })[]; links: { label: string; url: string }[]; + /** One row per configured leaderboard / achievement: its texts and where each store stands against the config + * (create / update / keep; null = not on that store or that store was not read). */ + items?: GameItemRow[]; + /** Game Center locale map: the configured entries and the suggestions from the live locales. */ + localeMap?: { configured: Record; suggested: { id: string; from: string; to: string }[] }; + /** Play: the Games Configuration API is off in the service account's Cloud project. */ + playApiDisabled?: { service: string; title: string; url: string } | null; +} + +export interface GameItemRow { + kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean; + ios: boolean; android: boolean; + apple: { action: Action; changed: string[]; locales: { locale: string; action: Action }[] } | null; + play: { action: Action; changed: string[] } | null; +} + +/** The per-item rows of the status (pure; plans are null when that store was not read). */ +export function gameItemRows(g: NonNullable, applePlan: ItemPlan[] | null, playPlan: ItemPlan[] | null): GameItemRow[] { + const find = (plan: ItemPlan[] | null, kind: ItemPlan['kind'], id: string) => plan?.find(p => p.kind === kind && p.id === id) ?? null; + const row = (kind: ItemPlan['kind'], x: { id: string; locales: Record; platforms?: ('ios' | 'android')[] }, extra: Partial): GameItemRow => { + const a = find(applePlan, kind, x.id), p = find(playPlan, kind, x.id); + return { kind, id: x.id, name: Object.values(x.locales)[0]?.name ?? x.id, locales: Object.keys(x.locales), ...extra, + ios: onStore(x, 'apple'), android: onStore(x, 'play'), + apple: a ? { action: a.action, changed: a.changed, locales: a.locales.map(l => ({ locale: l.locale, action: l.action })) } : null, + play: p ? { action: p.action, changed: p.changed } : null }; + }; + return [...(g.leaderboards ?? []).map(l => row('leaderboard', l, {})), ...(g.achievements ?? []).map(a => row('achievement', a, { points: a.points, hidden: !!a.hidden }))]; } /** Offline findings (check, scan-like): entitlement, APP_ID resource, declarations reminders. */ @@ -37,7 +65,8 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p const { config, root } = loaded; const g = config.games ?? {}; const findings = localGameFindings(loaded); - const out: GameServicesStatus = { apple: null, play: null, findings, links: [] }; + const out: GameServicesStatus = { apple: null, play: null, findings, links: [], localeMap: { configured: { ...(g.localeMap ?? {}) }, suggested: [] }, playApiDisabled: null }; + let applePlan: ItemPlan[] | null = null, playPlan: ItemPlan[] | null = null; const count = (store: 'apple' | 'play') => ({ leaderboards: (g.leaderboards ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length, achievements: (g.achievements ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length }); const gcUse = config.apple ? gameCenterInUse(config, root) : { inUse: false, why: null }; @@ -46,8 +75,10 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p try { const c = await ascClient(root, config.apple.bundleId, config.apple.appId); const s = await readGameCenter(c, config.apple.bundleId); + applePlan = planGameCenter(g, config.languages, s, g.localeMap); + out.localeMap!.suggested = suggestLocaleMap(config.languages, liveGcLocales(s), g.localeMap ?? {}); out.apple = { capability: s.capability, enabled: !!s.detail, version: s.version?.versionString ?? null, versionEnabled: s.version ? s.version.enabled : null, - leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s, g.localeMap)), why: gcUse.why ?? undefined }; + leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(applePlan), why: gcUse.why ?? undefined }; } catch (e) { out.apple = { capability: null, enabled: false, version: null, versionEnabled: null, leaderboards: 0, achievements: 0, config: count('apple'), changes: [], error: (e as Error).message.slice(0, 300) }; } } if (config.play && usesPlayGames(config) && o.play !== false) { @@ -58,9 +89,14 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p if (!appId) out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: 'no games project id yet (storebound console plan play-games-services)' }; else try { const have = await readPlayGames(gamesConfigClient(root), appId); - out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(planPlayGames(g, config.languages, playDefaultLocale(config), have)) }; - } catch (e) { out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; } + playPlan = planPlayGames(g, config.languages, playDefaultLocale(config), have); + out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(playPlan) }; + } catch (e) { + const f = parseGoogleError((e as Error).message); + if (f?.kind === 'api-disabled') out.playApiDisabled = { service: f.service, title: f.serviceTitle, url: f.activationUrl }; + out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; } } + out.items = gameItemRows(g, applePlan, playPlan); return out; } diff --git a/cli/inspect.ts b/cli/inspect.ts index 4c2e7d5..e3313d9 100644 --- a/cli/inspect.ts +++ b/cli/inspect.ts @@ -44,6 +44,7 @@ export function summarize(ctx: Ctx): ProjectSummary { variants: Object.keys(config.shots?.variants ?? {}), playReports: !!config.play?.reportsBucket, playRelease: config.play ? { managedPublishing: config.play.managedPublishing ?? null, countries: config.play.countries?.length ?? null, category: playCategoryOf(config)?.category ?? null } : null, + games: config.games ? { gameCenter: config.games.gameCenter === true, playGames: !!config.games.playGames, leaderboards: config.games.leaderboards?.length ?? 0, achievements: config.games.achievements?.length ?? 0 } : null, }; } diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 58a2582..1a174d5 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -148,6 +148,8 @@ export interface ProjectSummary { /** Play production settings from the config: play.managedPublishing (null: not set, Play's default off), the number * of play.countries (null: all countries / regions) and the App category (play.category or the App Store match). */ playRelease?: { managedPublishing: boolean | null; countries: number | null; category: string | null } | null; + /** The games section of the config (Game Center / Play Games Services and the item counts), null without one. */ + games?: { gameCenter: boolean; playGames: boolean; leaderboards: number; achievements: number } | null; } // ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ---- diff --git a/tests/game-services.test.ts b/tests/game-services.test.ts index 636bcd8..8554834 100644 --- a/tests/game-services.test.ts +++ b/tests/game-services.test.ts @@ -8,7 +8,7 @@ import { capabilitySpecs, planCapabilities } from '../cli/lib/signing'; import { gamesPrivacyFindings, gcFindings, gcLocaleMap, liveGcLocales, suggestLocaleMap, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services'; import { consolePlan } from '../cli/lib/console-plan'; import { verifyText } from '../cli/lib/console-verify'; -import { gameServicesFindings, type GameServicesStatus } from '../cli/games-status'; +import { gameItemRows, gameServicesFindings, type GameServicesStatus } from '../cli/games-status'; import { run as gameCenter } from '../cli/apple/game-center'; import { run as playGames } from '../cli/play/games'; import { run as appleSubmit } from '../cli/apple/submit'; @@ -298,3 +298,15 @@ describe('apple submit with a Game Center build (live 409 STATE_ERROR.BUILD_INDI expect(s.asc.all('gameCenterAppVersions')).toHaveLength(1); }); }); + +describe('games status rows (desktop Players section)', () => { + test('one row per item, store states from the plans, null when a store was not read', () => { + const g = { leaderboards: [{ id: 'com.example.lb', locales: { en: { name: 'Best' } } }], achievements: [{ id: 'com.example.a1', points: 10, hidden: true, platforms: ['android' as const], locales: { en: { name: 'First', description: 'd' } } }] }; + const plan = [{ kind: 'leaderboard' as const, id: 'com.example.lb', action: 'update' as const, attributes: {}, changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' as const, attributes: {}, changed: [] }] }]; + const rows = gameItemRows(g, plan, null); + expect(rows.map(r => [r.kind, r.id, r.name, r.ios, r.android])).toEqual([['leaderboard', 'com.example.lb', 'Best', true, true], ['achievement', 'com.example.a1', 'First', false, true]]); + expect(rows[0]!.apple).toEqual({ action: 'update', changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' }] }); + expect(rows[0]!.play).toBeNull(); + expect(rows[1]).toMatchObject({ points: 10, hidden: true, apple: null }); + }); +}); diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index 6f7ced0..1943097 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -244,20 +244,51 @@ async function newPage(ctx: { newPage(): Promise }): return page; } -/** Opens an app tab like a person: the tab itself when it is in the row, else through the row's "More" menu (tabs - * that do not fit the window go there). */ +/** The app's places (docs IA): five sections, each with sub views in a segmented control. A place is named by its + * sub view's old tab id ("tab-prices"); the section of each, for opening it like a person. */ +const SECTION_OF: Record = { + releases: 'release', testflight: 'release', listings: 'store', art: 'store', appinfo: 'store', catalog: 'revenue', prices: 'revenue', + reviews: 'players', games: 'players', health: 'health', growth: 'health', checks: 'health', tools: 'health', +}; + +/** Opens a place in an app like a person: its section in the section row (or through the row's "More" menu when it + * does not fit), then the sub view in the section's segmented control when the section has more than one. */ async function clickTab(page: Page, id: string, o: { timeout?: number } = {}): Promise { - const tab = page.locator(`[role="tab"]#${id}`); + const sub = id.replace(/^tab-/, ''); + const sec = SECTION_OF[sub]; + if (!sec) throw new Error(`unknown place ${id}`); + const tab = page.locator(`[role="tab"]#tab-${sec}`); await tab.waitFor({ state: 'attached', timeout: o.timeout }); - if (await tab.isVisible()) { await tab.click(o); return; } - const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim(); - try { - await page.locator('.ds-tab-more').click(o); - await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o); - } catch (e) { - const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth })); - throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`); + if (await tab.isVisible()) await tab.click(o); + else { + const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim(); + try { + await page.locator('.ds-tab-more').click(o); + await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o); + } catch (e) { + const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth })); + throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`); + } } + const btn = page.locator(`.sec-subs #sub-${sub}`); + if (await btn.count()) { await btn.click(o); return; } + // a section with one sub view has no segmented control: the section itself is the place + if (await page.locator('.sec-subs button').count()) throw new Error(`${id}: not in the ${sec} section's views`); +} + +/** Every place of the open app, in order: each section, then each of its sub views (as clickTab ids). */ +async function places(page: Page, o: { timeout?: number } = {}): Promise { + const secs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id.replace(/^tab-/, ''))); + const out: string[] = []; + for (const sec of secs) { + await page.locator(`[role="tab"]#tab-${sec}`).click(o).catch(() => {}); + await page.locator('[data-testid="section-head"]').waitFor({ state: 'visible', timeout: o.timeout }).catch(() => {}); + const subs = await page.locator('.sec-subs button').evaluateAll(bs => bs.map(b => b.id.replace(/^sub-/, ''))); + const first = Object.entries(SECTION_OF).find(([, s]) => s === sec)?.[0]; + // a one-view section: its only view (Players without games is Reviews) + out.push(...(subs.length ? subs : first ? [first] : []).map(x => `tab-${x}`)); + } + return out; } async function state(page: Page) { @@ -342,10 +373,12 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br if (await wizard.count()) problems.push('the welcome guide does not close with Escape'); } // every sidebar entry: the portfolio, each app (folders that are gone included), each workspace screen - const count = await page.locator('nav.side button.nav').count(); + // (apps are .nav rows; the workspace is the footer's icon row, named by aria-label) + const SIDE = 'nav.side button.nav, nav.side button.nav-ws'; + const count = await page.locator(SIDE).count(); for (let i = 0; i < count; i++) { - const b = page.locator('nav.side button.nav').nth(i); - const label = ((await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim(); + const b = page.locator(SIDE).nth(i); + const label = ((await b.getAttribute('aria-label').catch(() => null)) || (await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim(); await b.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`)); await settle(page); await check(label); @@ -365,7 +398,7 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br // shared with the CLI: each account names its credential profile (or that its old keychain key has not moved) const accts = fx.accounts_list as { label?: string; profile?: string | null }[]; if (accts.length) { - const nav = page.locator('nav.side button.nav', { hasText: 'Accounts' }).first(); + const nav = page.locator('nav.side [data-screen="accounts"]').first(); if (await nav.count()) { await nav.click({ timeout: WAIT_MS }); await settle(page); @@ -510,8 +543,8 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' await gone(page, '.cp-panel'); 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)); + // every place of the app (each section and its sub views), then App info's console steps and Dependencies + const tabs = await places(page, { timeout: WAIT_MS }); for (const id of tabs) { await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`)); await settle(page); @@ -524,7 +557,7 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' 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(); + const deps = page.locator('nav.side [data-screen="dependencies"]').first(); await deps.click({ timeout: WAIT_MS }).catch(() => {}); await shows(page, '#content', 'Android SDK'); await settle(page); @@ -710,7 +743,7 @@ async function runAutoReadCase(browser: Browser): Promise { await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`)); await settle(page); const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim(); - const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id)); + const tabs = await places(page, { timeout: WAIT_MS }); for (const id of tabs) { await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`${appName} ${id}: ${String(e).split('\n')[0]}`)); await settle(page); From 5b6e0992ca6c3632eb76b47494a08a8acd835b28 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:05:40 +0800 Subject: [PATCH 021/126] desktop: boot check finds unsaved marks on the sub views; neutral next-step lines before a read --- apps/desktop/src/i18n/en.ts | 4 ++-- apps/desktop/src/i18n/tr.ts | 4 ++-- tools/desktop-boot-check.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c4f37ed..9c5642b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2140,7 +2140,7 @@ export const en = { "next.managed": "{version} waits in Publishing overview (managed publishing)", "next.upload": "Upload a first build", "next.live": "Live: {version}", - "next.notRead": "Stores not read yet", + "next.notRead": "Release state shows once the stores are read", "next.storeLive": "The live store page, read from the stores", "next.listingProblems": "{count} listing problems", "next.listingProblems_one": "1 listing problem", @@ -2154,7 +2154,7 @@ export const en = { "next.products": "{products} products · {subs} subscriptions", "next.reviews": "{count} reviews without a reply", "next.reviews_one": "1 review without a reply", - "next.reviewsUnread": "Reviews not read yet", + "next.reviewsUnread": "See what players say", "next.reviewsOk": "All reviews answered", "next.gamesApiOff": "Play Games Services API is off", "next.gamesBlockers": "{count} game services blockers", diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index e19a2e7..6872f6b 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -2142,7 +2142,7 @@ export const tr: Record = { "next.managed": "{version} Yayınlama özeti'nde bekliyor (yönetilen yayınlama)", "next.upload": "İlk build'i yükle", "next.live": "Yayında: {version}", - "next.notRead": "Mağazalar henüz okunmadı", + "next.notRead": "Yayın durumu mağazalar okununca görünür", "next.storeLive": "Mağazalardan okunan canlı mağaza sayfası", "next.listingProblems": "{count} mağaza metni sorunu", "next.listingProblems_one": "1 mağaza metni sorunu", @@ -2156,7 +2156,7 @@ export const tr: Record = { "next.products": "{products} ürün · {subs} abonelik", "next.reviews": "{count} yorum yanıtsız", "next.reviews_one": "1 yorum yanıtsız", - "next.reviewsUnread": "Yorumlar henüz okunmadı", + "next.reviewsUnread": "Oyuncuların söylediklerine bak", "next.reviewsOk": "Tüm yorumlar yanıtlandı", "next.gamesApiOff": "Play Games Services API kapalı", "next.gamesBlockers": "{count} oyun servisi engeli", diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index 1943097..cc4d1e4 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -976,7 +976,7 @@ async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise problems.push(`no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="listings"]')).slice(0, 600)})`)); - await page.waitForFunction(() => !document.querySelector('#tab-listings .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('the unsaved mark stays after Save')); + await page.waitForFunction(() => !document.querySelector('#sub-listings .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('the unsaved mark stays after Save')); await settle(page); const md = readFileSync(appstore, 'utf8'); const changed = md.split('\n').filter((l, i) => l !== before.md.split('\n')[i]); @@ -1022,7 +1022,7 @@ async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise problems.push(`App info: no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="appinfo"]')).slice(0, 600)})`)); - await page.waitForFunction(() => !document.querySelector('#tab-appinfo .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('App info: the unsaved mark stays after Save')); + await page.waitForFunction(() => !document.querySelector('#sub-appinfo .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('App info: the unsaved mark stays after Save')); const cfg = readFileSync(join(dir, 'storebound.config.ts'), 'utf8'); const cfgChanged = cfg.split('\n').filter((l, i) => l !== before.cfg.split('\n')[i]); if (!cfg.includes("reviewNotes: 'Sign in with the demo account.'") || cfgChanged.length !== 1 || !cfg.includes('// storebound config of the demo app')) problems.push(`storebound.config.ts: expected one changed line with reviewNotes and the comments kept, got ${JSON.stringify(cfgChanged).slice(0, 300)}`); From 445ab1adbc1523b6d7588297735b24c8dc62e0a8 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:02:35 +0800 Subject: [PATCH 022/126] apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs --- CHANGELOG.md | 6 + cli/api.ts | 4 +- cli/api/gate.ts | 4 +- cli/api/wrapped.gen.ts | 2 +- cli/apple/build-mas.ts | 4 +- cli/apple/build.ts | 65 ++++++- cli/apple/signing.ts | 159 +++++++++++++-- cli/data/flags.json | 22 ++- cli/lib/keychain.ts | 45 +++++ cli/lib/signing.ts | 29 ++- cli/lib/targets.ts | 138 +++++++++++++ cli/lib/xcode.ts | 4 +- docs/api-coverage.md | 2 +- skills/build-and-sign/SKILL.md | 9 +- skills/store-api/SKILL.md | 4 +- skills/store-pitfalls/SKILL.md | 36 +++- tests/signing-targets.test.ts | 341 +++++++++++++++++++++++++++++++++ 17 files changed, 832 insertions(+), 42 deletions(-) create mode 100644 cli/lib/keychain.ts create mode 100644 cli/lib/targets.ts create mode 100644 tests/signing-targets.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a36300..5a15c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas. - `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them). - `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size. +||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs) + +- `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table. +- App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides. +- `apple build` over SSH / in CI: the refusal now names the fixes (GUI session `.command` file, a CI runner with an unlocked keychain, a build keychain). New `--keychain-unlock` (keychain password from `STOREBOUND_KEYCHAIN_PASSWORD`, never printed; runs `security unlock-keychain` and `set-key-partition-list`) and `--keychain ` (sign with a build keychain). +- `storebound api` writes: the usage, the dry-run message and the refusals show the agent form with an example (`--yes --confirm `), and the store-api skill says both flags are needed. - Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch). - `apple upload` / `play upload` `--pre-check ""` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence ` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`. - New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate ""`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language. diff --git a/cli/api.ts b/cli/api.ts index a629aed..953919f 100644 --- a/cli/api.ts +++ b/cli/api.ts @@ -35,7 +35,9 @@ export const USAGE = `usage: GETs run right away. Writes print the exact request and are sent only with --yes in a terminal; owner-only operations (submit, release, delete, cancel, prices, testers, review replies, experiments) also ask you to type the operation id. Agents without a terminal: --yes --confirm (must equal the operation id; plain writes -only, never owner-only operations). Full table: docs/api-coverage.md`; +only, never owner-only operations), e.g. + storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance +Full table: docs/api-coverage.md`; export interface Row { store: Store; id: string; method: string; path: string; gate: GateKind; wrapped: string } diff --git a/cli/api/gate.ts b/cli/api/gate.ts index d3f6ef7..c7c8cef 100644 --- a/cli/api/gate.ts +++ b/cli/api/gate.ts @@ -94,7 +94,7 @@ export type Decision = | { action: 'refuse'; why: string }; /** --yes without a terminal: refused loudly (exit 1, stderr), never a silent dry run (pitfall 77). */ -export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm (agents, plain writes only), or use the dedicated storebound command.'; +export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm (agents, plain writes only: --yes --confirm bundleIds_createInstance for the operation bundleIds_createInstance), or use the dedicated storebound command.'; /** --confirm never stands in for the owner's typed id on an owner-only operation. */ export const OWNER_NO_CONFIRM = 'refused: owner-only operation: --confirm does not apply; the owner types the operation id in an interactive terminal. Nothing was sent.'; @@ -107,7 +107,7 @@ export function decide(g: GateInput): Decision { if (g.agent && !g.bypass) return { action: 'refuse', why: g.kind === 'owner' ? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal' : 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' }; - if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' }; + if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : g.kind === 'owner' ? 'writes are dry by default: add --yes (in a terminal; owner-only operations also ask for the typed operation id)' : `writes are dry by default: add --yes in a terminal, or from an agent's shell --yes --confirm (e.g. \`storebound api apple ${g.opId ?? 'bundleIds_createInstance'} --body body.json --yes --confirm ${g.opId ?? 'bundleIds_createInstance'}\`)` }; if (g.confirm !== undefined) { // an agent's typed confirmation: only for plain writes, only when it names this exact operation if (g.kind === 'owner' && !g.bypass) return { action: 'refuse', why: OWNER_NO_CONFIRM }; diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index d361d9a..4a8fca5 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -134,7 +134,7 @@ export const WRAPPED: { apple: Record; play: Record { } if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)'); const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim()); - if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session (write the command into a .command file and \`open -a Terminal \`), or add --unsigned for a pipeline check.`); + if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`${sshBuildRefusal(remote, { unlockFlag: false })} + Or add --unsigned for a pipeline check.`); mkdirSync(out, { recursive: true }); writeFileSync(entFile, entitlementsXml(ent)); writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`); diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 74f0056..97ac211 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -3,6 +3,11 @@ // (Mac App Store build of a Tauri app: cli/apple/build-mas.ts) // storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P] // [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json] +// [--keychain ] [--keychain-unlock] [--allow-ssh] [--allow-app-group-gap] +// Over SSH (no GUI session) codesign needs an unlocked keychain: --keychain-unlock unlocks it with the password in +// STOREBOUND_KEYCHAIN_PASSWORD (never printed) and sets its key partition list; --keychain signs with a build +// keychain instead of the login keychain. Manual signing: stops before the build when a target's App Group is not in its +// installed profile (the developer portal assigns groups; storebound apple signing status/ensure names the steps). // First the prebuild (build.prebuild / apple.prebuild: e.g. the web build + npx cap sync; once per flow run), then a // Capacitor project's copied web assets (ios/App/App/public) must be newer than the source, else it stops. // Finds the workspace / project and scheme (native, Capacitor ios/App, React Native ios/, Flutter ios/Runner, @@ -24,20 +29,22 @@ import { pump } from '../lib/runner'; import { parseMobileprovision, toXmlPlist } from '../lib/plist'; import { nextBuildNumber } from '../lib/release'; import { exportOptions, profileDirs } from '../lib/signing'; +import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain'; +import { appGroupGapsFor, appGroupSteps } from '../lib/targets'; import { capacitorProject, webStep } from '../lib/web-assets'; import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; import { parsePlatform } from './platform'; -import { findBundleId } from './signing'; +import { discoverTargets, findBundleId } from './signing'; import * as rt from '../lib/rt'; import { shipPhase } from '../lib/ship-step'; const TAG = 'build'; /** Installed App Store profiles for a bundle id, newest expiry first: { uuid, name, team }. */ -export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string }[] { - const out: { uuid: string; name: string; team: string; expires: string }[] = []; +export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string; entitlements: Record }[] { + const out: { uuid: string; name: string; team: string; expires: string; entitlements: Record }[] = []; for (const d of profileDirs(home)) { let files: string[] = []; try { files = readdirSync(d).filter(f => f.endsWith('.mobileprovision')); } catch { continue; } @@ -50,13 +57,35 @@ export function installedProfiles(bundleId: string, teamId: string | null, home const store = !p.ProvisionedDevices && !p.ProvisionsAllDevices && ent?.['get-task-allow'] !== true; if (!store || appId !== `${team}.${bundleId}` || (teamId && team !== teamId)) continue; if (Date.parse(String(p.ExpirationDate)) < Date.now()) continue; - if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate) }); + if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate), entitlements: ent ?? {} }); } catch { /* not a profile */ } } } return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires)); } +const security = (argv: string[]): { ok: boolean; out: string } => { const r = rt.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); return { ok: r.success, out: (r.stdout.toString() + r.stderr.toString()).trim() }; }; + +/** Puts a build keychain on the user's keychain search list (xcodebuild -exportArchive finds identities there). */ +function addToSearchList(keychain: string): void { + const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out); + const step = unlockSteps(keychain, list).find(x => x[1] === 'list-keychains'); + if (step) { const r = security(step); if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.slice(0, 200)}`); log(TAG, `${keychain} added to the keychain search list`); } +} + +/** --keychain-unlock: unlocks the keychain (the login keychain, or --keychain) with STOREBOUND_KEYCHAIN_PASSWORD and + * sets the key partition list so codesign uses the keys without a prompt. The password is never printed. */ +function unlockKeychain(keychain: string | null): void { + const pw = keychainPassword(process.env); + const kc = keychain ?? parseKeychainList(security(['security', 'default-keychain', '-d', 'user']).out)[0] ?? join(homedir(), 'Library/Keychains/login.keychain-db'); + const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out); + for (const step of unlockSteps(kc, list)) { + const r = security(step.map(x => (x === 'PASSWORD' ? pw : x))); + if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.split(pw).join('***').slice(0, 300)}`); + } + log(TAG, `keychain ${kc} unlocked, key partition list set (password from ${'STOREBOUND_KEYCHAIN_PASSWORD'})`); +} + export async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record): Promise { log(TAG, `$ ${show}`); log(TAG, `full output: ${logFile}`); @@ -142,33 +171,45 @@ export async function run(ctx: Ctx): Promise { if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42'); const buildNo = build ?? ''; - const ids = [bundleId, ...(signing.extraBundleIds ?? [])]; + const scan = discoverTargets(root, config); + const ids = [bundleId, ...scan.extensions.map(t => t.bundleId)]; const profiles: Record = {}; if (style === 'manual') for (const id of ids) { const p = installedProfiles(id, teamId)[0]; if (p) profiles[id] = p.uuid; else if (!dry) throw new Error(`no installed App Store profile for ${id}: storebound apple signing ensure`); else warn(TAG, `no installed App Store profile for ${id} (storebound apple signing ensure)`); } + // App Groups: a manual-signing profile carries a group only when the portal assigned it to the bundle id + const groupGaps = style === 'manual' ? appGroupGapsFor([...(scan.main ? [scan.main] : []), ...scan.extensions], id => { const p = installedProfiles(id, teamId)[0]; return p ? p.entitlements : null; }, () => null) : []; + for (const g of groupGaps) { + const msg = `${g.bundleId}: the installed profile lacks App Group(s) ${g.groups.join(', ')}`; + if (a.has('allow-app-group-gap') || dry) warn(TAG, msg); + else throw new Error(`${msg}; codesign would refuse the build.\n${appGroupSteps(g).slice(1).join('\n')}\n(--allow-app-group-gap builds anyway)`); + } const opts = exportOptions({ teamId: teamId ?? '', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '' }) : undefined }); const plist = toXmlPlist(opts); + const keychainFlag = a.get('keychain'); + const keychain = keychainFlag && keychainFlag !== 'true' ? resolve(keychainFlag) : null; + const unlock = a.has('keychain-unlock'); const archivePath = join(out, `${target.scheme}.xcarchive`); const exportPath = exportDir(out, version, buildNo); const authPreview: AscAuth = { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' }; const auth = style === 'automatic' ? authPreview : null; const steps = [ ...(target.framework === 'flutter' ? [showCmd(flutterConfigArgs(buildNo, version), null)] : []), - showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth }), auth), + showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth, settings: keychain ? keychainSigningArgs(keychain) : undefined }), auth), showCmd(exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, auth), auth), ]; + const unlockPreview = unlock ? unlockSteps(keychain ?? '', []).map(showSecurity) : []; const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration, - version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps, notes: target.notes }; + version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes }; const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') }; if (dry) { const web = await webStep({ ...webOpts, dry: true, quiet: json }); if (json) { printJson({ ...plan, web }); return; } log(TAG, `${target.framework}: ${target.workspace ?? target.project} scheme ${target.scheme} (${configuration}), build ${buildNo}${version ? `, version ${version}` : ''}, signing ${style}`); for (const n of target.notes) log(TAG, n); - for (const s of steps) log(TAG, `would run: ${s}`); + for (const s of plan.steps) log(TAG, `would run: ${s}`); console.log(`\n${relative(root, join(out, 'ExportOptions.plist'))}:\n${plist}`); done(TAG, 'build planned (dry run)'); return; @@ -177,7 +218,11 @@ export async function run(ctx: Ctx): Promise { // codesign in an SSH session cannot use the login keychain: the archive fails late with errSecInternalComponent // (Embed Pods Frameworks). Stop before the web build and the archive (pitfall 101). const remote = remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim()); - if (remote && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session: a Jenkins / Forgejo agent started in that session, or from SSH: write the command into a .command file and \`open -a Terminal \` (it runs in the GUI session; unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). --allow-ssh skips this check after \`security unlock-keychain\`.`); + if (remote && !a.has('allow-ssh') && !unlock && !keychain) throw new Error(sshBuildRefusal(remote)); + if (unlock || keychain) { + if (unlock) unlockKeychain(keychain); + else addToSearchList(keychain!); + } shipPhase('prebuild', 'web build / prebuild'); const web = await webStep({ ...webOpts, dry: false }); for (const n of target.notes) log(TAG, n); @@ -190,7 +235,7 @@ export async function run(ctx: Ctx): Promise { try { if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null)); shipPhase('build', `xcodebuild archive, build ${build}`); - const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth }); + const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth, settings: keychain ? keychainSigningArgs(keychain) : undefined }); await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth)); shipPhase('sign', 'xcodebuild -exportArchive (signing)'); await exportIpa({ root, out, archivePath, exportPath, auth: realAuth }); diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index cea09e0..09fb9b0 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -2,7 +2,13 @@ // provisioning profiles, so `apple build` can sign without anyone opening Xcode's Accounts settings. // storebound apple signing status [--json] read-only: capabilities, certificates (with / without a // private key on this machine), profiles, what is installed -// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json] +// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json] +// every signable target of the Xcode project (app, app extensions such as a share extension or widget, watch app, +// App Clip: PRODUCT_BUNDLE_IDENTIFIER + entitlements of project.pbxproj, or apple.signing.extensions) gets its +// bundle id registered, the capabilities its entitlements need and (manual style) an App Store profile. +// App Groups: the API cannot assign a group to a bundle id, so a profile lacks the group until it is ticked in the +// developer portal; ensure compares the entitlements with the profile and names the portal link and the steps; +// --refresh-profile makes the new profile after that. // adds missing capabilities (apple.signing.capabilities) and reads them back (fails when one did not stick). // style automatic (the default) stops there: Xcode owns certificates and profiles. style manual also // creates a distribution certificate when this machine @@ -20,14 +26,15 @@ import { confirm, need } from '../lib/ctx'; import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr'; import { done, log, printJson, warn } from '../lib/log'; import { - type ApiCert, type ApiProfile, type AppleSigning, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo, + type ApiCert, type ApiProfile, type AppleSigning, type CapabilitySpec, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo, ENTITLEMENT_CAPABILITIES, machoEntitlements, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType, compareCapabilities, deleteProfileCmd, ensureScope, explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, type ProfileType, type ProjectEntitlements, projectEntitlements, readProfiles, unusedStoreboundProfiles, verifyCapabilities, } from '../lib/signing'; import { isBuildCopyPath } from '../lib/privacy-manifest'; import { exportedIpas, ipaInfo, scanXcode } from '../lib/xcode'; -import { parsePlist } from '../lib/plist'; +import { parseMobileprovision, parsePlist } from '../lib/plist'; +import { appGroupGapsFor, appGroupSteps, type AppGroupGap, pbxTargets, type TargetKind } from '../lib/targets'; import { zipEntries, zipRead } from '../lib/zip'; import type { ShipConfig } from '../lib/config'; import { textTable } from '../lib/table'; @@ -37,7 +44,7 @@ import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert'; import * as rt from '../lib/rt'; const TAG = 'signing'; -const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]'; +const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install] [--refresh-profile]'; export const signingDir = (root: string): string => join(root, '.storebound', 'signing'); @@ -98,6 +105,78 @@ export function readProjectEntitlements(root: string): ProjectEntitlements[] | n return out; } +export interface SigningTarget { + bundleId: string; name: string; kind: TargetKind | 'configured'; source: 'xcode' | 'config'; + /** the .entitlements file read (relative to the repository), if any */ + entitlements: string | null; + capabilities: CapabilitySpec[]; + /** com.apple.security.application-groups of the entitlements */ + appGroups: string[]; +} +export interface TargetScan { main: SigningTarget | null; extensions: SigningTarget[]; notes: string[] } + +/** Every signable target of the iOS project: the app and its extensions / widgets / watch app / App Clip, read from the + * pbxproj (Release configuration) and their entitlements files. apple.signing.extensions replaces the detected + * extensions; apple.signing.extraBundleIds are added (no capabilities). Capabilities come from the entitlements: + * keychain-access-groups has no App ID capability (nothing to enable). Offline. */ +export function discoverTargets(root: string, config: ShipConfig): TargetScan { + const main = config.apple?.bundleId ?? ''; + const s: AppleSigning = config.apple?.signing ?? {}; + const notes: string[] = []; + const found: SigningTarget[] = []; + const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p)); + for (const proj of projects) { + let pbx = ''; + try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { continue; } + for (const t of pbxTargets(pbx)) { + if (!t.bundleId) { notes.push(`${proj}: target ${t.name} has bundle id "${t.unresolved ?? '?'}" which storebound cannot resolve (an xcconfig or a build variable): list it in apple.signing.extensions`); continue; } + if (found.some(f => f.bundleId === t.bundleId)) continue; + let caps: CapabilitySpec[] = [], groups: string[] = [], file: string | null = null; + if (t.entitlements) { + const f = join(dirname(join(root, proj)), t.entitlements); + try { + const xml = readFileSync(f, 'utf8'); + file = relative(root, f).split(sep).join('/'); + caps = projectEntitlements(file, xml, true).caps.map((c): CapabilitySpec => ({ type: c.cap })); + const g = (parsePlist(xml) as Record)['com.apple.security.application-groups']; + groups = Array.isArray(g) ? g.map(String) : []; + } catch { notes.push(`${proj}: target ${t.name}: entitlements file ${t.entitlements} not readable`); } + } + found.push({ bundleId: t.bundleId, name: t.name, kind: t.kind, source: 'xcode', entitlements: file, capabilities: caps, appGroups: groups }); + } + } + const mainTarget = found.find(t => t.bundleId === main) ?? null; + let extensions: SigningTarget[]; + if (s.extensions?.length) { + extensions = s.extensions.map((x): SigningTarget => { + const det = found.find(t => t.bundleId === x.bundleId); + return { bundleId: x.bundleId, name: x.name ?? det?.name ?? x.bundleId, kind: det?.kind ?? 'configured', source: 'config', entitlements: det?.entitlements ?? null, + capabilities: (x.capabilities ?? det?.capabilities ?? []).map((c): CapabilitySpec => (typeof c === 'string' ? { type: c } : c)), appGroups: x.appGroups ?? det?.appGroups ?? [] }; + }); + notes.push(`apple.signing.extensions replaces the ${found.filter(t => t.bundleId !== main).length} extension target(s) found in the Xcode project`); + } else extensions = found.filter(t => t.bundleId !== main); + for (const b of s.extraBundleIds ?? []) if (b !== main && !extensions.some(t => t.bundleId === b)) extensions.push({ bundleId: b, name: b, kind: 'configured', source: 'config', entitlements: null, capabilities: [], appGroups: [] }); + return { main: mainTarget, extensions, notes }; +} + +/** Name of a bundle id on the developer portal (letters, digits, spaces). */ +export const bundleIdName = (appName: string, t: SigningTarget): string => `${appName} ${t.name}`.replace(/[^A-Za-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60); + +const platformOf = (type: ProfileType): 'IOS' | 'MAC_OS' => (type.startsWith('MAC_') ? 'MAC_OS' : 'IOS'); + +/** The entitlements of the newest ACTIVE profile of a bundle id that the API serves with its content (a managed one + * is not readable: null = unknown). */ +export function profileEntitlements(list: ApiProfile[]): Record | null { + const p = list.filter(x => !x.managed && x.attributes.profileState === 'ACTIVE' && x.attributes.profileContent) + .sort((a, b) => Date.parse(b.attributes.expirationDate ?? '') - Date.parse(a.attributes.expirationDate ?? ''))[0]; + if (!p) return null; + try { return (parseMobileprovision(Buffer.from(p.attributes.profileContent!, 'base64')).Entitlements ?? {}) as Record; } catch { return null; } +} + +export function warnAppGroupGaps(gaps: AppGroupGap[], tag: string): void { + for (const g of gaps) for (const l of appGroupSteps(g)) warn(tag, l); +} + const git = (root: string, args: string[]): string | null => { try { const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' }); @@ -171,7 +250,9 @@ export async function capabilityReport(c: Asc, config: ShipConfig, root: string) const b = await findBundleId(c, bundleId); const appId = b ? (b.capabilities as string[]) : null; const cfg = capabilitySpecs(config.apple?.signing, config.games?.gameCenter === true).map(x => x.type); - const project = readProjectEntitlements(root); + // an extension's entitlements belong to its own bundle id (signing ensure handles it), not to the app's App ID + const extFiles = new Set(discoverTargets(root, config).extensions.map(t => t.entitlements).filter((f): f is string => !!f)); + const project = readProjectEntitlements(root)?.filter(p => !extFiles.has(p.file)) ?? null; let build: BuildEntitlements | null = null; try { build = builtIpaEntitlements(root, config); } catch { /* unreadable IPA: compared without it */ } const checkout = project && cfg.some(x => Object.values(ENTITLEMENT_CAPABILITIES).includes(x as CapabilityType)) && !project.some(p => p.caps.length) ? checkoutInfo(root) : null; @@ -219,6 +300,9 @@ export interface SigningState { capabilities: { present: string[]; missing: string[]; extra: string[] }; certificate: { type: CertType; chosen: string | null; why: string; onTeam: number; withKey: number; limit: number | null }; profiles: { bundleId: string; name: string | null; uuid: string | null; state: string; installed: boolean; why: string }[]; + /** every signable target: where it was found, its capabilities (present / missing on its App ID) and App Groups */ + targets: { bundleId: string; name: string; kind: string; source: string; registered: boolean; capabilities: string[]; missingCapabilities: string[]; appGroups: string[] }[]; + appGroupGaps: AppGroupGap[]; } export async function run(ctx: Ctx): Promise { @@ -237,7 +321,13 @@ export async function run(ctx: Ctx): Promise { const c = await ascClient(root, bundleId, config.apple?.appId); // --- read everything first (read-only) - const ids = [bundleId, ...(s.extraBundleIds ?? [])]; + const scan = discoverTargets(root, config); + const exts = scan.extensions; + const ids = [bundleId, ...exts.map(t => t.bundleId)]; + const targetOf = (id: string): SigningTarget | undefined => (id === bundleId ? scan.main ?? undefined : exts.find(t => t.bundleId === id)); + const refresh = a.has('refresh-profile'); + const refreshIds = new Set(ids.filter(id => (targetOf(id)?.appGroups.length ?? 0) > 0)); + if (refresh && !refreshIds.size) ids.forEach(id => refreshIds.add(id)); const bundles = new Map(); for (const id of ids) bundles.set(id, await findBundleId(c, id)); const main = bundles.get(bundleId); @@ -257,8 +347,11 @@ export async function run(ctx: Ctx): Promise { const b = bundles.get(id); if (!b) { profiles.set(id, { list: [], plan: null }); continue; } const list = await profilesOf(c, b.id, profileType); - profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType }) : null }); + profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType, refresh: refresh && refreshIds.has(id) }) : null }); } + const wantCaps = (t: SigningTarget): CapabilitySpec[] => (t.bundleId === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true) : t.capabilities); + const targetList: SigningTarget[] = [...(scan.main ? [scan.main] : []), ...exts]; + const gapsOf = (): AppGroupGap[] => appGroupGapsFor(targetList, id => { const b = bundles.get(id); return b ? profileEntitlements(profiles.get(id)?.list ?? []) : null; }, id => bundles.get(id)?.id ?? null); const state: SigningState = { bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main, capabilities: { present: capPlan.present, missing: capabilitySpecs(s, config.games?.gameCenter === true).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra }, @@ -266,7 +359,7 @@ export async function run(ctx: Ctx): Promise { onTeam: certs.filter(x => Date.parse(x.attributes.expirationDate ?? '') > Date.now()).length, withKey: choice.usable.length, limit: CERT_LIMIT[certType] ?? null }, profiles: ids.map(id => { const p = profiles.get(id)!; - if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: 'register it first (storebound apple bundle for the app; extensions in Certificates, Identifiers & Profiles)' }; + if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: id === bundleId ? 'register it first (storebound apple bundle)' : 'storebound apple signing ensure registers it' }; const r = p.plan?.reuse; const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE'); if (style === 'automatic') @@ -277,8 +370,17 @@ export async function run(ctx: Ctx): Promise { return { bundleId: id, name: r?.attributes.name ?? null, uuid: r?.attributes.uuid ?? null, state: r ? 'ACTIVE' : 'needs a new profile', installed: installedAt(r?.attributes.uuid).length > 0, why: p.plan?.why ?? 'waits for a usable certificate' }; }), + targets: [], appGroupGaps: [], }; + state.targets = ids.map(id => { + const t = targetOf(id), b = bundles.get(id); + const want = t ? wantCaps(t).map(w => w.type) : id === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type) : []; + return { bundleId: id, name: t?.name ?? id, kind: t?.kind ?? 'app', source: t?.source ?? 'config', registered: !!b, capabilities: want, + missingCapabilities: want.filter(w => !(b?.capabilities ?? []).includes(w)), appGroups: t?.appGroups ?? [] }; + }); + state.appGroupGaps = style === 'manual' ? gapsOf() : []; + // storebound-made profiles that nothing uses (automatic signing: all of them; manual: all but the reused one) const inUse = new Set(style === 'manual' ? [...profiles.values()].map(p => p.plan?.reuse?.id).filter((x): x is string => !!x) : []); const unused = [...profiles.values()].flatMap(p => unusedStoreboundProfiles(p.list, inUse)); @@ -296,6 +398,9 @@ export async function run(ctx: Ctx): Promise { log(TAG, `capabilities: ${state.capabilities.present.join(', ') || 'none configured'}${state.capabilities.missing.length ? `; missing ${state.capabilities.missing.join(', ')}` : ''}${state.capabilities.extra.length ? `; also on the bundle id: ${state.capabilities.extra.join(', ')}` : ''}`); log(TAG, `certificate ${certType}: ${choice.why} (${state.certificate.onTeam} valid on the team${state.certificate.limit ? `, limit ${state.certificate.limit}` : ''})`); for (const x of choice.expiring) warn(TAG, `${x.attributes.name ?? x.id} expires ${x.attributes.expirationDate?.slice(0, 10)}: make a new one soon (storebound apple signing ensure)`); + for (const n of scan.notes) log(TAG, n); + console.log(textTable(['target', 'kind', 'bundle id', 'registered', 'capabilities', 'app groups'], state.targets.map(t => [t.name, t.kind, t.bundleId, t.registered ? 'yes' : 'no', t.capabilities.join(', ') || '-', t.appGroups.join(', ') || '-']))); + warnAppGroupGaps(state.appGroupGaps, TAG); console.log(textTable(['bundle id', 'profile', 'state', 'installed', ''], state.profiles.map(p => [p.bundleId, p.name ?? '', p.state, p.installed ? 'yes' : 'no', p.why]))); printCapabilityReport(report, TAG); cleanup(); @@ -305,11 +410,19 @@ export async function run(ctx: Ctx): Promise { // --- ensure: plan, show, ask once, then write if (!main) throw new Error(`bundle id ${bundleId} is not registered: storebound apple bundle --dry, then without --dry`); - const missingExtra = ids.filter(id => !bundles.get(id)); - if (missingExtra.length) throw new Error(`not registered: ${missingExtra.join(', ')} (register extension bundle ids in Certificates, Identifiers & Profiles first)`); + for (const n of scan.notes) log(TAG, n); + // extensions: register the bundle id (not there yet), then the capabilities its entitlements need + const toRegister = exts.filter(t => !bundles.get(t.bundleId)); + const registerReq = (t: SigningTarget): Req => ({ method: 'POST', path: '/v1/bundleIds', note: `register ${t.bundleId} (${t.kind} ${t.name})`, + body: { data: { type: 'bundleIds', attributes: { identifier: t.bundleId, name: bundleIdName(config.app.name, t), platform: platformOf(profileType) } } } }); + const extCapPlans = exts.map(t => { const b = bundles.get(t.bundleId); return { t, b, plan: b ? planCapabilities(b.id, b.capabilities ?? [], t.capabilities) : null }; }); + const extCapLines = extCapPlans.flatMap(({ t, b, plan: cp }) => (b ? (cp?.create ?? []).map(r => `${describeReq(r)} # ${t.bundleId}`) + : t.capabilities.map(w => `POST /v1/bundleIdCapabilities {capabilityType="${w.type}"} # ${t.bundleId}, after it is registered`))); const needCert = scope.certificates && !choice.cert; const plan: string[] = [ + ...toRegister.map(t => `${describeReq(registerReq(t))}`), ...capPlan.create.map(r => describeReq(r)), + ...extCapLines, ...(needCert ? [describeReq(certificateReq(certType, ''))] : []), ...(!scope.profiles ? [] : ids.flatMap(id => { const p = profiles.get(id)!.plan; @@ -339,6 +452,20 @@ export async function run(ctx: Ctx): Promise { const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG }); if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type), responses); if (gcPlan.length && gcSw) await ensureGcSwitch(c, gcSw, TAG); + for (const t of toRegister) { + const made = (await createOnce({ what: `${t.bundleId} bundle id`, tag: TAG, + find: async () => findBundleId(c, t.bundleId), + create: async () => (await c.asc('POST', '/v1/bundleIds', registerReq(t).body)).data })).value; + bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? { ...made, capabilities: [] }); + log(TAG, `${t.bundleId}: bundle id registered (${t.kind} ${t.name})`); + } + for (const { t } of extCapPlans) { + const bid = bundles.get(t.bundleId)!; + const cp = planCapabilities(bid.id, bid.capabilities ?? [], t.capabilities); + if (!cp.create.length) continue; + const rs = await execute(c.asc, cp.create, { dry: false, tag: TAG }); + await checkCapabilitiesStuck(c, t.bundleId, t.capabilities.map(w => w.type), rs); + } if (!scope.profiles) { printCapabilityReport(await capabilityReport(c, config, root), TAG); cleanup(); @@ -385,10 +512,11 @@ export async function run(ctx: Ctx): Promise { else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)'); cert = created; } + const finalEnt = new Map>(); for (const id of ids) { const b = bundles.get(id)!; const list = profiles.get(id)!.list; - const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType }); + const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType, refresh: refresh && refreshIds.has(id) }); let prof: ApiProfile; if (p.create) { // an unclear answer is checked by reading the bundle id's profiles again (one active profile with this @@ -407,9 +535,16 @@ export async function run(ctx: Ctx): Promise { log(TAG, `${id}: ${p.why}`); } if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`); + try { finalEnt.set(id, (parseMobileprovision(Buffer.from(prof.attributes.profileContent ?? '', 'base64')).Entitlements ?? {}) as Record); } catch { /* unreadable: not compared */ } } cleanup(); - if (json) printJson({ ...state, plan, done: true }); + // App Groups: the profile carries a group only when it is assigned to the bundle id in the developer portal + const gaps = appGroupGapsFor(targetList, id => finalEnt.get(id) ?? null, id => bundles.get(id)?.id ?? null); + if (json) printJson({ ...state, plan, appGroupGaps: gaps, done: !gaps.length }); + if (gaps.length) { + warnAppGroupGaps(gaps, TAG); + throw new Error(`profile(s) installed, but ${gaps.map(g => `${g.bundleId} lacks ${g.groups.join(', ')}${g.url ? ` (${g.url})` : ''}`).join('; ')}: the build would fail at codesign. Tick the App Group(s) in the developer portal (steps above), then run storebound apple signing ensure --refresh-profile.`); + } done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)'); } diff --git a/cli/data/flags.json b/cli/data/flags.json index 7473ef4..2e3f8ba 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -66,6 +66,7 @@ "allow-price-changes" ], "apple build": [ + "allow-app-group-gap", "allow-ssh", "allow-stale-web", "allow-version-mismatch", @@ -73,6 +74,7 @@ "configuration", "env-file", "keychain", + "keychain-unlock", "locale-map", "no-install", "no-prebuild", @@ -82,6 +84,7 @@ "profile", "progress", "project", + "refresh-profile", "result-file", "scan-paths", "scheme", @@ -122,7 +125,8 @@ "apple game-center": [ "keychain", "locale-map", - "no-install" + "no-install", + "refresh-profile" ], "apple iap": [ "all", @@ -238,6 +242,7 @@ "no-submit", "platform", "progress", + "refresh-profile", "resolve-iaps", "result-file", "version", @@ -261,7 +266,8 @@ "apple signing": [ "keychain", "locale-map", - "no-install" + "no-install", + "refresh-profile" ], "apple status": [ "cancel-submission", @@ -269,6 +275,7 @@ "locale-map", "no-install", "platform", + "refresh-profile", "timeout", "version", "wait-review" @@ -283,6 +290,7 @@ "no-submit", "platform", "progress", + "refresh-profile", "resolve-iaps", "result-file", "version", @@ -460,6 +468,7 @@ "name", "no-install", "react-native", + "refresh-profile", "sku", "storefront", "unity" @@ -563,7 +572,8 @@ "games status": [ "keychain", "locale-map", - "no-install" + "no-install", + "refresh-profile" ], "health": [ "apple", @@ -1067,6 +1077,7 @@ "play", "prune", "react-native", + "refresh-profile", "repair-stuck", "signature", "skip-out-of-range-regions", @@ -1113,6 +1124,7 @@ "agent", "all", "all-pages", + "allow-app-group-gap", "allow-availability-changes", "allow-existing", "allow-price-changes", @@ -1212,6 +1224,7 @@ "keep", "keep-booted", "keychain", + "keychain-unlock", "keyword", "keywords", "kind", @@ -1292,6 +1305,7 @@ "rating", "react-native", "reconvert", + "refresh-profile", "region", "release", "release-date", @@ -1497,7 +1511,7 @@ ], "apple signing": [ "storebound apple signing status [--json] read-only: capabilities, certificates (with / without a", - "storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]" + "storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json]" ], "apple status": [ "storebound apple status [--platform ios|macos|tvos|visionos] [--json]", diff --git a/cli/lib/keychain.ts b/cli/lib/keychain.ts new file mode 100644 index 0000000..5106f43 --- /dev/null +++ b/cli/lib/keychain.ts @@ -0,0 +1,45 @@ +// Code signing keychain for `storebound apple build` outside the Mac's logged-in GUI session. codesign in an SSH +// session (or any session without the login keychain unlocked) fails late with errSecInternalComponent. The fixes: +// - run from the GUI session (a .command file opened with `open -a Terminal`) +// - a CI runner / Jenkins agent started in that session, or with an unlocked keychain +// - a build keychain: --keychain , unlocked with --keychain-unlock (password from STOREBOUND_KEYCHAIN_PASSWORD, +// never printed) and its key partition list set so codesign may use the keys without a prompt. +// Pure planning here; cli/apple/build.ts runs it. +export const KEYCHAIN_PASSWORD_ENV = 'STOREBOUND_KEYCHAIN_PASSWORD'; + +/** The refusal for a build in an SSH / non-GUI session, naming every fix. */ +export function sshBuildRefusal(reason: string, o: { unlockFlag?: boolean } = {}): string { + const unlock = o.unlockFlag !== false; + return `apple build: ${reason}; codesign there fails with errSecInternalComponent (locked keychain). Fixes, pick one:\n` + + ` 1. Run it in the Mac's logged-in GUI session: write the command into a .command file and \`open -a Terminal \` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside).\n` + + ` 2. A CI runner / Jenkins agent started in the GUI session, or one whose keychain is unlocked first (\`security unlock-keychain -p "$${KEYCHAIN_PASSWORD_ENV}" \`, then \`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$${KEYCHAIN_PASSWORD_ENV}" \`).\n` + + (unlock ? ` 3. Let storebound do step 2: export ${KEYCHAIN_PASSWORD_ENV}=... (the keychain password; never printed) and add --keychain-unlock (the login keychain), or --keychain --keychain-unlock for a build keychain that holds the Apple Distribution key + certificate (storebound apple signing ensure --keychain imports them).\n` + + ` --allow-ssh skips this check when the keychain is already unlocked in this session.` + : ' --allow-ssh skips this check when the keychain is already unlocked in this session.'); +} + +export interface KeychainPlan { keychain: string; steps: string[][] } + +/** The `security` calls --keychain-unlock runs, with PASSWORD as a placeholder (substituted by the caller, never logged). */ +export function unlockSteps(keychain: string, searchList: string[]): string[][] { + const steps: string[][] = [['security', 'unlock-keychain', '-p', 'PASSWORD', keychain], + ['security', 'set-key-partition-list', '-S', 'apple-tool:,apple:,codesign:', '-s', '-k', 'PASSWORD', keychain]]; + if (!searchList.includes(keychain)) steps.push(['security', 'list-keychains', '-d', 'user', '-s', keychain, ...searchList]); + return steps; +} + +/** `security list-keychains` output -> paths (quotes stripped). */ +export const parseKeychainList = (out: string): string[] => out.split('\n').map(l => l.trim().replace(/^"|"$/g, '')).filter(Boolean); + +/** The password from the environment, or an error that names the variable (the value is never in a message). */ +export function keychainPassword(env: Record): string { + const p = env[KEYCHAIN_PASSWORD_ENV]; + if (!p) throw new Error(`--keychain-unlock needs the keychain password in the environment variable ${KEYCHAIN_PASSWORD_ENV} (not an argument: arguments end up in shell history)`); + return p; +} + +/** A command line for logs with the password replaced. */ +export const showSecurity = (argv: string[]): string => argv.map(a => (a === 'PASSWORD' ? '$' + KEYCHAIN_PASSWORD_ENV : a)).join(' '); + +/** Settings that make xcodebuild sign with a given keychain. */ +export const keychainSigningArgs = (keychain: string): string[] => [`OTHER_CODE_SIGN_FLAGS=--keychain ${keychain}`]; diff --git a/cli/lib/signing.ts b/cli/lib/signing.ts index 70019c9..9b28e45 100644 --- a/cli/lib/signing.ts +++ b/cli/lib/signing.ts @@ -43,6 +43,20 @@ export interface AppleSigning { capabilities?: (CapabilityType | CapabilitySpec)[]; /** Bundle ids of app extensions / widgets / watch apps in the same archive: each gets its own profile. */ extraBundleIds?: string[]; + /** Override for the app extensions / widgets / watch app / App Clip that `signing ensure` finds in the Xcode project + * (every target's PRODUCT_BUNDLE_IDENTIFIER and entitlements): when set, these replace the detected ones. Each is + * registered as a bundle id, gets the capabilities its entitlements need (or `capabilities` here) and a profile. */ + extensions?: ExtensionSpec[]; +} + +export interface ExtensionSpec { + bundleId: string; + /** A label for messages and the bundle id's name on the developer portal. */ + name?: string; + /** Default: the capabilities of the target's entitlements file (none when there is none). */ + capabilities?: (CapabilityType | CapabilitySpec)[]; + /** App group ids the target's entitlements list (com.apple.security.application-groups); checked against the profile. */ + appGroups?: string[]; } export function validateSigning(s: AppleSigning | undefined): string[] { @@ -56,6 +70,14 @@ export function validateSigning(s: AppleSigning | undefined): string[] { const t = typeof c === 'string' ? c : c?.type; if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.capabilities: unknown capability "${String(t)}" (${CAPABILITY_TYPES.slice(0, 6).join(', ')}, ...)`); } + for (const x of s.extensions ?? []) { + if (!x || typeof x.bundleId !== 'string' || !/^[A-Za-z0-9.-]+$/.test(x.bundleId)) { e.push(`apple.signing.extensions: "${String(x?.bundleId)}" is not a bundle id`); continue; } + for (const c of x.capabilities ?? []) { + const t = typeof c === 'string' ? c : c?.type; + if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.extensions ${x.bundleId}: unknown capability "${String(t)}"`); + } + if (x.appGroups && (!Array.isArray(x.appGroups) || x.appGroups.some(g => typeof g !== 'string' || !/^group\.[A-Za-z0-9.-]+$/.test(g)))) e.push(`apple.signing.extensions ${x.bundleId}: appGroups are ids like group.com.example.app`); + } for (const b of s.extraBundleIds ?? []) if (!/^[A-Za-z0-9.-]+$/.test(b)) e.push(`apple.signing.extraBundleIds: "${b}" is not a bundle id`); return e; } @@ -158,19 +180,20 @@ export const profileName = (bundleId: string, type: ProfileType, date = new Date export interface ProfilePlan { reuse: ApiProfile | null; create: Req | null; why: string } /** Reuse an ACTIVE profile of the type that holds the certificate and is valid for 14 more days; else create one. */ -export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number }): ProfilePlan { +export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number; /** make a new profile even when one is reusable (after an App Group was ticked in the portal) */ refresh?: boolean }): ProfilePlan { const now = o.now ?? Date.now(); const ok = existing.filter(p => p.attributes.profileType === o.type && p.attributes.profileState === 'ACTIVE' && Date.parse(p.attributes.expirationDate ?? '') - now > 14 * 86_400_000 && (p.certIds ?? []).includes(o.certId)) .sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!)); - if (ok[0]) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` }; + if (ok[0] && !o.refresh) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` }; const stale = existing.filter(p => p.attributes.profileType === o.type && !p.managed); const managed = existing.filter(p => p.attributes.profileType === o.type && p.managed); const why = [ stale.length ? `${stale.length} ${o.type} profile(s), none active with this certificate (states: ${[...new Set(stale.map(p => p.attributes.profileState))].join(', ')})` : '', managed.length ? `${managed.length} Xcode-managed (automatic signing; not readable through the API, not reused)` : '', ].filter(Boolean).join('; ') || `no ${o.type} profile`; - return { reuse: null, why, create: { + const why2 = ok[0] ? `--refresh-profile: a new profile instead of "${ok[0].attributes.name}"` : why; + return { reuse: null, why: why2, create: { method: 'POST', path: '/v1/profiles', note: `create ${o.type} profile for ${o.bundleId}`, body: { data: { type: 'profiles', attributes: { name: profileName(o.bundleId, o.type, new Date(now)), profileType: o.type }, relationships: { bundleId: one('bundleIds', o.bundleIdId), certificates: many('certificates', [o.certId]) } } }, diff --git a/cli/lib/targets.ts b/cli/lib/targets.ts new file mode 100644 index 0000000..fc8e8b9 --- /dev/null +++ b/cli/lib/targets.ts @@ -0,0 +1,138 @@ +// Signable targets of an Xcode project (the app, app extensions such as a share extension or widget, the watch app, an +// App Clip), read from project.pbxproj: product type, PRODUCT_BUNDLE_IDENTIFIER and CODE_SIGN_ENTITLEMENTS of the +// Release configuration. Pure parsing; the fs side (entitlements files, config override) is cli/apple/signing.ts. +// Also the App Group check: Apple's API can switch the APP_GROUPS capability on for a bundle id but cannot assign a +// group container to it, so a profile carries `com.apple.security.application-groups` only when the group was ticked in +// the developer portal; comparing the target's entitlements with the profile's entitlements finds the gap offline. +export type TargetKind = 'app' | 'extension' | 'watch' | 'clip'; + +export interface PbxTarget { name: string; productType: string; kind: TargetKind; /** null: not resolvable (set in an xcconfig or a variable) */ bundleId: string | null; unresolved?: string; entitlements: string | null } + +/** Product types that are code signed with their own bundle id and provisioning profile. Frameworks, bundles, test + * bundles, tools and libraries are not. */ +export function targetKind(productType: string): TargetKind | null { + const m = /^com\.apple\.product-type\.(.+)$/.exec(productType); + const t = m?.[1]; + if (!t) return null; + if (t === 'application.on-demand-install-capable') return 'clip'; + if (/^application\.watchapp/.test(t) || /^watchkit2?-extension$/.test(t)) return 'watch'; + if (t === 'application') return 'app'; + if (/^(app-extension(\..+)?|extensionkit-extension|tv-app-extension)$/.test(t)) return 'extension'; + return null; +} + +/** id -> body of every `<24 hex id> /* comment *\/ = { ... };` object (brace matched, quoted strings skipped). */ +function pbxObjects(pbx: string): Map { + const out = new Map(); + const re = /^[ \t]*([0-9A-Fa-f]{24})(?:[ \t]*\/\*[\s\S]*?\*\/)?[ \t]*=[ \t]*\{/gm; + for (let m = re.exec(pbx); m; m = re.exec(pbx)) { + const open = m.index + m[0].length - 1; + let depth = 0, q = false, i = open; + for (; i < pbx.length; i++) { + const ch = pbx[i]; + if (q) { if (ch === '\\') i++; else if (ch === '"') q = false; continue; } + if (ch === '"') q = true; + else if (ch === '{') depth++; + else if (ch === '}' && --depth === 0) break; + } + out.set(m[1]!, pbx.slice(open + 1, i)); + re.lastIndex = i; + } + return out; +} + +const unquote = (s: string): string => { const t = s.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; }; +const field = (body: string, name: string): string | null => { + const m = new RegExp(`(?:^|[;\\s])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*(?:/\\*[\\s\\S]*?\\*/)?\\s*;`).exec(body); + return m ? unquote(m[1]!) : null; +}; + +function buildSettings(body: string): Record { + const at = body.search(/buildSettings\s*=\s*\{/); + if (at < 0) return {}; + const inner = body.slice(body.indexOf('{', at) + 1); + const out: Record = {}; + for (const m of inner.matchAll(/^[ \t]*("?[A-Za-z_][A-Za-z0-9_\[\]=*]*"?)[ \t]*=[ \t]*("(?:[^"\\]|\\.)*"|\([^)]*\)|[^;]*);/gm)) out[unquote(m[1]!)] = unquote(m[2]!); + return out; +} + +/** $(VAR) / ${VAR} from the target's own settings, TARGET_NAME and PRODUCT_NAME; a modifier (:rfc1034identifier) + * makes the value an identifier. Anything still containing $( afterwards is unresolved. */ +export function resolveSetting(value: string, vars: Record): string { + let v = value; + for (let n = 0; n < 5 && /\$[({]/.test(v); n++) { + v = v.replace(/\$[({]([A-Za-z0-9_]+)(?::([a-z0-9]+))?[)}]/g, (all, name: string, mod?: string) => { + const r = vars[name]; + if (r === undefined || r === `$(${name})`) return all; + return mod === 'rfc1034identifier' || mod === 'identifier' ? r.replace(/[^A-Za-z0-9.-]/g, '-') : r; + }); + } + return v; +} + +/** The signable targets of one project.pbxproj, in file order. */ +export function pbxTargets(pbx: string): PbxTarget[] { + const objs = pbxObjects(pbx); + const out: PbxTarget[] = []; + for (const body of objs.values()) { + if (field(body, 'isa') !== 'PBXNativeTarget') continue; + const productType = field(body, 'productType') ?? ''; + const kind = targetKind(productType); + if (!kind) continue; + const name = field(body, 'name') ?? field(body, 'productName') ?? '?'; + const listBody = objs.get((field(body, 'buildConfigurationList') ?? '').split(/\s/)[0]!); + const ids = [...(/buildConfigurations\s*=\s*\(([\s\S]*?)\)/.exec(listBody ?? '')?.[1] ?? '').matchAll(/([0-9A-Fa-f]{24})/g)].map(m => m[1]!); + const cfgs = ids.map(id => objs.get(id)).filter((b): b is string => !!b); + const cfg = cfgs.find(c => field(c, 'name') === 'Release') ?? cfgs[cfgs.length - 1]; + const s = cfg ? buildSettings(cfg) : {}; + const vars: Record = { ...s, TARGET_NAME: name, PRODUCT_NAME: resolveSetting(s.PRODUCT_NAME ?? name, { TARGET_NAME: name }) }; + const raw = s.PRODUCT_BUNDLE_IDENTIFIER; + const bundleId = raw ? resolveSetting(raw, vars) : null; + const ent = s.CODE_SIGN_ENTITLEMENTS ? resolveSetting(s.CODE_SIGN_ENTITLEMENTS, { ...vars, SRCROOT: '' }).replace(/^\/+/, '') : null; + out.push({ name, productType, kind, bundleId: bundleId && !/\$[({]/.test(bundleId) ? bundleId : null, ...(raw && (!bundleId || /\$[({]/.test(bundleId)) ? { unresolved: raw } : {}), entitlements: ent }); + } + return out; +} + +/** App groups (`com.apple.security.application-groups`) a profile's Entitlements allow. */ +export const profileAppGroups = (ent: Record | undefined): string[] => { + const g = ent?.['com.apple.security.application-groups']; + return Array.isArray(g) ? g.map(String) : []; +}; + +/** Groups the target's entitlements need that the profile does not carry. `*` in a profile allows any group. */ +export function appGroupGaps(want: string[], profileEnt: Record | undefined): string[] { + const have = profileAppGroups(profileEnt); + if (have.includes('*')) return []; + return want.filter(g => !have.includes(g)); +} + +/** The developer portal page of a bundle id (the id is the App Store Connect API resource id of the bundle id). */ +export const bundleIdPortalUrl = (resourceId: string): string => `https://developer.apple.com/account/resources/identifiers/bundleId/edit/${encodeURIComponent(resourceId)}`; + +export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null } + +/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. */ +export function appGroupSteps(g: AppGroupGap): string[] { + const url = g.url ?? 'https://developer.apple.com/account/resources/identifiers/list'; + return [ + `${g.bundleId}: the profile lacks App Group(s) ${g.groups.join(', ')} (the API can enable the App Groups capability but cannot assign a group to a bundle id).`, + `Open ${url} in the signed-in browser (agent-first: you drive it).`, + 'Capabilities list > App Groups: tick it if it is not ticked, press Configure, tick each group above (Continue if the group is new: register it first under Identifiers > App Groups), Continue, then Save at the top right (and Confirm in the dialog that says the profiles get invalidated).', + 'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button.', + `Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.`, + ]; +} + +/** Groups missing per bundle id -> one gap row each. `groupsOf` and `profileOf` are looked up by bundle id. */ +export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[] }[], profileEnt: (bundleId: string) => Record | undefined | null, resourceId: (bundleId: string) => string | null): AppGroupGap[] { + const out: AppGroupGap[] = []; + for (const t of targets) { + if (!t.appGroups.length) continue; + const ent = profileEnt(t.bundleId); + if (ent === null) continue; // no readable profile: unknown, not a gap + const groups = appGroupGaps(t.appGroups, ent); + if (groups.length) { const rid = resourceId(t.bundleId); out.push({ bundleId: t.bundleId, resourceId: rid, groups, url: rid ? bundleIdPortalUrl(rid) : null }); } + } + return out; +} diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index fe0dd52..a961fd6 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -125,14 +125,14 @@ export interface AscAuth { keyPath: string; keyId: string; issuerId: string } const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []); -export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null } +export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain */ settings?: string[] } /** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */ export function archiveArgs(i: ArchiveInput): string[] { return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]), '-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath, ...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []), - ...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : [])]; + ...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])]; } export function exportArgs(archivePath: string, plist: string, exportPath: string, auth: AscAuth | null): string[] { diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 9388e08..5a00c04 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -672,7 +672,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `bundleIds_app_getToOneRelationship` | GET | `/v1/bundleIds/{id}/relationships/app` | read | | | `bundleIds_bundleIdCapabilities_getToManyRelated` | GET | `/v1/bundleIds/{id}/bundleIdCapabilities` | read | | | `bundleIds_bundleIdCapabilities_getToManyRelationship` | GET | `/v1/bundleIds/{id}/relationships/bundleIdCapabilities` | read | | -| `bundleIds_createInstance` | POST | `/v1/bundleIds` | write (dry by default) | apple/bundle | +| `bundleIds_createInstance` | POST | `/v1/bundleIds` | write (dry by default) | apple/bundle, apple/signing | | `bundleIds_deleteInstance` | DELETE | `/v1/bundleIds/{id}` | owner only (typed confirm) | | | `bundleIds_getCollection` | GET | `/v1/bundleIds` | read | apple/adhoc, apple/bundle, apple/signing | | `bundleIds_getInstance` | GET | `/v1/bundleIds/{id}` | read | | diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index e54b8e9..9845d21 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -19,7 +19,7 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ 1. **Read.** `storebound apple signing status`: is the bundle id registered (else `storebound apple bundle`), which capabilities it has vs `apple.signing.capabilities`, the distribution certificates of the team and whether this Mac holds a private key for one (a key file in `.storebound/signing/` or an identity in the keychain), App Store - profiles per bundle id (app + `apple.signing.extraBundleIds` for extensions) and whether Xcode has them installed. + profiles per bundle id (the app and every extension / widget / watch app / App Clip found in the Xcode project, or `apple.signing.extensions`; `extraBundleIds` adds more) and whether Xcode has them installed. `signing ensure` registers the extensions' bundle ids, enables the capabilities their entitlements need and (manual style) makes their profiles; an App Group the profile lacks is reported with the developer portal link and steps (the API cannot assign a group; then `signing ensure --refresh-profile`, store-pitfalls 121). It also compares three places a capability must be and names what is missing where: the App ID, the config's `apple.signing.capabilities` and the iOS project's `.entitlements` (`aps-environment` = PUSH_NOTIFICATIONS, `com.apple.developer.game-center` = GAME_CENTER, associated domains, Sign in with Apple, iCloud, app groups, ...; @@ -57,7 +57,12 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ number**: later steps use exactly it. Any archive or export failure exits non-zero, so `apple build && apple upload` stops there; an export failure shows the IDEDistribution log's real error lines. xcodebuild runs with Apple's tool folders first on PATH (a Homebrew rsync breaks Xcode's IPA step: "exportArchive Copy failed"; `storebound doctor` - flags it). + flags it). Over SSH or in CI (no GUI session) codesign fails with errSecInternalComponent on a locked keychain: + `apple build` stops first and names the fixes. `STOREBOUND_KEYCHAIN_PASSWORD=... storebound apple build + --keychain-unlock` unlocks the login keychain and sets its key partition list (the password is read from the + environment and never printed); `--keychain ` signs with a build keychain (`--keychain --keychain-unlock` + unlocks that one; `signing ensure --keychain ` imports the key + certificate into it). With manual signing it + also stops when a target's App Group is missing from its installed profile (store-pitfalls 121). - Capacitor: the archive ships the web assets `npx cap sync` copied last. Set `build.prebuild` (e.g. `"npm run build && npx cap sync"`; per store `apple.prebuild` / `play.prebuild`) and `apple build` / `play build` run it first (once per `flow run`; `--no-prebuild` skips it). Then the copied `public/index.html` must be newer diff --git a/skills/store-api/SKILL.md b/skills/store-api/SKILL.md index dae8e94..74e61dc 100644 --- a/skills/store-api/SKILL.md +++ b/skills/store-api/SKILL.md @@ -42,7 +42,9 @@ storebound api apple salesReports_getCollection --param ... --out report.gz # on stderr (store-pitfalls 78). Never pipe or redirect a write; use the dedicated command the message names. An agent without a terminal sends a plain (non-owner) write with `--yes --confirm `, the id equal to the one the call resolved to (store-pitfalls 92), after showing the dry run. `--confirm` never applies to - owner-only operations. + owner-only operations. Both flags are needed, and `--confirm` repeats the operation id: + `storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance` + (without `--yes` it stays a dry run; a `--confirm` that differs from the id is refused). 3. Owner-only operations (cli/api/gate.ts: every DELETE, submissions and their cancel, release requests, phased release changes, product page test start / stop, price schedules and price migrations, review replies, tester and user invitations, Play `edits.commit`, product activation, refunds, revokes and cancels, app signing) also ask for diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 15a874c..4d19934 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1229,8 +1229,13 @@ that fails without the fix (see CONTRIBUTING.md). (and read relative to the working directory, not the project). - **Do:** run the build in the Mac's logged-in session: a CI agent started there, or from SSH a `.command` file opened with `open -a Terminal ` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). `--allow-ssh` after - `security unlock-keychain` skips the check. -- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive; `credPath` in + `security unlock-keychain` skips the check. To build over SSH or in CI anyway: export `STOREBOUND_KEYCHAIN_PASSWORD` + (never printed, never an argument of storebound) and add `--keychain-unlock` (unlocks the login keychain and runs + `security set-key-partition-list -S apple-tool:,apple:,codesign:`), or `--keychain --keychain-unlock` for a + build keychain that holds the Apple Distribution key + certificate (`apple signing ensure --keychain ` imports + them); `--keychain` also signs the archive with `OTHER_CODE_SIGN_FLAGS=--keychain ` and puts the keychain on the search list. +- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive, and its message names + these fixes (`sshBuildRefusal`, `cli/lib/keychain.ts`; `tests/signing-targets.test.ts`); `credPath` in `cli/lib/credentials.ts` resolves relative credential paths from the project and `ascKeyFile` hands xcodebuild an absolute path (`tests/build-sign.test.ts`). @@ -1454,3 +1459,30 @@ that fails without the fix (see CONTRIBUTING.md). attributes first, `text=` last); `shots capture --dry` prints them per size, `--dry --probe` opens the pages and names the MISS with the size and every candidate's match count. - **Guard:** `tests/shots-gaps.test.ts`, `tests/browser/feature-fit.browser.ts` (rotate), `cli/shots/selectors.ts`. +||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs) + +## 121. An app extension (share extension, widget, watch app) could not be signed: bundle id not registered, capabilities missing, App Group not in the profile + +- **Symptom:** an iOS app with a share extension archived, then failed at export / codesign: "No profile for team 'X' + matching 'com.example.app.share' found", "Provisioning profile ... doesn't include the App Groups capability" or + "doesn't match the entitlements file's value for the com.apple.security.application-groups entitlement". `apple + signing ensure` knew only the app's bundle id and refused (`not registered`) for `extraBundleIds`. +- **Cause:** every signable target of the archive (app, app extension, widget, watch app, App Clip) is its own bundle id + with its own capabilities and profile. And Apple's API can switch the APP_GROUPS capability on for a bundle id but + cannot assign a group container to it: the profile carries `com.apple.security.application-groups` only after the + group was ticked in the developer portal (Identifiers > the bundle id > App Groups > Configure). +- **Do:** `storebound apple signing ensure --dry` lists every target found in the Xcode project (PRODUCT_BUNDLE_IDENTIFIER + and CODE_SIGN_ENTITLEMENTS of the Release configuration) and what it would register, enable and create; without + `--dry` it registers the extension bundle ids, enables the capabilities its entitlements need (the entitlement-to- + capability table; `keychain-access-groups` needs none) and, for manual signing, creates an App Store profile per + target. It then decodes each profile and compares its App Groups with the target's entitlements: a gap fails with + the developer portal deep link `https://developer.apple.com/account/resources/identifiers/bundleId/edit/` and the + steps to tick the group (drive it in the signed-in browser; never press Remove / Delete / Revoke). After that + `storebound apple signing ensure --refresh-profile` makes the profile again (a normal run reuses the active one, so it + does not pile up profiles while the group is unassigned). `apple build` (manual signing) stops before the archive on the + same gap (`--allow-app-group-gap` overrides). A variable bundle id (set in an xcconfig) is not resolvable: list the + extension in `apple.signing.extensions` (`bundleId`, `capabilities`, `appGroups`), which replaces the detected ones. +- **Guard:** `cli/lib/targets.ts` (pbxproj targets, App Group comparison, portal link and steps), `discoverTargets` in + `cli/apple/signing.ts`, `tests/signing-targets.test.ts` (fixture pbxproj with an app, a share extension and app group + entitlements, a decoded profile plist, a fake App Store Connect). There is no `console plan` form for the portal page: + the steps are in the error and the warning. diff --git a/tests/signing-targets.test.ts b/tests/signing-targets.test.ts new file mode 100644 index 0000000..0104925 --- /dev/null +++ b/tests/signing-targets.test.ts @@ -0,0 +1,341 @@ +// Signable targets of an Xcode project (app + share extension + App Group entitlements): `apple signing ensure` +// registers the extension's bundle id, enables the capabilities its entitlements need, creates its profile and checks +// the App Group against the profile (the API cannot assign a group to a bundle id); `apple build` over SSH names the +// keychain fixes; `storebound api` writes say how an agent confirms. Fixtures are placeholders (com.example.*). +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { toXmlPlist } from '../cli/lib/plist'; +import { run as signing, discoverTargets, profileEntitlements } from '../cli/apple/signing'; +import { decide, NOT_A_TTY } from '../cli/api/gate'; +import { USAGE } from '../cli/api'; +import { keychainPassword, KEYCHAIN_PASSWORD_ENV, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../cli/lib/keychain'; +import { planProfile, validateSigning } from '../cli/lib/signing'; +import { appGroupGaps, appGroupGapsFor, appGroupSteps, bundleIdPortalUrl, pbxTargets, targetKind } from '../cli/lib/targets'; +import { upsertTiming } from '../cli/lib/upsert'; +import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock'; + +const APP_ENT = ` + + aps-environmentdevelopment + com.apple.security.application-groupsgroup.com.example.notes +`; +const SHARE_ENT = ` + + com.apple.security.application-groupsgroup.com.example.notes + keychain-access-groups$(AppIdentifierPrefix)com.example.notes + com.apple.developer.associated-domainsapplinks:example.com +`; + +/** A pbxproj the way Xcode 15 writes it: app, share extension, a unit test bundle, a framework, one variable bundle id. */ +const PBX = `// !$*UTF8*$! +{ + archiveVersion = 1; + objects = { +/* Begin PBXNativeTarget section */ + AAAAAAAAAAAAAAAAAAAAAA01 /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC01 /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( ); + name = App; + productName = App; + productType = "com.apple.product-type.application"; + }; + AAAAAAAAAAAAAAAAAAAAAA02 /* ShareExt */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC02 /* Build configuration list for PBXNativeTarget "ShareExt" */; + name = ShareExt; + productType = "com.apple.product-type.app-extension"; + }; + AAAAAAAAAAAAAAAAAAAAAA03 /* AppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */; + name = AppTests; + productType = "com.apple.product-type.bundle.unit-test"; + }; + AAAAAAAAAAAAAAAAAAAAAA04 /* Kit */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */; + name = Kit; + productType = "com.apple.product-type.framework"; + }; + AAAAAAAAAAAAAAAAAAAAAA05 /* Widget */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC05 /* x */; + name = Widget; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ +/* Begin XCBuildConfiguration section */ + BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.notes.debug; }; + name = Debug; + }; + BBBBBBBBBBBBBBBBBBBBBB02 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "{braces}", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.notes; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + BBBBBBBBBBBBBBBBBBBBBB03 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Share/Share.entitlements"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.notes.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + BBBBBBBBBBBBBBBBBBBBBB04 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "$(MY_BASE_ID).widget"; }; + name = Release; + }; +/* End XCBuildConfiguration section */ +/* Begin XCConfigurationList section */ + CCCCCCCCCCCCCCCCCCCCCC01 /* x */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */, + BBBBBBBBBBBBBBBBBBBBBB02 /* Release */, + ); + }; + CCCCCCCCCCCCCCCCCCCCCC02 /* x */ = { + isa = XCConfigurationList; + buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB03 /* Release */, ); + }; + CCCCCCCCCCCCCCCCCCCCCC03 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( ); }; + CCCCCCCCCCCCCCCCCCCCCC05 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB04 ); }; +/* End XCConfigurationList section */ + }; +} +`; + +const files = { 'ios/App/App.xcodeproj/project.pbxproj': PBX, 'ios/App/App/App.entitlements': APP_ENT, 'ios/App/Share/Share.entitlements': SHARE_ENT }; + +describe('pbxproj targets', () => { + test('targetKind: only signable product types', () => { + expect(targetKind('com.apple.product-type.application')).toBe('app'); + expect(targetKind('com.apple.product-type.app-extension')).toBe('extension'); + expect(targetKind('com.apple.product-type.app-extension.messages-sticker-pack')).toBe('extension'); + expect(targetKind('com.apple.product-type.application.watchapp2')).toBe('watch'); + expect(targetKind('com.apple.product-type.application.on-demand-install-capable')).toBe('clip'); + expect(targetKind('com.apple.product-type.framework')).toBeNull(); + expect(targetKind('com.apple.product-type.bundle.unit-test')).toBeNull(); + }); + + test('app + share extension + widget: bundle ids resolved from Release, entitlements paths, tests and frameworks skipped', () => { + const t = pbxTargets(PBX); + expect(t.map(x => x.name)).toEqual(['App', 'ShareExt', 'Widget']); + expect(t[0]).toMatchObject({ kind: 'app', bundleId: 'com.example.notes', entitlements: 'App/App.entitlements' }); + expect(t[1]).toMatchObject({ kind: 'extension', bundleId: 'com.example.notes.ShareExt', entitlements: 'Share/Share.entitlements' }); + expect(t[2]).toMatchObject({ bundleId: null, unresolved: '$(MY_BASE_ID).widget' }); + }); + + test('discoverTargets reads the entitlements: capabilities and app groups per target; unresolved ids are noted', () => { + const p = project({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101' } }, files); + const s = discoverTargets(p.root, p.loaded.config); + expect(s.main).toMatchObject({ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] }); + expect(s.main!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'PUSH_NOTIFICATIONS']); + expect(s.extensions).toHaveLength(1); + expect(s.extensions[0]).toMatchObject({ bundleId: 'com.example.notes.ShareExt', kind: 'extension', source: 'xcode', appGroups: ['group.com.example.notes'] }); + expect(s.extensions[0]!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']); // keychain-access-groups has no capability + expect(s.notes.join('\n')).toContain('Widget has bundle id "$(MY_BASE_ID).widget"'); + }); + + test('apple.signing.extensions replaces the detected extensions; extraBundleIds are added', () => { + const cfg = { ...BASE, apple: { bundleId: 'com.example.notes', signing: { extensions: [{ bundleId: 'com.example.notes.widget', capabilities: ['PUSH_NOTIFICATIONS' as const], appGroups: ['group.com.example.notes'] }], extraBundleIds: ['com.example.notes.watch'] } } }; + const p = project(cfg, files); + const s = discoverTargets(p.root, p.loaded.config); + expect(s.extensions.map(t => t.bundleId)).toEqual(['com.example.notes.widget', 'com.example.notes.watch']); + expect(s.extensions[0]).toMatchObject({ source: 'config', appGroups: ['group.com.example.notes'], capabilities: [{ type: 'PUSH_NOTIFICATIONS' }] }); + }); + + test('validateSigning checks the extensions override', () => { + expect(validateSigning({ extensions: [{ bundleId: 'com.example.app.share', capabilities: ['APP_GROUPS'], appGroups: ['group.com.example.app'] }] })).toEqual([]); + const e = validateSigning({ extensions: [{ bundleId: 'bad id', capabilities: ['NOPE' as never], appGroups: ['x'] }, { bundleId: 'com.example.a', capabilities: ['NOPE' as never], appGroups: ['x'] }] }); + expect(e.join('\n')).toContain('"bad id" is not a bundle id'); + expect(e.join('\n')).toContain('unknown capability "NOPE"'); + expect(e.join('\n')).toContain('appGroups are ids like group.'); + }); +}); + +describe('App Group check', () => { + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00trailer`, 'latin1').toString('base64'); + const profile = (ent: Record) => ({ id: 'P1', attributes: { name: 'p', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z', profileContent: prov(ent) } }); + + test('the profile plist is decoded and compared with the target\'s groups', () => { + const ent = profileEntitlements([profile({ 'application-identifier': 'ABCDE12345.com.example.notes.share', 'com.apple.security.application-groups': ['group.com.example.other'] })])!; + expect(appGroupGaps(['group.com.example.notes', 'group.com.example.other'], ent)).toEqual(['group.com.example.notes']); + expect(appGroupGaps(['group.com.example.notes'], { 'com.apple.security.application-groups': ['*'] })).toEqual([]); + expect(appGroupGaps(['group.com.example.notes'], {})).toEqual(['group.com.example.notes']); + }); + + test('a managed or contentless profile is unknown, not a gap', () => { + expect(profileEntitlements([{ id: 'M', attributes: { profileType: 'IOS_APP_STORE', profileState: 'ACTIVE' }, managed: true }])).toBeNull(); + expect(appGroupGapsFor([{ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] }], () => null, () => 'X')).toEqual([]); + }); + + test('the gap carries the portal deep link and the agent-first steps with a never-press list', () => { + const g = appGroupGapsFor([{ bundleId: 'com.example.notes.share', appGroups: ['group.com.example.notes'] }], () => ({}), () => 'EXT0001'); + expect(g).toEqual([{ bundleId: 'com.example.notes.share', resourceId: 'EXT0001', groups: ['group.com.example.notes'], url: 'https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001' }]); + expect(bundleIdPortalUrl('EXT0001')).toBe(g[0]!.url!); + const steps = appGroupSteps(g[0]!).join('\n'); + expect(steps).toContain(g[0]!.url!); + expect(steps).toContain('Never press'); + expect(steps).toContain('signing ensure --refresh-profile'); + }); + + test('planProfile: --refresh-profile makes a new profile although an active one is reusable', () => { + const existing = [{ id: 'P1', certIds: ['C1'], attributes: { name: 'old', profileType: 'IOS_APP_STORE' as const, profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z' } }]; + const o = { bundleId: 'com.example.notes', bundleIdId: 'B1', certId: 'C1', type: 'IOS_APP_STORE' as const }; + expect(planProfile(existing, o).reuse?.id).toBe('P1'); + const r = planProfile(existing, { ...o, refresh: true }); + expect(r.reuse).toBeNull(); + expect(r.create?.path).toBe('/v1/profiles'); + expect(r.why).toContain('--refresh-profile'); + }); +}); + +describe('signing ensure with an extension (fake App Store Connect)', () => { + beforeEach(() => { upsertTiming.waitMs = 0; }); + afterEach(() => { restore(); upsertTiming.waitMs = 3000; }); + + const MAIN = 'BUNDLE0001', EXT = 'EXT0001'; + const cfg = (style: 'automatic' | 'manual') => ({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101', signing: { style, capabilities: ['IN_APP_PURCHASE' as const] } } }); + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64'); + + function store(o: { groupInProfile: boolean }) { + const caps: Record = { [MAIN]: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'APP_GROUPS'], [EXT]: [] }; + let registered = false; + const bundle = (id: string, ident: string) => ({ type: 'bundleIds', id, attributes: { identifier: ident, platform: 'IOS', seedId: 'ABCDE12345' }, + relationships: { bundleIdCapabilities: { data: caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}` })) } } }); + const incl = (id: string) => caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}`, attributes: { capabilityType: t, settings: null } })); + const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } }); + const routes = [ + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: () => (registered ? page([bundle(EXT, 'com.example.notes.ShareExt')], incl(EXT)) : page([])) }, + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes&/, answer: () => page([bundle(MAIN, 'com.example.notes')], incl(MAIN)) }, + { method: 'POST', match: /^\/v1\/bundleIds$/, answer: () => { registered = true; return { data: bundle(EXT, 'com.example.notes.ShareExt') }; } }, + { method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => { + const a = (c.body as { data: { attributes: { capabilityType: string }; relationships: { bundleId: { data: { id: string } } } } }).data; + caps[a.relationships.bundleId.data.id]!.push(a.attributes.capabilityType); + return { data: { type: 'bundleIdCapabilities', id: 'x', attributes: { capabilityType: a.attributes.capabilityType } } }; + } }, + { match: /^\/v1\/certificates\?/, answer: page([]) }, + { method: 'POST', match: /^\/v1\/certificates$/, answer: { data: { type: 'certificates', id: 'CERT0001', attributes: { certificateType: 'DISTRIBUTION', name: 'Apple Distribution: Example', expirationDate: '2099-01-01T00:00:00.000+00:00', certificateContent: Buffer.from('cert').toString('base64') } } } }, + { match: /^\/v1\/bundleIds\/[A-Z0-9]+\/profiles/, answer: page([]) }, + { method: 'POST', match: /^\/v1\/profiles$/, answer: (c: { body: unknown }) => { + const id = (c.body as { data: { relationships: { bundleId: { data: { id: string } } } } }).data.relationships.bundleId.data.id; + const groups = o.groupInProfile ? { 'com.apple.security.application-groups': ['group.com.example.notes'] } : {}; + return { data: { type: 'profiles', id: `PROF_${id}`, attributes: { name: `storebound ${id}`, profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: `uuid-${id}`, expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov({ 'application-identifier': `ABCDE12345.${id}`, ...groups }) } } }; + } }, + { match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 }, + ]; + return { routes: routes as never, caps, isRegistered: () => registered }; + } + + test('--dry lists every target: the extension bundle id, its capabilities and (manual) its profile', async () => { + ascEnv(); + const p = project(cfg('manual'), files); + const s = store({ groupInProfile: true }); + const calls = mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--dry']))); + const all = r.out + r.err; + expect(all).toContain('POST /v1/bundleIds'); + expect(all).toContain('register com.example.notes.ShareExt'); + expect(all).toMatch(/POST \/v1\/bundleIdCapabilities \{capabilityType="APP_GROUPS"\} # com\.example\.notes\.ShareExt, after it is registered/); + expect(all).toContain('ASSOCIATED_DOMAINS'); + expect(all).toContain('# com.example.notes.ShareExt: with the new certificate'); + expect(all).not.toContain('ASSOCIATED_DOMAINS is in ios/App/Share/Share.entitlements'); // the extension's entitlements are not compared with the app's App ID + expect(calls.filter(c => c.method !== 'GET')).toEqual([]); + }); + + test('automatic: registers the extension bundle id and its capabilities, reads them back, no profile', async () => { + ascEnv(); + const p = project(cfg('automatic'), files); + const s = store({ groupInProfile: true }); + const calls = mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--yes']))); + expect(r.exit).toBeNull(); + const writes = calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`); + expect(writes[0]).toBe('POST /v1/bundleIds'); + expect(writes.filter(w => w === 'POST /v1/bundleIdCapabilities')).toHaveLength(2); + expect(writes.some(w => /profiles|certificates/.test(w))).toBe(false); + expect(s.caps.EXT0001!.sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']); + const reg = calls.find(c => c.method === 'POST' && c.path === '/v1/bundleIds')!.body as { data: { attributes: Record } }; + expect(reg.data.attributes).toMatchObject({ identifier: 'com.example.notes.ShareExt', platform: 'IOS', name: 'Example Notes ShareExt' }); + }); + + test('manual: a profile for the app and the extension; the group missing from the profile fails with the portal link and steps', async () => { + ascEnv(); + const p = project(cfg('manual'), files); + const s = store({ groupInProfile: false }); + const calls = mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); + const all = r.out + r.err; + expect(calls.filter(c => c.method === 'POST' && c.path === '/v1/profiles')).toHaveLength(2); + expect(all).toContain('com.example.notes.ShareExt lacks group.com.example.notes'); + expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001'); + expect(all).toContain('signing ensure --refresh-profile'); + }); + + test('manual: the profile carries the group: ensure finishes', async () => { + ascEnv(); + const p = project(cfg('manual'), files); + const s = store({ groupInProfile: true }); + mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))); + expect(r.exit).toBeNull(); + expect(r.out + r.err).toContain('signing ready'); + }); +}); + +describe('apple build over SSH: the keychain fixes', () => { + test('the refusal names the GUI session, the CI runner and the keychain flags', () => { + const m = sshBuildRefusal('this is an SSH session'); + expect(m).toContain('errSecInternalComponent'); + expect(m).toContain('open -a Terminal'); + expect(m).toContain('Jenkins'); + expect(m).toContain('security unlock-keychain'); + expect(m).toContain('security set-key-partition-list'); + expect(m).toContain('--keychain-unlock'); + expect(m).toContain('--keychain '); + expect(m).toContain(KEYCHAIN_PASSWORD_ENV); + expect(sshBuildRefusal('x', { unlockFlag: false })).not.toContain('--keychain-unlock'); + }); + + test('unlock steps carry a PASSWORD placeholder that is never shown; the build keychain joins the search list', () => { + const steps = unlockSteps('/tmp/build.keychain-db', ['/Users/example/Library/Keychains/login.keychain-db']); + expect(steps.map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list', 'list-keychains']); + expect(steps[1]).toContain('apple-tool:,apple:,codesign:'); + expect(steps[2]).toEqual(['security', 'list-keychains', '-d', 'user', '-s', '/tmp/build.keychain-db', '/Users/example/Library/Keychains/login.keychain-db']); + expect(showSecurity(steps[0]!)).toBe(`security unlock-keychain -p $${KEYCHAIN_PASSWORD_ENV} /tmp/build.keychain-db`); + expect(unlockSteps('/a', ['/a']).map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list']); + expect(parseKeychainList(' "/Users/example/Library/Keychains/login.keychain-db"\n "/Library/Keychains/System.keychain"\n')).toEqual(['/Users/example/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']); + }); + + test('the password comes from the environment; its absence is an error that names the variable only', () => { + expect(() => keychainPassword({})).toThrow(KEYCHAIN_PASSWORD_ENV); + expect(keychainPassword({ [KEYCHAIN_PASSWORD_ENV]: 'pw-for-test' })).toBe('pw-for-test'); + try { keychainPassword({}); } catch (e) { expect((e as Error).message).not.toContain('pw-for-test'); } + }); +}); + +describe('storebound api writes: --yes --confirm ', () => { + const w = { store: 'apple' as const, method: 'POST', kind: 'write' as const, opId: 'bundleIds_createInstance', yes: false, tty: false, dry: false, agent: false }; + test('the usage and the refusals show the example', () => { + expect(USAGE).toContain('--yes --confirm bundleIds_createInstance'); + expect(NOT_A_TTY).toContain('--yes --confirm bundleIds_createInstance'); + const d = decide(w as never); + expect(d.action).toBe('dry'); + const why = (d as { why: string }).why; + expect(why).toContain('--yes --confirm bundleIds_createInstance'); + expect(why).toContain('storebound api apple bundleIds_createInstance --body body.json'); + }); +}); From 5236f002f195ea2011e6f4b0c4588485abfbcc36 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:44:08 +0800 Subject: [PATCH 023/126] desktop: tray menu panels and pinnable context menus The tray's right-click menu is drawn by the app: one tree from tray.rs (stable submenu keys), one frameless always-on-top menu-N window per panel rendered by tray-menu.html with the design system, placed inside the monitor's work area. A pure panel book keeps transient and pinned panels apart; pinned panels (pin button, Ctrl+click, middle click) are draggable, step aside from each other and are saved in state.json (menuPins) and restored at start. tray_update pushes the tree to the open panels, which update in place; closed panels are destroyed. The native menu, rendered from the same tree, stays as the fallback and on Linux. Only menu-* windows get the menu_* commands (menu.json). In-app context menus get the same behaviour through a pure menu stack (ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels survive the next menu, are draggable and re-read their entries from live sources so data changes update them in place. Also drops a stray diff3 marker line from CHANGELOG.md. --- CHANGELOG.md | 3 +- apps/desktop/src-tauri/build.rs | 2 +- apps/desktop/src-tauri/capabilities/menu.json | 21 + apps/desktop/src-tauri/src/commands.rs | 81 ++ apps/desktop/src-tauri/src/commands_list.rs | 3 + apps/desktop/src-tauri/src/lib.rs | 83 +- apps/desktop/src-tauri/src/menu_panel.rs | 744 ++++++++++++++++++ apps/desktop/src-tauri/src/policy.rs | 11 + apps/desktop/src-tauri/src/store.rs | 6 +- apps/desktop/src-tauri/src/tray.rs | 58 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src-tauri/tests/menu_panel.rs | 203 +++++ apps/desktop/src-tauri/tests/security.rs | 47 +- apps/desktop/src-tauri/tests/tray.rs | 2 +- apps/desktop/src/App.tsx | 2 +- apps/desktop/src/design.css | 13 + apps/desktop/src/i18n/en.ts | 6 + apps/desktop/src/i18n/tr.ts | 6 + apps/desktop/src/screens/AppView.tsx | 4 +- apps/desktop/src/screens/Ignored.tsx | 2 +- apps/desktop/src/screens/Portfolio.tsx | 6 +- apps/desktop/src/traymenu/TrayMenu.tsx | 166 ++++ apps/desktop/src/traymenu/demo.tsx | 55 ++ apps/desktop/src/traymenu/main.tsx | 70 ++ apps/desktop/src/traymenu/menu-api.ts | 33 + apps/desktop/src/traymenu/model.ts | 75 ++ apps/desktop/src/traymenu/traymenu.css | 28 + apps/desktop/src/ui/ContextMenu.tsx | 282 +++++-- apps/desktop/src/ui/icons.tsx | 4 +- apps/desktop/src/ui/menu-stack.ts | 140 ++++ apps/desktop/tests/menu-stack.test.ts | 150 ++++ apps/desktop/tests/tray-menu.test.ts | 85 ++ apps/desktop/tray-menu.html | 14 + apps/desktop/vite.config.ts | 5 +- docs/desktop.md | 19 + 35 files changed, 2308 insertions(+), 123 deletions(-) create mode 100644 apps/desktop/src-tauri/capabilities/menu.json create mode 100644 apps/desktop/src-tauri/src/menu_panel.rs create mode 100644 apps/desktop/src-tauri/tests/menu_panel.rs create mode 100644 apps/desktop/src/traymenu/TrayMenu.tsx create mode 100644 apps/desktop/src/traymenu/demo.tsx create mode 100644 apps/desktop/src/traymenu/main.tsx create mode 100644 apps/desktop/src/traymenu/menu-api.ts create mode 100644 apps/desktop/src/traymenu/model.ts create mode 100644 apps/desktop/src/traymenu/traymenu.css create mode 100644 apps/desktop/src/ui/menu-stack.ts create mode 100644 apps/desktop/tests/menu-stack.test.ts create mode 100644 apps/desktop/tests/tray-menu.test.ts create mode 100644 apps/desktop/tray-menu.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a15c38..ac914f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ ## Unreleased +- Desktop tray: the right-click menu is drawn by the app (the design system's menu look) instead of the OS menu. Each submenu opens as its own small panel; the pin button, Ctrl+click or a middle click keeps a panel open (pinned): it can be dragged, steps aside so it never covers another pinned panel, and comes back after a restart. Values (the apps and their store state, mute) update in place after every store read without closing anything. The native menu stays as a fallback (and on Linux), built from the same menu tree. The left-click tray card is unchanged. +- Desktop right-click menus in the window work the same way: a menu's pin button keeps it open as a floating panel, a submenu can be torn off (its pin button, Ctrl+click or a middle click), pinned panels survive the next menu, can be dragged, and update in place when the data behind them changes ("Gone" when the thing they were for left the screen). - `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas. - `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them). - `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size. -||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs) - `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table. - App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides. diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 673bde2..02a67cc 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -5,7 +5,7 @@ include!("src/commands_list.rs"); fn main() { tauri_build::try_build( tauri_build::Attributes::new() - .app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS].concat().into_boxed_slice()))), + .app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS, MENU_COMMANDS].concat().into_boxed_slice()))), ) .expect("tauri build script failed"); } diff --git a/apps/desktop/src-tauri/capabilities/menu.json b/apps/desktop/src-tauri/capabilities/menu.json new file mode 100644 index 0000000..2f5783e --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/menu.json @@ -0,0 +1,21 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "menu-window", + "description": "The tray menu panels (menu-1, menu-2...: small frameless windows the app opens on a right click on the tray icon, one per open or pinned submenu) with the app's own bundled tray-menu page only. They may listen to app events (the live menu tree) and call the menu commands: read their panel, report their size, open, pin and close panels, pick a row, which runs the same tray action as the native menu, and start a drag of their own window (a pinned panel's header). They never read the stores or reach the sidecar; every command re-checks the window label and page origin in Rust.", + "windows": ["menu-*"], + "local": true, + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:window:allow-start-dragging", + "allow-menu-state", + "allow-menu-ready", + "allow-menu-open-sub", + "allow-menu-pin-sub", + "allow-menu-close-subs", + "allow-menu-close", + "allow-menu-dismiss", + "allow-menu-pin", + "allow-menu-select" + ] +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 2cbf8f9..9739e6a 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -49,6 +49,11 @@ fn guard_card(w: &WebviewWindow) -> Res<()> { policy::require_card(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host) } +fn guard_menu(w: &WebviewWindow) -> Res<()> { + let url = w.url().map_err(|e| Fail::host(e.to_string()))?; + policy::require_menu(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host) +} + fn saved(s: &AppState) -> Res> { s.saved.lock().map_err(|_| Fail::host("state poisoned")) } @@ -1665,3 +1670,79 @@ pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res Res> { + guard_menu(&window)?; + Ok(crate::menu_panel::panel_state(&window)) +} + +/// A panel measured itself (CSS px): sized, placed inside the work area and shown. +#[tauri::command] +pub fn menu_ready(window: WebviewWindow, width: f64, height: f64) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::ready(&window, width, height); + Ok(()) +} + +/// Opens a submenu beside this panel at the row whose top is `top` (CSS px). +#[tauri::command] +pub fn menu_open_sub(window: WebviewWindow, key: String, top: f64, focus: bool) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::open_sub(&window, &key, top, focus, false); + Ok(()) +} + +/// Ctrl / middle click on a submenu row: opens it as its own pinned panel. +#[tauri::command] +pub fn menu_pin_sub(window: WebviewWindow, key: String, top: f64) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::open_sub(&window, &key, top, false, true); + Ok(()) +} + +/// The pointer moved to a plain row: this panel's submenus close. +#[tauri::command] +pub fn menu_close_subs(window: WebviewWindow) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::close_subs(&window); + Ok(()) +} + +/// Closes this panel (Left / Esc in a submenu, X on a pinned one). +#[tauri::command] +pub fn menu_close(window: WebviewWindow, refocus_parent: bool) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::close(&window, refocus_parent); + Ok(()) +} + +/// Esc on the root menu: every transient panel closes. +#[tauri::command] +pub fn menu_dismiss(window: WebviewWindow) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::dismiss(window.app_handle()); + Ok(()) +} + +/// Pins or unpins this panel. +#[tauri::command] +pub fn menu_pin(window: WebviewWindow, pinned: bool) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::pin(&window, pinned); + Ok(()) +} + +/// A pick: the same action as the native tray menu (an id tray.rs knows; anything else is refused). +#[tauri::command] +pub fn menu_select(window: WebviewWindow, id: String) -> Res<()> { + guard_menu(&window)?; + if id.len() > 300 || crate::tray::action_of(&id).is_none() { + return Err(Fail::host("unknown menu item")); + } + crate::menu_panel::select(&window, id); + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 1daea05..a076389 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -56,3 +56,6 @@ pub const COMMANDS: &[&str] = &[ /// The tray card window's commands (capabilities/card.json grants them to the `card` window only). pub const CARD_COMMANDS: &[&str] = &["card_get", "card_action"]; + +/// The tray menu panels' commands (capabilities/menu.json grants them to the `menu-*` windows only). +pub const MENU_COMMANDS: &[&str] = &["menu_state", "menu_ready", "menu_open_sub", "menu_pin_sub", "menu_close_subs", "menu_close", "menu_dismiss", "menu_pin", "menu_select"]; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2ef5046..e616bdd 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ pub mod commands; pub mod confirm; pub mod downloads; pub mod logs; +pub mod menu_panel; pub mod policy; pub mod profiles; pub mod registry; @@ -43,17 +44,53 @@ fn started_hidden() -> bool { std::env::args().any(|a| a == "--hidden") } -/// Tray icon: a click shows the window; the menu (tray.rs) opens the window, reads the stores, lists the apps with their -/// state, mutes notifications, checks for updates, opens Settings and quits (the only real quit on Windows and Linux, -/// where closing the window keeps the app running in the tray). The window refines tooltip, menu and icon after every -/// read (`tray_update`). +/// Tray icon: a left click shows the tray card (card.rs), a right click the app's own menu panels (menu_panel.rs, with +/// the tree from tray.rs: open the window, read the stores, the apps with their state, mute, updates, Settings and Quit, +/// the only real quit on Windows and Linux, where closing the window keeps the app running in the tray). The window +/// refines tooltip, menu and icon after every read (`tray_update`). Linux trays send no clicks: the native menu, +/// rendered from the same tree, stays on the icon there (and anywhere the panels fail). fn tray(app: &tauri::App) -> tauri::Result<()> { - let menu = tray::initial_menu(app.handle())?; - let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false); + let handle = app.handle(); + let panels = handle.state::(); + if let Ok(mut n) = panels.nodes.lock() { + *n = tray::initial_nodes(); + } + let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").show_menu_on_left_click(false); + if cfg!(target_os = "linux") { + panels.native.store(true, std::sync::atomic::Ordering::Relaxed); + b = b.menu(&tray::native_menu(handle, &tray::initial_nodes())?); + } if let Some(icon) = app.default_window_icon() { b = b.icon(icon.clone()); } - b.on_menu_event(|app, e| match tray::action_of(e.id().as_ref()) { + b.on_menu_event(|app, e| dispatch_menu(app, e.id().as_ref())) + .on_tray_icon_event(|t, e| { + let TrayIconEvent::Click { button, button_state: MouseButtonState::Up, rect, position, .. } = e else { return }; + let app = t.app_handle(); + match button { + // right click: the menu panels at the click point + MouseButton::Right => menu_panel::open_root(app, position.x, position.y), + // left click: the tray card next to the icon + MouseButton::Left => { + if app.get_webview_window(card::LABEL).is_none() { + show_main(app); + return; + } + let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0); + let p = rect.position.to_physical::(scale); + let s = rect.size.to_physical::(scale); + card::toggle(app, card::Rect { x: p.x, y: p.y, w: s.width, h: s.height }); + } + _ => {} + } + }) + .build(app)?; + Ok(()) +} + +/// A tray menu pick (a menu panel row or the native fallback menu): tray.rs maps the id to what it does. +pub fn dispatch_menu(app: &AppHandle, id: &str) { + match tray::action_of(id) { Some(tray::Action::Open) => show_main(app), Some(tray::Action::Quit) => app.exit(0), Some(tray::Action::Event(word)) => { @@ -64,23 +101,7 @@ fn tray(app: &tauri::App) -> tauri::Result<()> { let _ = app.emit_to(policy::MAIN, "tray-route", route); } None => {} - }) - .on_tray_icon_event(|t, e| { - // left click: the tray card next to the icon (right click keeps the native menu) - if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, rect, .. } = e { - let app = t.app_handle(); - if app.get_webview_window(card::LABEL).is_none() { - show_main(app); - return; - } - let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0); - let p = rect.position.to_physical::(scale); - let s = rect.size.to_physical::(scale); - card::toggle(app, card::Rect { x: p.x, y: p.y, w: s.width, h: s.height }); - } - }) - .build(app)?; - Ok(()) + } } /// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is @@ -282,7 +303,7 @@ pub fn run() { .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app))) .plugin(tauri_plugin_deep_link::init()) // never restore "visible": a --hidden start would otherwise pop the window up - .plugin(tauri_plugin_window_state::Builder::new().with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build()) + .plugin(tauri_plugin_window_state::Builder::new().with_filter(|label| !label.starts_with(menu_panel::LABEL_PREFIX)).with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build()) .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"]))) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) @@ -337,7 +358,10 @@ pub fn run() { Err(e) => logs::host("warn", &format!("autostart not set: {e}")), } app.manage(state); + app.manage(menu_panel::MenuPanels::default()); tray(app)?; + // pinned tray menu panels of the last session come back + menu_panel::restore(app.handle()); if let Err(e) = card::create(app.handle()) { logs::host("warn", &format!("tray card not created: {e}")); } @@ -438,6 +462,15 @@ pub fn run() { commands::ai_fix_undo, commands::card_get, commands::card_action, + commands::menu_state, + commands::menu_ready, + commands::menu_open_sub, + commands::menu_pin_sub, + commands::menu_close_subs, + commands::menu_close, + commands::menu_dismiss, + commands::menu_pin, + commands::menu_select, ]) .build(tauri::generate_context!()) .expect("storebound desktop failed to start"); diff --git a/apps/desktop/src-tauri/src/menu_panel.rs b/apps/desktop/src-tauri/src/menu_panel.rs new file mode 100644 index 0000000..3809791 --- /dev/null +++ b/apps/desktop/src-tauri/src/menu_panel.rs @@ -0,0 +1,744 @@ +//! The tray's right-click menu, drawn by the app itself instead of the OS menu (tray-menu.html, src/traymenu/). +//! +//! - One tree: tray.rs builds the menu as `Entry`s, turned here into `Node`s. Every submenu has a stable key (its label +//! without the live part), so an open or pinned panel finds its submenu again after the values change. +//! - One window per panel: the root menu and each submenu are their own frameless, always-on-top `menu-N` windows. The +//! page measures itself and calls `menu_ready`; Rust sizes, places (inside the monitor's work area) and shows it. +//! - The panel book (`Book`, pure, tests/menu_panel.rs) records which panels are open, which are pinned and whose child +//! each one is. Transient panels close together (focus left all of them, Esc, a pick); pinned ones stay, can be +//! dragged, step aside so they never cover each other and come back after a restart (`menuPins` in state.json). +//! - Live: every `tray_update` pushes the tree to the open panels (`menu-model`), which update in place. Nothing polls. +//! - Idle: a closed panel is destroyed, never hidden. +//! - Fallback: when a panel window cannot be built or never reports ready, the native tray menu (rendered from the same +//! tree) comes back. Linux always uses it (its tray sends no click events). +//! +//! The top half is pure and covered by tests/menu_panel.rs. +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::tray::Entry; + +pub const LABEL_PREFIX: &str = "menu-"; +pub const PAGE: &str = "tray-menu.html"; +pub const MODEL_EVENT: &str = "menu-model"; +pub const PANEL_EVENT: &str = "menu-panel"; +/// Gap to the work area's edges (physical px at scale 1). +pub const GAP: f64 = 8.0; +/// The estimated size of a panel before it measured itself (logical px). +pub const EST: (f64, f64) = (280.0, 220.0); + +// ---------------------------------------------------------------- model + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Node { + Item { id: String, label: String, enabled: bool }, + Sep, + Sub { key: String, label: String, items: Vec }, +} + +/// A submenu's stable key: its label without status glyphs and without the live part after ':' / '·' / '(' +/// ("Apps (3)" -> "apps", "Apps · 2 need you" -> "apps"). Pinned panels are found again by these keys. +pub fn sub_key(label: &str) -> String { + let head = label.split([':', '\u{00B7}', '(']).next().unwrap_or(""); + let mut out = String::new(); + for c in head.chars() { + if c.is_alphanumeric() { + out.extend(c.to_lowercase()); + } else if (c == ' ' || c == '-' || c == '_') && !out.is_empty() && !out.ends_with('-') { + out.push('-'); + } + } + let out = out.trim_end_matches('-').to_string(); + if out.is_empty() { "sub".into() } else { out } +} + +/// Sibling submenus with the same key get "-2", "-3"... +pub fn unique_keys(mut nodes: Vec) -> Vec { + let mut seen: BTreeMap = BTreeMap::new(); + for n in nodes.iter_mut() { + if let Node::Sub { key, .. } = n { + let count = seen.entry(key.clone()).or_insert(0); + *count += 1; + if *count > 1 { + *key = format!("{key}-{count}"); + } + } + } + nodes +} + +/// The tray menu entries as panel nodes. A submenu keeps the fixed key tray.rs gives it (so a translated label never +/// changes it), else the key comes from its label. +pub fn from_entries(entries: &[Entry]) -> Vec { + let nodes = entries + .iter() + .map(|e| match e { + Entry::Item { id, label, enabled } => Node::Item { id: id.clone(), label: label.clone(), enabled: *enabled }, + Entry::Separator => Node::Sep, + Entry::Sub { key, label, items } => Node::Sub { key: if key.is_empty() { sub_key(label) } else { sub_key(key) }, label: label.clone(), items: from_entries(items) }, + }) + .collect(); + unique_keys(nodes) +} + +/// The items of the submenu at `path` (empty path: the root) and its label. +pub fn find<'a>(nodes: &'a [Node], path: &[String]) -> Option<(&'a [Node], Option<&'a str>)> { + let mut current = nodes; + let mut label = None; + for key in path { + let (items, l) = current.iter().find_map(|n| match n { + Node::Sub { key: k, label: l, items } if k == key => Some((items.as_slice(), l.as_str())), + _ => None, + })?; + current = items; + label = Some(l); + } + Some((current, label)) +} + +// ---------------------------------------------------------------- placement + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Anchor { + /// The tray click point (physical). + Point { x: f64, y: f64 }, + /// Beside a parent panel: its left and right edge and the row's top (physical). + Beside { left: f64, right: f64, top: f64 }, + /// A remembered top-left (pinned panels). + At { x: f64, y: f64 }, +} + +/// x, y, width, height (physical px). +pub type Rect = (f64, f64, f64, f64); + +/// Keeps a panel of size w x h fully inside the work area (with `GAP`). +pub fn clamp_into(x: f64, y: f64, w: f64, h: f64, work: Rect) -> (f64, f64) { + let (wx, wy, ww, wh) = work; + (x.clamp(wx + GAP, (wx + ww - w - GAP).max(wx + GAP)), y.clamp(wy + GAP, (wy + wh - h - GAP).max(wy + GAP))) +} + +/// Where a panel of `size` goes for `anchor`, inside the work area: the root opens away from the taskbar / menu bar the +/// click is near, a submenu beside its parent (flipped left at the screen edge), a pinned panel at its spot. +pub fn place_menu(anchor: Anchor, size: (f64, f64), work: Rect) -> (f64, f64) { + let (w, h) = size; + let (wx, wy, ww, wh) = work; + let (x, y) = match anchor { + Anchor::Point { x, y } => (if x + w > wx + ww { x - w } else { x }, if y + h > wy + wh { y - h } else { y }), + Anchor::Beside { left, right, top } => (if right + w <= wx + ww - GAP { right - 2.0 } else { left - w + 2.0 }, top - 5.0), + Anchor::At { x, y } => (x, y), + }; + clamp_into(x, y, w, h, work) +} + +pub fn overlaps(a: Rect, b: Rect) -> bool { + a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3 +} + +/// A spot for a newly pinned panel that covers no other pinned panel: below whatever it overlaps, then the next column. +/// Gives up after a while (a screen full of pins) and returns the clamped wish. +pub fn free_spot(want: Rect, others: &[Rect], work: Rect) -> (f64, f64) { + let (mut x, mut y, w, h) = want; + for _ in 0..64 { + let Some(hit) = others.iter().find(|o| overlaps((x, y, w, h), **o)) else { + return clamp_into(x, y, w, h, work); + }; + y = hit.1 + hit.3 + GAP; + if y + h > work.1 + work.3 { + y = work.1 + GAP; + x += w + GAP; + if x + w > work.0 + work.2 { + x = work.0 + GAP; + } + } + } + clamp_into(want.0, want.1, w, h, work) +} + +// ---------------------------------------------------------------- panel book + +#[derive(Clone, Debug, PartialEq)] +pub struct Panel { + pub path: Vec, + pub pinned: bool, + pub parent: Option, + pub anchor: Anchor, + /// Measured and shown once. + pub shown: bool, + /// Physical top-left once shown (kept for pins). + pub pos: Option<(f64, f64)>, + /// Opened from the keyboard: takes focus and starts on its first row. + pub focus: bool, +} + +/// A pinned panel as state.json keeps it (`menuPins`). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct PinRecord { + pub path: Vec, + pub x: i32, + pub y: i32, +} + +/// Which panels are open, which are pinned, who is whose child. +#[derive(Default, Debug)] +pub struct Book { + pub panels: BTreeMap, + next: u32, +} + +impl Book { + fn add(&mut self, path: Vec, pinned: bool, parent: Option, anchor: Anchor) -> String { + self.next += 1; + let label = format!("{LABEL_PREFIX}{}", self.next); + self.panels.insert(label.clone(), Panel { path, pinned, parent, anchor, shown: false, pos: None, focus: false }); + label + } + + fn remove_all(&mut self, labels: &[String]) { + for l in labels { + self.panels.remove(l); + } + } + + pub fn transient(&self) -> Vec { + self.panels.iter().filter(|(_, p)| !p.pinned).map(|(l, _)| l.clone()).collect() + } + + /// Transient panels below `label` (children first). + pub fn descendants(&self, label: &str) -> Vec { + let mut out = Vec::new(); + let mut frontier = vec![label.to_string()]; + while let Some(l) = frontier.pop() { + for (child, p) in &self.panels { + if p.parent.as_deref() == Some(l.as_str()) && !p.pinned && !out.contains(child) { + out.push(child.clone()); + frontier.push(child.clone()); + } + } + } + out + } + + /// A fresh root menu: every transient panel closes, pinned ones stay. Returns the new label and the closed ones. + pub fn open_root(&mut self, anchor: Anchor) -> (String, Vec) { + let closes = self.transient(); + self.remove_all(&closes); + (self.add(vec![], false, None, anchor), closes) + } + + /// Opens submenu `key` of `parent`; its other transient children close. None when that submenu already is the open + /// child, or the parent is gone. `pinned`: torn off as its own pinned panel (one pinned copy per submenu). + pub fn open_sub(&mut self, parent: &str, key: &str, anchor: Anchor, pinned: bool) -> Option<(String, Vec)> { + let mut path = self.panels.get(parent)?.path.clone(); + path.push(key.to_string()); + if !pinned && self.panels.values().any(|p| p.parent.as_deref() == Some(parent) && !p.pinned && p.path == path) { + return None; + } + let mut closes = self.descendants(parent); + self.remove_all(&closes); + if pinned { + let old: Vec = self.panels.iter().filter(|(_, p)| p.pinned && p.path == path).map(|(l, _)| l.clone()).collect(); + for l in old { + closes.extend(self.close(&l)); + } + return Some((self.add(path, true, None, anchor), closes)); + } + Some((self.add(path, false, Some(parent.to_string()), anchor), closes)) + } + + /// Closes `label` and its transient children. + pub fn close(&mut self, label: &str) -> Vec { + if !self.panels.contains_key(label) { + return vec![]; + } + let mut out = self.descendants(label); + out.push(label.to_string()); + self.remove_all(&out); + out + } + + /// Every transient panel closes (Esc on the root, a pick, focus left the menus). + pub fn dismiss(&mut self) -> Vec { + let t = self.transient(); + self.remove_all(&t); + t + } + + /// Pin or unpin. Pinning detaches the panel from its chain: the transient panels that are not its own children + /// close (returned). A second pinned copy of the same submenu closes too. + pub fn pin(&mut self, label: &str, on: bool) -> Vec { + let Some(p) = self.panels.get(label) else { return vec![] }; + let path = p.path.clone(); + if !on { + if let Some(p) = self.panels.get_mut(label) { + p.pinned = false; + } + return vec![]; + } + let mut closes: Vec = self.panels.iter().filter(|(l, p)| p.pinned && p.path == path && l.as_str() != label).map(|(l, _)| l.clone()).collect(); + if let Some(p) = self.panels.get_mut(label) { + p.pinned = true; + p.parent = None; + } + let mine = self.descendants(label); + closes.extend(self.transient().into_iter().filter(|l| l != label && !mine.contains(l))); + self.remove_all(&closes); + closes + } + + /// The tree changed: transient panels whose submenu is gone close (with their children). Pinned ones stay and show + /// that their submenu is gone until it comes back (an app that is read again) or they are closed. + pub fn prune(&mut self, nodes: &[Node]) -> Vec { + let gone: Vec = self.panels.iter().filter(|(_, p)| !p.pinned && find(nodes, &p.path).is_none()).map(|(l, _)| l.clone()).collect(); + let mut out = Vec::new(); + for l in gone { + if self.panels.contains_key(&l) { + out.extend(self.close(&l)); + } + } + out + } + + /// The pinned panels to save (only placed ones; in label order, so the order is stable). + pub fn pins(&self) -> Vec { + self.panels + .values() + .filter(|p| p.pinned) + .filter_map(|p| p.pos.map(|(x, y)| PinRecord { path: p.path.clone(), x: x.round() as i32, y: y.round() as i32 })) + .collect() + } + + /// Recreates pinned panels from a saved list (duplicates and more than `MAX_PINS` dropped). + pub fn restore(&mut self, pins: &[PinRecord]) -> Vec { + let mut out = Vec::new(); + for r in pins.iter().take(MAX_PINS) { + if self.panels.values().any(|p| p.pinned && p.path == r.path) || r.path.len() > 8 { + continue; + } + let (x, y) = (r.x as f64, r.y as f64); + let l = self.add(r.path.clone(), true, None, Anchor::At { x, y }); + if let Some(p) = self.panels.get_mut(&l) { + p.pos = Some((x, y)); + } + out.push(l); + } + out + } +} + +/// At most this many pinned panels are restored after a restart. +pub const MAX_PINS: usize = 12; + +/// A panel label the app made (`menu-` and a number), for the window / command checks. +pub fn is_menu_label(label: &str) -> bool { + label.strip_prefix(LABEL_PREFIX).is_some_and(|n| !n.is_empty() && n.len() <= 9 && n.chars().all(|c| c.is_ascii_digit())) +} + +// ---------------------------------------------------------------- runtime + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PanelInfo { + pub label: String, + pub path: Vec, + pub pinned: bool, + pub has_parent: bool, + pub focus_first: bool, + pub nodes: Vec, +} + +/// The panels' shared state (managed by the app). +#[derive(Default)] +pub struct MenuPanels { + pub book: Mutex, + pub nodes: Mutex>, + /// The native menu took over (a panel failed, or Linux). + pub native: AtomicBool, + /// Bumped on every move of a pinned panel; the save runs once the moves stop. + moves: AtomicU64, +} + +#[cfg(desktop)] +mod runtime { + use super::*; + use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + + fn state(app: &AppHandle) -> tauri::State<'_, MenuPanels> { + app.state::() + } + + fn book(app: &AppHandle, f: impl FnOnce(&mut Book) -> R) -> R { + let s = state(app); + let mut b = s.book.lock().unwrap_or_else(|e| e.into_inner()); + f(&mut b) + } + + fn nodes(app: &AppHandle) -> Vec { + state(app).nodes.lock().map(|n| n.clone()).unwrap_or_default() + } + + pub fn native_fallback(app: &AppHandle) -> bool { + state(app).native.load(Ordering::Relaxed) + } + + /// Puts the native menu (from the current tree) on the tray: the fallback, and Linux. + pub fn set_native(app: &AppHandle) { + let Some(tray) = app.tray_by_id("main") else { return }; + match crate::tray::native_menu(app, &nodes(app)) { + Ok(m) => { + let _ = tray.set_menu(Some(m)); + } + Err(e) => crate::logs::host("warn", &format!("native tray menu not built: {e}")), + } + } + + /// Writes the pinned panels into state.json (`menuPins`). + fn save_pins(app: &AppHandle) { + let pins = book(app, |b| b.pins()); + let Some(s) = app.try_state::() else { return }; + let Ok(mut st) = s.saved.lock() else { return }; + if st.menu_pins == pins { + return; + } + st.menu_pins = pins; + if let Err(e) = crate::store::save(&s.data_dir, &st) { + crate::logs::host("warn", &format!("menu pins not saved: {e}")); + } + } + + /// Stores the latest tree and pushes it to every open panel (they update in place). Panels of a submenu that is gone + /// close when transient. Cheap when nothing changed or no panel is open. + pub fn set_nodes(app: &AppHandle, next: Vec) { + let changed = { + let s = state(app); + let mut g = s.nodes.lock().unwrap_or_else(|e| e.into_inner()); + let changed = *g != next; + *g = next.clone(); + changed + }; + if !changed { + return; + } + let (closes, open) = book(app, |b| (b.prune(&next), b.panels.keys().cloned().collect::>())); + destroy(app, &closes); + for l in open { + let _ = app.emit_to(l.as_str(), MODEL_EVENT, &next); + } + if native_fallback(app) { + set_native(app); + } + } + + fn destroy(app: &AppHandle, labels: &[String]) { + for l in labels { + if let Some(w) = app.get_webview_window(l) { + let _ = w.destroy(); + } + } + } + + fn build_window(app: &AppHandle, label: &str) -> tauri::Result { + let b = WebviewWindowBuilder::new(app, label, WebviewUrl::App(PAGE.into())) + .title("storebound") + .inner_size(EST.0, EST.1) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .shadow(true) + .visible(false) + .focused(false); + #[cfg(not(target_os = "macos"))] + let b = b.transparent(true); + let window = b.build()?; + #[cfg(target_os = "macos")] + let _ = window.set_visible_on_all_workspaces(true); + let handle = app.clone(); + let me = label.to_string(); + window.on_window_event(move |event| match event { + tauri::WindowEvent::Focused(false) => { + let handle = handle.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(160)); + close_transient_unless_focused(&handle); + }); + } + tauri::WindowEvent::Moved(pos) => { + let pinned = book(&handle, |b| match b.panels.get_mut(&me) { + Some(p) if p.shown => { + p.pos = Some((pos.x as f64, pos.y as f64)); + p.pinned + } + _ => false, + }); + if pinned { + // a drag sends many moves: save once they stop + let n = state(&handle).moves.fetch_add(1, Ordering::Relaxed) + 1; + let handle = handle.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(400)); + if state(&handle).moves.load(Ordering::Relaxed) == n { + save_pins(&handle); + } + }); + } + } + _ => {} + }); + Ok(window) + } + + /// Focus left a menu window: every transient panel closes unless one of them has focus now. + fn close_transient_unless_focused(app: &AppHandle) { + let transient = book(app, |b| b.transient()); + if transient.iter().any(|l| app.get_webview_window(l).and_then(|w| w.is_focused().ok()).unwrap_or(false)) { + return; + } + book(app, |b| { + for l in &transient { + b.panels.remove(l); + } + }); + destroy(app, &transient); + } + + /// The panels cannot be shown: the native menu goes on the tray and opens once now. + fn native_popup(app: &AppHandle) { + if !state(app).native.swap(true, Ordering::Relaxed) { + crate::logs::host("warn", "tray menu panels unavailable: the native menu is used"); + } + set_native(app); + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + if let (Some(w), Ok(menu)) = (handle.get_webview_window(crate::policy::MAIN), crate::tray::native_menu(&handle, &nodes(&handle))) { + let _ = w.popup_menu(&menu); + } + }); + } + + /// A right click on the tray icon: a fresh root menu at the click point (physical px). + pub fn open_root(app: &AppHandle, x: f64, y: f64) { + if native_fallback(app) { + return; // the native menu is on the tray and opens by itself + } + let (label, closes) = book(app, |b| b.open_root(Anchor::Point { x, y })); + destroy(app, &closes); + let handle = app.clone(); + std::thread::spawn(move || { + if let Err(e) = build_window(&handle, &label) { + crate::logs::host("warn", &format!("tray menu panel not built: {e}")); + book(&handle, |b| b.panels.remove(&label)); + native_popup(&handle); + return; + } + std::thread::sleep(Duration::from_millis(2500)); + if book(&handle, |b| b.panels.get(&label).map(|p| p.shown)) == Some(false) { + crate::logs::host("warn", "tray menu panel never reported ready"); + let closes = book(&handle, |b| b.close(&label)); + destroy(&handle, &closes); + native_popup(&handle); + } + }); + } + + /// Brings back the pinned panels of the last session (state.json `menuPins`). + pub fn restore(app: &AppHandle) { + let pins = app.try_state::().and_then(|s| s.saved.lock().ok().map(|st| st.menu_pins.clone())).unwrap_or_default(); + if pins.is_empty() || native_fallback(app) { + return; + } + let labels = book(app, |b| b.restore(&pins)); + let handle = app.clone(); + std::thread::spawn(move || { + for l in labels { + if build_window(&handle, &l).is_err() { + book(&handle, |b| b.panels.remove(&l)); + } + } + }); + } + + fn work_area(app: &AppHandle, x: f64, y: f64) -> Option { + let m = app.monitor_from_point(x, y).ok().flatten().or_else(|| app.primary_monitor().ok().flatten())?; + let w = m.work_area(); + Some((w.position.x as f64, w.position.y as f64, w.size.width as f64, w.size.height as f64)) + } + + fn anchor_point(a: Anchor) -> (f64, f64) { + match a { + Anchor::Point { x, y } | Anchor::At { x, y } => (x, y), + Anchor::Beside { right, top, .. } => (right, top), + } + } + + fn outer_rect(w: &WebviewWindow) -> Option { + let p = w.outer_position().ok()?; + let s = w.outer_size().ok()?; + Some((p.x as f64, p.y as f64, s.width as f64, s.height as f64)) + } + + fn pinned_rects(app: &AppHandle, skip: &str) -> Vec { + let labels: Vec = book(app, |b| b.panels.iter().filter(|(l, p)| p.pinned && l.as_str() != skip).map(|(l, _)| l.clone()).collect()); + labels.iter().filter_map(|l| app.get_webview_window(l).and_then(|w| outer_rect(&w))).collect() + } + + // ---- what the panel page calls (commands.rs checks the window first) ---- + + pub fn panel_state(window: &WebviewWindow) -> Option { + let app = window.app_handle(); + let p = book(app, |b| b.panels.get(window.label()).cloned())?; + Some(PanelInfo { label: window.label().into(), path: p.path, pinned: p.pinned, has_parent: p.parent.is_some(), focus_first: p.focus, nodes: nodes(app) }) + } + + /// The panel measured itself (CSS px): size it, place it inside the work area, show it the first time. + pub fn ready(window: &WebviewWindow, width: f64, height: f64) { + let app = window.app_handle().clone(); + let Some(panel) = book(&app, |b| b.panels.get(window.label()).cloned()) else { return }; + let scale = window.scale_factor().unwrap_or(1.0); + let size = ((width.clamp(120.0, 600.0) * scale).ceil(), (height.clamp(40.0, 900.0) * scale).ceil()); + let (ax, ay) = panel.pos.unwrap_or_else(|| anchor_point(panel.anchor)); + let Some(work) = work_area(&app, ax, ay) else { return }; + let (x, y) = if panel.shown { + let (x, y, _, _) = outer_rect(window).unwrap_or((ax, ay, 0.0, 0.0)); + clamp_into(x, y, size.0, size.1, work) + } else if let Some((x, y)) = panel.pos { + clamp_into(x, y, size.0, size.1, work) + } else { + place_menu(panel.anchor, size, work) + }; + let _ = window.set_size(PhysicalSize::new(size.0 as u32, size.1 as u32)); + let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32)); + if !panel.shown { + book(&app, |b| { + if let Some(p) = b.panels.get_mut(window.label()) { + p.shown = true; + p.pos = Some((x, y)); + } + }); + let _ = window.show(); + if panel.focus || (panel.parent.is_none() && !panel.pinned) { + let _ = window.set_focus(); + } + if panel.pinned { + save_pins(&app); + } + } + } + + /// Opens submenu `key` beside this panel at the row whose top is `top` (CSS px). `pinned`: torn off (Ctrl / middle + /// click), placed so it covers no other pinned panel. + pub fn open_sub(window: &WebviewWindow, key: &str, top: f64, focus: bool, pinned: bool) { + let app = window.app_handle().clone(); + let Some((wx, wy, ww, _)) = outer_rect(window) else { return }; + let scale = window.scale_factor().unwrap_or(1.0); + let anchor = Anchor::Beside { left: wx, right: wx + ww, top: wy + top.max(0.0) * scale }; + let Some((label, closes)) = book(&app, |b| b.open_sub(window.label(), key, anchor, pinned)) else { return }; + destroy(&app, &closes); + if pinned { + let others = pinned_rects(&app, &label); + if let Some(work) = work_area(&app, wx, wy) { + let est = (EST.0 * scale, EST.1 * scale); + let (x, y) = place_menu(anchor, est, work); + let spot = free_spot((x, y, est.0, est.1), &others, work); + book(&app, |b| { + if let Some(p) = b.panels.get_mut(&label) { + p.pos = Some(spot); + } + }); + } + } + book(&app, |b| { + if let Some(p) = b.panels.get_mut(&label) { + p.focus = focus; + } + }); + let handle = app.clone(); + std::thread::spawn(move || { + if build_window(&handle, &label).is_err() { + book(&handle, |b| b.panels.remove(&label)); + } + }); + } + + /// The pointer moved to a plain row: this panel's transient submenus close. + pub fn close_subs(window: &WebviewWindow) { + let app = window.app_handle().clone(); + let closes = book(&app, |b| { + let d = b.descendants(window.label()); + for l in &d { + b.panels.remove(l); + } + d + }); + destroy(&app, &closes); + } + + /// Closes this panel (Left / Esc in a submenu, X on a pinned one). + pub fn close(window: &WebviewWindow, refocus_parent: bool) { + let app = window.app_handle().clone(); + let (closes, parent, was_pinned) = book(&app, |b| { + let p = b.panels.get(window.label()).cloned(); + (b.close(window.label()), p.as_ref().and_then(|p| p.parent.clone()), p.is_some_and(|p| p.pinned)) + }); + if refocus_parent { + if let Some(w) = parent.and_then(|p| app.get_webview_window(&p)) { + let _ = w.set_focus(); + let _ = app.emit_to(w.label(), PANEL_EVENT, "focus"); + } + } + destroy(&app, &closes); + if was_pinned { + save_pins(&app); + } + } + + /// Esc on the root menu: every transient panel closes. + pub fn dismiss(app: &AppHandle) { + let closes = book(app, |b| b.dismiss()); + destroy(app, &closes); + } + + /// Pin or unpin this panel. Pinning steps it aside from the other pinned panels. + pub fn pin(window: &WebviewWindow, on: bool) { + let app = window.app_handle().clone(); + let label = window.label().to_string(); + let closes = book(&app, |b| b.pin(&label, on)); + destroy(&app, &closes); + if on { + let others = pinned_rects(&app, &label); + if let Some(me) = outer_rect(window) { + if let Some(work) = work_area(&app, me.0, me.1) { + let (x, y) = free_spot(me, &others, work); + if (x, y) != (me.0, me.1) { + let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32)); + } + book(&app, |b| { + if let Some(p) = b.panels.get_mut(&label) { + p.pos = Some((x, y)); + } + }); + } + } + } + let _ = app.emit_to(label.as_str(), PANEL_EVENT, if on { "pinned" } else { "unpinned" }); + save_pins(&app); + } + + /// A pick: the same action as the native menu. Transient panels close; pinned ones stay and update in place. + pub fn select(window: &WebviewWindow, id: String) { + let app = window.app_handle().clone(); + let pinned = book(&app, |b| b.panels.get(window.label()).map(|p| p.pinned).unwrap_or(false)); + if !pinned { + dismiss(&app); + } + let handle = app.clone(); + let _ = app.run_on_main_thread(move || crate::dispatch_menu(&handle, &id)); + } +} + +#[cfg(desktop)] +pub use runtime::*; diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index 8d6cfff..3381956 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -56,6 +56,17 @@ pub fn require_card(label: &str, url: &Url, dev: bool) -> Result<(), String> { Ok(()) } +/// The tray menu panels (`menu-1`, `menu-2`... menu_panel.rs): they may call only the menu commands. +pub fn require_menu(label: &str, url: &Url, dev: bool) -> Result<(), String> { + if !crate::menu_panel::is_menu_label(label) { + return Err(format!("not allowed from window '{label}'")); + } + if !is_local_origin(url, dev) { + return Err("not allowed from this page".into()); + } + Ok(()) +} + /// Method kinds the sidecar announces in its `session.hello` descriptors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 5d2c567..f999653 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -45,11 +45,14 @@ pub struct State { pub overrides: Overrides, #[serde(default)] pub settings: Settings, + /// Pinned tray menu panels and where they sit (menu_panel.rs); restored at start. + #[serde(default, rename = "menuPins", skip_serializing_if = "Vec::is_empty")] + pub menu_pins: Vec, } impl Default for State { fn default() -> State { - State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default() } + State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] } } } @@ -121,6 +124,7 @@ pub fn migrate(mut v: Value) -> (State, u32, Vec) { studios: list(&o, "studios", &mut notes), overrides: one(&o, "overrides", &mut notes), settings: one(&o, "settings", &mut notes), + menu_pins: list(&o, "menuPins", &mut notes), }; // two entries with one id (hand-edited or merged files): the first one wins let mut seen = std::collections::HashSet::new(); diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index e3a653f..0b23b9f 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -1,7 +1,7 @@ //! The tray: tooltip, attention icon, context menu and the "where to go" memory of the last notification. //! //! The window pushes a small summary after every store read (`tray_update`); this module turns it into a menu model -//! (pure, tested in tests/tray.rs), rebuilds the menu, sets the tooltip and swaps the icon for one with a red dot while +//! (pure, tested in tests/tray.rs), pushes it to the menu panels (menu_panel.rs), sets the tooltip and swaps the icon for one with a red dot while //! something needs the owner. Menu clicks come back as events to the window: `tray-route` (a route as JSON) and //! `tray-action` (`read`, `mute-toggle`). //! @@ -14,7 +14,9 @@ use std::time::{Duration, Instant}; use serde::Deserialize; use serde_json::Value; use tauri::image::Image; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; + +use crate::menu_panel::{from_entries, Node}; use tauri::{AppHandle, Runtime}; pub const ID_OPEN: &str = "tray-open"; @@ -88,7 +90,8 @@ pub struct TraySummary { pub enum Entry { Item { id: String, label: String, enabled: bool }, Separator, - Sub { label: String, items: Vec }, + /// `key`: the submenu's fixed key (menu_panel::sub_key), the same in every language. + Sub { key: String, label: String, items: Vec }, } fn clean(s: &str, max: usize) -> String { @@ -132,7 +135,7 @@ pub fn menu_model(s: &TraySummary) -> Vec { item(ID_OPEN, &l.open), item(ID_READ, &l.read_now), Entry::Separator, - Entry::Sub { label: clean(&l.apps, 40), items: apps }, + Entry::Sub { key: "apps".into(), label: clean(&l.apps, 40), items: apps }, Entry::Separator, item(ID_MUTE, if s.muted { &l.unmute } else { &l.mute }), item(ID_UPDATE, &l.update), @@ -233,40 +236,45 @@ pub fn take_pending() -> Option { static LAST: Mutex> = Mutex::new(None); -fn build_menu(app: &AppHandle, model: &[Entry]) -> tauri::Result> { - let menu = Menu::new(app)?; - for e in model { - match e { - Entry::Item { id, label, enabled } => menu.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?, - Entry::Separator => menu.append(&PredefinedMenuItem::separator(app)?)?, - Entry::Sub { label, items } => { - let sub = Submenu::new(app, label, true)?; - for it in items { - if let Entry::Item { id, label, enabled } = it { - sub.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?; - } - } - menu.append(&sub)?; +/// The native menu items for a panel tree (the fallback, and Linux), nested to any depth. +fn native_items(app: &AppHandle, nodes: &[Node]) -> tauri::Result>>> { + let mut out: Vec>> = Vec::new(); + for n in nodes { + match n { + Node::Item { id, label, enabled } => out.push(Box::new(MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)), + Node::Sep => out.push(Box::new(PredefinedMenuItem::separator(app)?)), + Node::Sub { label, items, .. } => { + let children = native_items(app, items)?; + let refs: Vec<&dyn IsMenuItem> = children.iter().map(|b| b.as_ref()).collect(); + out.push(Box::new(Submenu::with_items(app, label, true, &refs)?)); } } } - Ok(menu) + Ok(out) } -/// The menu shown before the window has sent anything (English words). -pub fn initial_menu(app: &AppHandle) -> tauri::Result> { - build_menu(app, &menu_model(&TraySummary::default())) +/// The native tray menu, rendered from the same tree as the panels. +pub fn native_menu(app: &AppHandle, nodes: &[Node]) -> tauri::Result> { + let items = native_items(app, nodes)?; + let refs: Vec<&dyn IsMenuItem> = items.iter().map(|b| b.as_ref()).collect(); + Menu::with_items(app, &refs) } -/// Applies a summary: tooltip, menu and icon of the tray with id `main`. Skips the work when nothing changed. -pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> { +/// The tree before the window has sent anything (English words). +pub fn initial_nodes() -> Vec { + from_entries(&menu_model(&TraySummary::default())) +} + +/// Applies a summary: tooltip and icon of the tray with id `main`, and the menu tree, which goes live to the open menu +/// panels (and to the native menu when that is in use). Skips the work when nothing changed. +pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> { let Some(tray) = app.tray_by_id("main") else { return Ok(()) }; let key = format!("{s:?}"); if LAST.lock().ok().is_some_and(|l| l.as_deref() == Some(key.as_str())) { return Ok(()); } tray.set_tooltip(Some(tooltip_text(s)))?; - tray.set_menu(Some(build_menu(app, &menu_model(s))?))?; + crate::menu_panel::set_nodes(app, from_entries(&menu_model(s))); if let Some(icon) = app.default_window_icon() { let img = if s.attention > 0 { Image::new_owned(with_attention_dot(icon.rgba(), icon.width(), icon.height()), icon.width(), icon.height()) } else { icon.clone() }; tray.set_icon(Some(img))?; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c786df1..b95e93c 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -50,7 +50,7 @@ "form-action": "'none'" }, "freezePrototype": true, - "capabilities": ["main-window", "card-window"] + "capabilities": ["main-window", "card-window", "menu-window"] } }, "bundle": { diff --git a/apps/desktop/src-tauri/tests/menu_panel.rs b/apps/desktop/src-tauri/tests/menu_panel.rs new file mode 100644 index 0000000..0f89878 --- /dev/null +++ b/apps/desktop/src-tauri/tests/menu_panel.rs @@ -0,0 +1,203 @@ +//! The tray menu panels' pure half: the tree and its stable keys, live updates, placement inside the work area, the +//! panel book (transient vs pinned, parents, restore after a restart). +use storebound_desktop::menu_panel::*; +use storebound_desktop::store::State; +use storebound_desktop::tray::{menu_model, Entry, TrayApp, TrayLabels, TraySummary}; + +const WORK: Rect = (0.0, 0.0, 1920.0, 1040.0); +const AT: Anchor = Anchor::At { x: 0.0, y: 0.0 }; + +fn summary(state: &str, apps_label: &str) -> TraySummary { + TraySummary { + apps: vec![TrayApp { key: "k1".into(), name: "First".into(), state: state.into() }, TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }], + labels: TrayLabels { apps: apps_label.into(), ..TrayLabels::default() }, + ..TraySummary::default() + } +} + +fn tree() -> Vec { + from_entries(&[ + Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }, + Entry::Separator, + Entry::Sub { key: String::new(), label: "Fleet".into(), items: vec![Entry::Sub { key: String::new(), label: "Recently lowered".into(), items: vec![] }] }, + Entry::Sub { key: String::new(), label: "Profile: Work".into(), items: vec![] }, + ]) +} + +#[test] +fn keys_ignore_live_parts_and_stay_unique() { + assert_eq!(sub_key("Profile: Work"), "profile"); + assert_eq!(sub_key("\u{25CF} First \u{00B7} ok"), "first"); + assert_eq!(sub_key("Queue (1 running, 2 queued)"), "queue"); + assert_eq!(sub_key("\u{2716}"), "sub"); + let nodes = unique_keys(vec![Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }, Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }]); + assert!(matches!(&nodes[1], Node::Sub { key, .. } if key == "a-2")); +} + +#[test] +fn find_walks_keys() { + let t = tree(); + let (items, label) = find(&t, &["fleet".into(), "recently-lowered".into()]).unwrap(); + assert_eq!((items.len(), label), (0, Some("Recently lowered"))); + assert!(find(&t, &["nope".into()]).is_none()); + assert_eq!(find(&t, &[]).unwrap().0.len(), 4); +} + +#[test] +fn the_tray_tree_keeps_its_apps_key_in_every_language_and_value() { + let en = from_entries(&menu_model(&summary("App Store: In review", "Apps"))); + let tr = from_entries(&menu_model(&summary("App Store: Live", "Uygulamalar"))); + let key = |n: &[Node]| n.iter().find_map(|n| if let Node::Sub { key, .. } = n { Some(key.clone()) } else { None }).unwrap(); + assert_eq!(key(&en), "apps"); + assert_eq!(key(&tr), "apps"); + // a live value changed: the tree differs (so it is pushed), the open Apps panel still resolves + assert_ne!(en, tr); + let (items, _) = find(&tr, &["apps".into()]).unwrap(); + assert!(matches!(&items[0], Node::Item { id, label, .. } if id == "tray-app:k1" && label.contains("Live"))); + // the same summary twice: the same tree (nothing is pushed) + assert_eq!(from_entries(&menu_model(&summary("x", "Apps"))), from_entries(&menu_model(&summary("x", "Apps")))); +} + +#[test] +fn the_tree_serializes_as_the_panel_page_reads_it() { + let v = serde_json::to_value(tree()).unwrap(); + assert_eq!(v[0], serde_json::json!({ "kind": "item", "id": "open", "label": "Open", "enabled": true })); + assert_eq!(v[1], serde_json::json!({ "kind": "sep" })); + assert_eq!(v[2]["kind"], "sub"); + assert_eq!(v[2]["key"], "fleet"); +} + +#[test] +fn root_opens_away_from_the_taskbar_and_stays_inside() { + // bottom taskbar, icon at the right: above and left of the click + let (x, y) = place_menu(Anchor::Point { x: 1900.0, y: 1060.0 }, (300.0, 400.0), WORK); + assert!(x + 300.0 <= 1920.0 - GAP && y + 400.0 <= 1040.0 - GAP && x >= 0.0 && y >= 0.0); + // macOS menu bar at the top: below it + let (_, y) = place_menu(Anchor::Point { x: 1800.0, y: 0.0 }, (300.0, 400.0), (0.0, 25.0, 1920.0, 1055.0)); + assert!(y >= 25.0 + GAP); + // a second monitor left of the primary (negative x) + let (x, _) = place_menu(Anchor::Point { x: -10.0, y: 500.0 }, (300.0, 400.0), (-1920.0, 0.0, 1920.0, 1080.0)); + assert!(x + 300.0 <= -GAP + 0.001 && x >= -1920.0); + // a panel taller than the work area: pinned to the top edge, never above it + let (_, y) = place_menu(Anchor::Point { x: 100.0, y: 100.0 }, (300.0, 2000.0), WORK); + assert_eq!(y, GAP); +} + +#[test] +fn submenus_open_beside_and_flip_left_at_the_edge() { + let (x, _) = place_menu(Anchor::Beside { left: 1600.0, right: 1880.0, top: 100.0 }, (280.0, 200.0), WORK); + assert!(x + 280.0 <= 1602.0); + let (x, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 100.0 }, (280.0, 200.0), WORK); + assert!((378.0..=380.0).contains(&x) && (90.0..=100.0).contains(&y)); + // a row near the bottom: the submenu moves up into the work area + let (_, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 1000.0 }, (280.0, 300.0), WORK); + assert!(y + 300.0 <= 1040.0 - GAP); +} + +#[test] +fn pinned_panels_step_aside_and_stay_inside() { + let others = [(100.0, 100.0, 280.0, 300.0), (100.0, 408.0, 280.0, 300.0)]; + let (x, y) = free_spot((120.0, 120.0, 280.0, 300.0), &others, WORK); + assert!(others.iter().all(|o| !overlaps((x, y, 280.0, 300.0), *o))); + assert!(x >= 0.0 && y >= 0.0 && x + 280.0 <= 1920.0 && y + 300.0 <= 1040.0); + // no room left below: the next column + let col = [(8.0, 8.0, 280.0, 1000.0)]; + let (x, _) = free_spot((8.0, 8.0, 280.0, 300.0), &col, WORK); + assert!(x >= 288.0); + // nothing in the way: where it was + assert_eq!(free_spot((500.0, 500.0, 280.0, 300.0), &others, WORK), (500.0, 500.0)); +} + +#[test] +fn several_menus_open_and_pinned_ones_survive_a_new_menu() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap(); + assert!(b.open_sub(&root, "fleet", AT, false).is_none(), "the same submenu again is a no-op"); + let (deep, _) = b.open_sub(&fleet, "recently-lowered", AT, false).unwrap(); + assert_eq!(b.descendants(&root).len(), 2); + // pinning the Fleet panel detaches it; the root closes, its own child stays its own + let closes = b.pin(&fleet, true); + assert_eq!(closes, vec![root.clone()]); + assert!(b.panels[&fleet].pinned && b.panels[&fleet].parent.is_none()); + assert_eq!(b.descendants(&fleet), vec![deep.clone()]); + let (root2, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + assert_eq!(closes, vec![deep]); + assert!(b.panels.contains_key(&fleet)); + assert_eq!(b.transient(), vec![root2.clone()]); + // a pick / Esc: transient panels go, the pinned one stays + assert_eq!(b.dismiss(), vec![root2]); + assert_eq!(b.panels.len(), 1); + // unpinned, it behaves like a menu again and goes with the next one + b.pin(&fleet, false); + let (_, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + assert!(closes.contains(&fleet)); +} + +#[test] +fn a_torn_off_submenu_keeps_one_pinned_copy() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (p1, _) = b.open_sub(&root, "fleet", AT, true).unwrap(); + let (p2, closes) = b.open_sub(&root, "fleet", AT, true).unwrap(); + assert!(closes.contains(&p1) && b.panels[&p2].pinned && !b.panels.contains_key(&p1)); + assert_eq!(b.panels[&p2].path, vec!["fleet".to_string()]); + // the root stays open (a tear-off is not a pick) + assert!(b.panels.contains_key(&root)); + // closing a panel that is already gone does nothing + assert!(b.close("menu-99").is_empty()); +} + +#[test] +fn a_live_update_closes_nothing_that_still_exists() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (profile, _) = b.open_sub(&root, "profile", AT, true).unwrap(); + let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap(); + assert!(b.prune(&tree()).is_empty()); + // the Fleet submenu is gone from the tree: its transient panel closes, the pinned Profile panel stays even when its + // submenu goes too (it shows that it is gone) + let smaller = from_entries(&[Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }]); + assert_eq!(b.prune(&smaller), vec![fleet]); + assert!(b.panels.contains_key(&root) && b.panels.contains_key(&profile)); +} + +#[test] +fn pins_are_saved_in_the_state_and_restored_after_a_restart() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (p, _) = b.open_sub(&root, "apps", AT, true).unwrap(); + // not placed yet: nothing to save + assert!(b.pins().is_empty()); + b.panels.get_mut(&p).unwrap().pos = Some((40.4, 50.6)); + let pins = b.pins(); + assert_eq!(pins, vec![PinRecord { path: vec!["apps".into()], x: 40, y: 51 }]); + // through state.json + let st = State { menu_pins: pins.clone(), ..State::default() }; + let text = serde_json::to_string(&st).unwrap(); + assert!(text.contains("\"menuPins\"")); + let back: State = serde_json::from_str(&text).unwrap(); + assert_eq!(back.menu_pins, pins); + // an empty list is not written at all (older files stay as they were) + assert!(!serde_json::to_string(&State::default()).unwrap().contains("menuPins")); + let mut fresh = Book::default(); + let mut saved = back.menu_pins.clone(); + saved.push(saved[0].clone()); // a duplicate is dropped + let labels = fresh.restore(&saved); + assert_eq!(labels.len(), 1); + let r = &fresh.panels[&labels[0]]; + assert!(r.pinned && r.path == vec!["apps".to_string()] && r.pos == Some((40.0, 51.0)) && r.parent.is_none()); + // a restored pin survives the next menu + let (_, closes) = fresh.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + assert!(closes.is_empty()); + let many: Vec = (0..40).map(|i| PinRecord { path: vec![format!("s{i}")], x: 0, y: 0 }).collect(); + assert_eq!(Book::default().restore(&many).len(), MAX_PINS); +} + +#[test] +fn panel_labels_are_menu_and_a_number() { + assert!(is_menu_label("menu-1") && is_menu_label("menu-204")); + for bad in ["menu-", "menu-a", "card", "main", "menu-1-2", "xmenu-1"] { + assert!(!is_menu_label(bad), "{bad}"); + } +} diff --git a/apps/desktop/src-tauri/tests/security.rs b/apps/desktop/src-tauri/tests/security.rs index 1d31b45..6c6c4c6 100644 --- a/apps/desktop/src-tauri/tests/security.rs +++ b/apps/desktop/src-tauri/tests/security.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; use storebound_desktop::policy::*; -use storebound_desktop::{CARD_COMMANDS, COMMANDS}; +use storebound_desktop::{CARD_COMMANDS, COMMANDS, MENU_COMMANDS}; use url::Url; fn root() -> PathBuf { @@ -26,7 +26,7 @@ fn one_capability_for_the_main_window_with_exactly_the_app_commands() { let dir = root().join("capabilities"); let mut files: Vec = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect(); files.sort(); - assert_eq!(files.len(), 2, "main and card capability files expected, found {files:?}"); + assert_eq!(files.len(), 3, "main, card and menu capability files expected, found {files:?}"); let cap = read_json(&dir.join("main.json")); assert_eq!(cap["identifier"], "main-window"); assert_eq!(cap["windows"], json!(["main"])); @@ -71,6 +71,45 @@ fn the_card_window_gets_only_the_card_commands() { assert!(require_main("card", &u("tauri://localhost/card.html"), false).is_err()); } +#[test] +fn the_menu_panels_get_only_the_menu_commands() { + let cap = read_json(&root().join("capabilities/menu.json")); + assert_eq!(cap["identifier"], "menu-window"); + assert_eq!(cap["windows"], json!(["menu-*"])); + assert!(cap.get("webviews").is_none() && cap.get("remote").is_none()); + assert_ne!(cap["local"], json!(false)); + let got: BTreeSet = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect(); + let mut want: BTreeSet = MENU_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect(); + want.insert("core:event:allow-listen".into()); + want.insert("core:event:allow-unlisten".into()); + // a pinned panel is dragged by its header (data-tauri-drag-region): only its own window + want.insert("core:window:allow-start-dragging".into()); + assert_eq!(got, want); + // the menu commands never leak into the main or card capability + for other in ["main.json", "card.json"] { + let cap = read_json(&root().join("capabilities").join(other)); + let perms = cap["permissions"].to_string(); + for c in MENU_COMMANDS { + assert!(!perms.contains(&format!("\"allow-{}\"", c.replace('_', "-"))), "{c} granted in {other}"); + } + } + let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap(); + for c in MENU_COMMANDS { + let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs")); + let body = &cmds[at..]; + let open = body.find('{').unwrap(); + assert!(body[open + 1..].trim_start().starts_with("guard_menu(&window)?;"), "{c} must start with guard_menu(&window)?"); + } + assert!(require_menu("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_ok()); + assert!(require_menu("menu-12", &u("http://tauri.localhost/tray-menu.html"), false).is_ok()); + for bad in ["main", "card", "menu-", "menu-x", "menu-1a", "menus-1"] { + assert!(require_menu(bad, &u("tauri://localhost/tray-menu.html"), false).is_err(), "{bad}"); + } + assert!(require_menu("menu-1", &u("https://example.com/tray-menu.html"), false).is_err()); + assert!(require_main("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err()); + assert!(require_card("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err()); +} + #[test] fn every_registered_command_is_in_the_list_and_nothing_else() { let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap(); @@ -81,7 +120,7 @@ fn every_registered_command_is_in_the_list_and_nothing_else() { .map(|s| s.trim().trim_start_matches("commands::").to_string()) .filter(|s| !s.is_empty()) .collect(); - assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).map(|s| s.to_string()).collect::>()); + assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_COMMANDS).map(|s| s.to_string()).collect::>()); // every command checks the caller first let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap(); for c in COMMANDS { @@ -109,7 +148,7 @@ fn csp_is_strict_and_the_config_has_no_backdoors() { assert!(!v.contains("unsafe-eval") && !v.contains("unsafe-inline") && !v.contains('*'), "{k}: {v}"); assert!(!v.contains("https:") && !v.contains("http://") || v == "ipc: http://ipc.localhost", "{k}: no remote sources: {v}"); } - assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window"])); + assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window", "menu-window"])); assert_eq!(conf["app"]["security"]["freezePrototype"], json!(true)); assert!(conf["app"].get("withGlobalTauri").is_none() || conf["app"]["withGlobalTauri"] == json!(false)); let windows = conf["app"]["windows"].as_array().unwrap(); diff --git a/apps/desktop/src-tauri/tests/tray.rs b/apps/desktop/src-tauri/tests/tray.rs index 95d5b67..47c03e8 100644 --- a/apps/desktop/src-tauri/tests/tray.rs +++ b/apps/desktop/src-tauri/tests/tray.rs @@ -23,7 +23,7 @@ fn ids(m: &[Entry]) -> Vec { fn menu_has_every_entry_in_order() { let m = menu_model(&summary()); assert_eq!(ids(&m), vec![ID_OPEN, ID_READ, ID_MUTE, ID_UPDATE, ID_SETTINGS, ID_QUIT]); - let Entry::Sub { label, items } = &m[3] else { panic!("the Apps submenu comes after the first separator") }; + let Entry::Sub { label, items, .. } = &m[3] else { panic!("the Apps submenu comes after the first separator") }; assert_eq!(label, "Apps"); assert_eq!(items.len(), 2); let Entry::Item { id, label, enabled } = &items[0] else { panic!() }; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4cfa717..02a809d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -211,7 +211,7 @@ export function App({ onTheme }: { onTheme: () => void }) { const menu = useContextMenu(); const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
- +
); } @@ -130,7 +130,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { return (
appMenu(app, d, nav, { showIgnored }))} + {...cm(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)} badges={<> {e?.summary?.profile && app.project && {t(`profile.${e.summary.profile}` as 'profile.app')}} {!app.project && } diff --git a/apps/desktop/src/screens/Ignored.tsx b/apps/desktop/src/screens/Ignored.tsx index 4c7ac27..fdc3167 100644 --- a/apps/desktop/src/screens/Ignored.tsx +++ b/apps/desktop/src/screens/Ignored.tsx @@ -30,7 +30,7 @@ export function Ignored() { {apps.map(a => { const s = d.studios.find(x => x.id === a.studio) ?? null; return ( -
appMenu(a, d, nav, { showIgnored: true }))}> +
appMenu(a, d, nav, { showIgnored: true }), `app:${a.key}`)}>
{a.name}{!a.project && } diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index c49926d..27eb5b0 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -153,7 +153,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; const menu = useContextMenu(); const [showIgnored] = useShowIgnored(); return ( -
appMenu(app, d, nav, { showIgnored }))}> +
appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}>