From 4f993a906a5a780ec7a19ab8b249736a30cdfc7d Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 01:39:14 +0800 Subject: [PATCH 001/255] fastlane traces: check and release status warn, scan reports fastlane-upload when a Fastfile / Appfile or a CI file or script still runs fastlane supply, deliver or pilot (file:line, storebound import fastlane); test, pitfall 96 --- CHANGELOG.md | 4 +++ cli/check.ts | 3 ++ cli/lib/fastlane-traces.ts | 66 ++++++++++++++++++++++++++++++++++ cli/lib/scan/index.ts | 10 ++++++ cli/release.ts | 4 ++- skills/store-pitfalls/SKILL.md | 11 ++++++ tests/fastlane-traces.test.ts | 43 ++++++++++++++++++++++ 7 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 cli/lib/fastlane-traces.ts create mode 100644 tests/fastlane-traces.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bed5bc..e306175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **fastlane left in a project is named.** `check` and `release status` warn and `scan` reports `fastlane-upload` + when a Fastfile / Appfile or a CI file or script still runs `fastlane supply`, `deliver` or `pilot`, with the + file:line and the way over (`storebound import fastlane`). Pitfall 96. + ## 0.8.24 · 2026-10-21 - **Windows: the MCP server starts.** `storebound install` writes `cmd /c npx -y storebound@ mcp` (or diff --git a/cli/check.ts b/cli/check.ts index 8b9368e..9147c00 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -34,6 +34,7 @@ import { creativeNote } from './shots/creative'; import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets'; import { iphoneDuoNote } from './shots/spec'; import { hitLine } from './lib/cross-store'; +import { FASTLANE_FIX, findFastlaneTraces, traceLine } from './lib/fastlane-traces'; import { listingFileMentions } from './lib/listing-mentions'; /** The newest .ipa in a folder (apple build's export), or null. */ @@ -95,6 +96,8 @@ export async function run(ctx: Ctx): Promise { } return `${Object.keys(copy).length} locales`; }); + // fastlane uploads left next to storebound (pitfall 96) + try { const tr = findFastlaneTraces(dirname(path)); if (tr.length) for (const t of tr) note(`${traceLine(t)} · ${FASTLANE_FIX}`); } catch (e) { warn('check', `fastlane traces: ${(e as Error).message}`); } // other platforms named in a store's text: App Store 2.3.10 / Play Metadata, a blocker (pitfall 77) try { for (const h of listingFileMentions(ctx.loaded)) problems.push(`other platform in ${hitLine(h)}`); } catch (e) { warn('check', `platform mentions: ${(e as Error).message}`); } // listing translation drafts (storebound listing translate): fine to keep working on, not to push unreviewed diff --git a/cli/lib/fastlane-traces.ts b/cli/lib/fastlane-traces.ts new file mode 100644 index 0000000..0a666ba --- /dev/null +++ b/cli/lib/fastlane-traces.ts @@ -0,0 +1,66 @@ +// Fastlane left in a project storebound manages: a CI job or script that still uploads with `fastlane supply` +// (or deliver / pilot) bypasses storebound's checks and fights its writes, and nothing said so (pitfall 96). +// Offline and read-only: looks for Fastfile / Appfile, a fastlane/ folder and upload calls in CI files and scripts. +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +export interface FastlaneTrace { + file: string; + line: number; + text: string; + /** play (supply, upload_to_play_store), apple (deliver, pilot, upload_to_app_store / testflight) or both (a file). */ + store: 'apple' | 'play' | 'both'; +} + +const UPLOAD = /\b(supply|upload_to_play_store|deliver|upload_to_app_store|pilot|upload_to_testflight)\b/; +/** A fastlane command line in CI / scripts: `fastlane supply`, `bundle exec fastlane run deliver`, `fastlane pilot upload`. */ +const CLI_CALL = /\bfastlane\s+(?:run\s+)?(supply|upload_to_play_store|deliver|upload_to_app_store|pilot|upload_to_testflight)\b/; +const PLAY = new Set(['supply', 'upload_to_play_store']); +const FASTLANE_DIRS = ['', 'ios', 'android', 'ios/App', 'android/app']; +const CI_FILES = ['Jenkinsfile', '.gitlab-ci.yml', 'bitrise.yml', 'codemagic.yaml', 'azure-pipelines.yml', 'Makefile', 'package.json', 'appcenter-post-build.sh']; +const CI_DIRS = ['.github/workflows', '.forgejo/workflows', '.gitea/workflows', '.circleci', 'scripts', 'ci', '.ci', 'tools']; +const SCRIPT_EXT = /\.(ya?ml|sh|bash|zsh|ps1|groovy|rb|mjs|js|ts|json)$/i; + +const storeOf = (m: string): 'apple' | 'play' => (PLAY.has(m) ? 'play' : 'apple'); + +function lines(file: string): string[] { + try { return statSync(file).size > 2_000_000 ? [] : readFileSync(file, 'utf8').split(/\r?\n/); } catch { return []; } +} + +export function findFastlaneTraces(root: string): FastlaneTrace[] { + const out: FastlaneTrace[] = []; + const rel = (p: string) => relative(root, p).split(sep).join('/'); + const seen = new Set(); + // Fastfile / Appfile (in fastlane/ or next to it): the file itself, plus each upload action it calls + for (const d of FASTLANE_DIRS) { + for (const f of [join(root, d, 'fastlane', 'Fastfile'), join(root, d, 'Fastfile'), join(root, d, 'fastlane', 'Appfile'), join(root, d, 'Appfile')]) { + if (seen.has(f) || !existsSync(f)) continue; + seen.add(f); + const ls = lines(f); + let calls = 0; + ls.forEach((l, i) => { + const m = UPLOAD.exec(l.replace(/#.*/, '')); + if (m && /Fastfile$/.test(f)) { out.push({ file: rel(f), line: i + 1, text: l.trim(), store: storeOf(m[1]!) }); calls++; } + }); + if (!calls) out.push({ file: rel(f), line: 1, text: ls.find(l => l.trim())?.trim() ?? '', store: 'both' }); + } + } + // CI files and scripts that run fastlane uploads + const files = CI_FILES.map(f => join(root, f)).filter(f => existsSync(f)); + for (const d of CI_DIRS) { + const dir = join(root, d); + try { for (const n of readdirSync(dir)) if (SCRIPT_EXT.test(n)) files.push(join(dir, n)); } catch { /* none */ } + } + try { for (const n of readdirSync(root)) if (/\.(sh|bash|ps1)$/i.test(n)) files.push(join(root, n)); } catch { /* unreadable */ } + for (const f of new Set(files)) { + lines(f).forEach((l, i) => { + const m = CLI_CALL.exec(l); + if (m) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: storeOf(m[1]!) }); + }); + } + return out; +} + +export const FASTLANE_FIX = 'uploads outside storebound skip its checks and overwrite its listing: run `storebound import fastlane` (MCP import_fastlane), then replace the call with the storebound command (play upload / apple upload) (pitfall 96)'; + +export const traceLine = (t: FastlaneTrace): string => `fastlane in ${t.file}:${t.line}${t.text ? `: ${t.text}` : ''}`; diff --git a/cli/lib/scan/index.ts b/cli/lib/scan/index.ts index 5b88cff..c692d81 100644 --- a/cli/lib/scan/index.ts +++ b/cli/lib/scan/index.ts @@ -10,6 +10,7 @@ import { AI_FIX_RULES, scanFixAction } from '../fix-actions'; import { proposeDeclarations, type Proposal } from './declare'; import { detectProject, ECO_LABEL, type ProjectFacts, type Stack, where } from './project'; import type { OriginalityReport } from '../originality'; +import { FASTLANE_FIX, findFastlaneTraces } from '../fastlane-traces'; import { APPLE_GUIDELINES, applyIgnore, type ScanFinding, scanRules, type Severity, sortScan, unusedIgnores } from './rules'; import type { SdkCategory } from './sdks'; @@ -73,6 +74,15 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise fix: g.fix ?? '', skill: 'game-services', ref: 'https://developer.android.com/games/pgs/android/android-start' }); } catch { /* unreadable Android project */ } } + // fastlane still uploading next to storebound (pitfall 96) + const traces = findFastlaneTraces(dir); + for (const store of ['apple', 'play'] as const) { + if (config && !config[store]) continue; + const ev = traces.filter(t => t.store === store || t.store === 'both'); + if (ev.length && !(config?.scan?.ignore ?? []).some(i => i.rule === 'fastlane-upload')) + findings.push({ rule: 'fastlane-upload', guideline: 'storebound workflow', store, severity: 'medium', title: `fastlane still in the project (${ev.length} place${ev.length > 1 ? 's' : ''}); uploads there bypass storebound`, + evidence: ev.map(t => ({ file: t.file, line: t.line, text: t.text })), fix: FASTLANE_FIX, skill: 'migrate-from-fastlane', ref: 'https://git.crt.fyi/CRT/storebound/src/branch/main/skills/migrate-from-fastlane/SKILL.md' }); + } for (const f of findings) { const action = f.accepted ? undefined : scanFixAction(f.rule, { configPath, declarationsChanged: declarations.changed }); if (action) f.action = action; diff --git a/cli/release.ts b/cli/release.ts index dfaf404..c31ef2f 100644 --- a/cli/release.ts +++ b/cli/release.ts @@ -21,6 +21,7 @@ import { laneOf, laneText, type PlanStep, releasePlan, type ReleasePlan, sameVer import type { Finding } from './lib/validate-rules'; import { findWorkspace, loadWorkspace, resolveApps, selectApps } from './lib/workspace'; import { appDir, appEnv, workspaceApps } from './workspace'; +import { FASTLANE_FIX, findFastlaneTraces, traceLine } from './lib/fastlane-traces'; interface Target { name: string; dir: string; virtual: boolean; env: Record; problems: string[] } @@ -94,7 +95,7 @@ async function status(args: Args): Promise { const read = await pool(list, concurrency, async t => ({ t, s: await readLive(t) })); if (args.has('json')) { printJson({ apps: read.map(({ t, s }) => ({ - app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], warnings: s.warnings ?? [], storeErrors: s.storeErrors, + app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], warnings: [...(s.warnings ?? []), ...(t.virtual ? [] : findFastlaneTraces(t.dir).map(f => `${traceLine(f)} · ${FASTLANE_FIX}`))], storeErrors: s.storeErrors, trains: trainsOf(s, all).map(({ which, train }) => ({ which, version: train.version, ios: train.ios, android: train.android, lanes: { ios: laneOf('ios', train.ios), android: laneOf('android', train.android) } })), })), at: new Date().toISOString() }); @@ -106,6 +107,7 @@ async function status(args: Args): Promise { if (!trains.length) rows.push([t.name, '', '', s.errors[0] ? `error: ${s.errors[0]}` : unread ? 'not read (see the warnings)' : 'no version on either store yet', '']); for (const { which, train } of trains) rows.push([t.name, train.version, which === 'other' ? '' : which, cell('ios', train, s), cell('android', train, s)]); for (const w of s.warnings ?? []) warn('release', `${t.name}: ${w}`); + if (!t.virtual) for (const f of findFastlaneTraces(t.dir)) warn('release', `${t.name}: ${traceLine(f)} · ${FASTLANE_FIX}`); for (const p of [...t.problems, ...s.errors.slice(trains.length ? 0 : 1), ...Object.entries(s.storeErrors).map(([k, v]) => `${k}: ${v}`)]) warn('release', `${t.name}: ${p}`); } console.log(textTable(['app', 'version', 'train', 'App Store', 'Google Play'], rows)); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 68af0d1..322ab96 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1166,3 +1166,14 @@ that fails without the fix (see CONTRIBUTING.md). - **Do:** run the command again later; after a release, warm the CDN cache by fetching every installer once. - **Guard:** `download` in `cli/desktop.ts` reads in chunks, prints progress every 10 % and fails after 60 s without data (`tests/desktop-dist.test.ts`). + +## 96. A project still uploaded with fastlane and storebound never said so +- **Symptom:** the Play listing or build changed behind storebound's back; CI ran `fastlane supply` (or `deliver` / + `pilot`) next to the storebound config, and `check`, `scan` and `release status` were all quiet. +- **Cause:** storebound looked only at its own config; fastlane files and CI upload calls were never read. +- **Do:** run `storebound import fastlane` (MCP `import_fastlane`) to bring the metadata over, then replace each + upload call with the storebound command and remove the lane. Keep a lane only on purpose (`scan.ignore` + `fastlane-upload` with the reason). +- **Guard:** `findFastlaneTraces` in `cli/lib/fastlane-traces.ts` (Fastfile / Appfile, CI files and scripts with + `fastlane supply|deliver|pilot`); `check` warns, `scan` reports `fastlane-upload` with file:line, `release status` + warns per app (`tests/fastlane-traces.test.ts`). diff --git a/tests/fastlane-traces.test.ts b/tests/fastlane-traces.test.ts new file mode 100644 index 0000000..c0e30e9 --- /dev/null +++ b/tests/fastlane-traces.test.ts @@ -0,0 +1,43 @@ +// fastlane left in a project (Fastfile, CI calls to supply / deliver / pilot) is found and named (pitfall 96). +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { findFastlaneTraces, traceLine } from '../cli/lib/fastlane-traces'; +import { runScan } from '../cli/lib/scan'; + +function project(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'sb-fl-')); + for (const [p, text] of Object.entries(files)) { mkdirSync(join(dir, p, '..'), { recursive: true }); writeFileSync(join(dir, p), text); } + return dir; +} + +describe('fastlane traces', () => { + test('Fastfile actions, Appfile, and CI / script calls with file:line', () => { + const dir = project({ + 'android/fastlane/Fastfile': 'lane :ship do\n # supply in a comment is not a call\n supply(track: "internal")\nend\n', + 'fastlane/Appfile': 'app_identifier("com.example.game")\n', + 'Jenkinsfile': 'stage("upload") {\n sh "bundle exec fastlane supply --aab app.aab"\n}\n', + '.github/workflows/ios.yml': 'steps:\n - run: fastlane pilot upload\n', + 'scripts/release.sh': 'echo fastlane is fine to mention\n', + }); + const t = findFastlaneTraces(dir); + expect(t).toContainEqual({ file: 'android/fastlane/Fastfile', line: 3, text: 'supply(track: "internal")', store: 'play' }); + expect(t).toContainEqual({ file: 'fastlane/Appfile', line: 1, text: 'app_identifier("com.example.game")', store: 'both' }); + expect(t).toContainEqual({ file: 'Jenkinsfile', line: 2, text: 'sh "bundle exec fastlane supply --aab app.aab"', store: 'play' }); + expect(t).toContainEqual({ file: '.github/workflows/ios.yml', line: 2, text: '- run: fastlane pilot upload', store: 'apple' }); + expect(t.some(x => x.file === 'scripts/release.sh')).toBe(false); + expect(t.filter(x => x.file === 'android/fastlane/Fastfile')).toHaveLength(1); + expect(traceLine(t.find(x => x.file === 'Jenkinsfile')!)).toBe('fastlane in Jenkinsfile:2: sh "bundle exec fastlane supply --aab app.aab"'); + }); + test('a project without fastlane has no traces', () => { + expect(findFastlaneTraces(project({ 'package.json': '{"scripts":{"build":"vite build"}}' }))).toEqual([]); + }); + test('scan reports fastlane-upload with the evidence lines', async () => { + const dir = project({ 'Jenkinsfile': 'sh "fastlane supply"\n' }); + const { report } = await runScan(dir, {}); + const f = report.findings.find(x => x.rule === 'fastlane-upload' && x.store === 'play'); + expect(f?.evidence[0]).toMatchObject({ file: 'Jenkinsfile', line: 1 }); + expect(f?.fix).toContain('storebound import fastlane'); + }); +}); From 62eb010998e173c15b3c58d5fade0fa76f70cf1e Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 00:59:03 +0700 Subject: [PATCH 002/255] storebound 0.8.25: version in package.json and every copy, CHANGELOG 0.8.25 section --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b8dfe47..40ac3a9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.24", + "version": "0.8.25", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 39866c9..6e1a9e5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.24", + "version": "0.8.25", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 841c6ac..6821e9c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.24", + "version": "0.8.25", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/CHANGELOG.md b/CHANGELOG.md index e306175..1ae1e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.25 · 2026-10-21 + - **fastlane left in a project is named.** `check` and `release status` warn and `scan` reports `fastlane-upload` when a Fastfile / Appfile or a CI file or script still runs `fastlane supply`, `deliver` or `pilot`, with the file:line and the way over (`storebound import fastlane`). Pitfall 96. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d1be11a..72b47c9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.24", + "version": "0.8.25", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index ec08b77..368d239 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4010,7 +4010,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.24" +version = "0.8.25" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index f81a7e5..e1f9e34 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.24" +version = "0.8.25" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 122f7ad..1ff5db7 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.24", + "version": "0.8.25", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", diff --git a/gemini-extension.json b/gemini-extension.json index a6f0276..9571d0f 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.24", + "version": "0.8.25", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index b327a24..e5f365b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.24", + "version": "0.8.25", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", From 4fcee60ba66f4fa43bc82c9e7666e410d3092ab3 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:04:28 +0800 Subject: [PATCH 003/255] Direct store API calls in a project's scripts: androidpublisher.googleapis.com, the googleapis androidpublisher client, api.appstoreconnect.apple.com, altool / Transporter in CI files and scripts; scan rule direct-store-api, check and release status warnings with file:line and the storebound command; storebound's own checkout skipped; test, pitfall 97 --- CHANGELOG.md | 4 ++++ cli/check.ts | 4 ++-- cli/lib/fastlane-traces.ts | 29 ++++++++++++++++++++++++++--- cli/lib/scan/index.ts | 10 +++++++++- cli/release.ts | 6 +++--- skills/store-pitfalls/SKILL.md | 12 ++++++++++++ tests/fastlane-traces.test.ts | 17 ++++++++++++++++- 7 files changed, 72 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e306175..9d0b8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **Scripts that call the store APIs themselves are named.** `androidpublisher.googleapis.com`, the googleapis + androidpublisher client, `api.appstoreconnect.apple.com`, altool and Transporter in CI files and scripts: `check` and + `release status` warn, `scan` reports `direct-store-api`, each with file:line and the storebound command. Pitfall 97. + - **fastlane left in a project is named.** `check` and `release status` warn and `scan` reports `fastlane-upload` when a Fastfile / Appfile or a CI file or script still runs `fastlane supply`, `deliver` or `pilot`, with the file:line and the way over (`storebound import fastlane`). Pitfall 96. diff --git a/cli/check.ts b/cli/check.ts index 9147c00..2e2ebd0 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -34,7 +34,7 @@ import { creativeNote } from './shots/creative'; import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets'; import { iphoneDuoNote } from './shots/spec'; import { hitLine } from './lib/cross-store'; -import { FASTLANE_FIX, findFastlaneTraces, traceLine } from './lib/fastlane-traces'; +import { findFastlaneTraces, traceMessage } from './lib/fastlane-traces'; import { listingFileMentions } from './lib/listing-mentions'; /** The newest .ipa in a folder (apple build's export), or null. */ @@ -97,7 +97,7 @@ export async function run(ctx: Ctx): Promise { return `${Object.keys(copy).length} locales`; }); // fastlane uploads left next to storebound (pitfall 96) - try { const tr = findFastlaneTraces(dirname(path)); if (tr.length) for (const t of tr) note(`${traceLine(t)} · ${FASTLANE_FIX}`); } catch (e) { warn('check', `fastlane traces: ${(e as Error).message}`); } + try { const tr = findFastlaneTraces(dirname(path)); if (tr.length) for (const t of tr) note(traceMessage(t)); } catch (e) { warn('check', `fastlane traces: ${(e as Error).message}`); } // other platforms named in a store's text: App Store 2.3.10 / Play Metadata, a blocker (pitfall 77) try { for (const h of listingFileMentions(ctx.loaded)) problems.push(`other platform in ${hitLine(h)}`); } catch (e) { warn('check', `platform mentions: ${(e as Error).message}`); } // listing translation drafts (storebound listing translate): fine to keep working on, not to push unreviewed diff --git a/cli/lib/fastlane-traces.ts b/cli/lib/fastlane-traces.ts index 0a666ba..ddc0668 100644 --- a/cli/lib/fastlane-traces.ts +++ b/cli/lib/fastlane-traces.ts @@ -10,16 +10,30 @@ export interface FastlaneTrace { text: string; /** play (supply, upload_to_play_store), apple (deliver, pilot, upload_to_app_store / testflight) or both (a file). */ store: 'apple' | 'play' | 'both'; + /** fastlane, or a script that calls a store API / upload tool itself (no fastlane, same bypass). */ + via?: 'api'; + /** The storebound command that replaces it (api traces). */ + fix?: string; } +/** Store APIs and upload tools called by a project's own scripts: each with its storebound replacement. */ +const DIRECT: { re: RegExp; store: 'apple' | 'play'; what: string; fix: string }[] = [ + { re: /androidpublisher\.googleapis\.com|\bandroidpublisher\s*\(|google\.androidpublisher|['"]androidpublisher['"]/, store: 'play', what: 'Google Play Developer API called directly', + fix: 'use storebound: play upload / play release (MCP play_release_testing), play images (play_images), play listing pull (play_listing_pull) (pitfall 97)' }, + { re: /api\.appstoreconnect\.apple\.com/, store: 'apple', what: 'App Store Connect API called directly', + fix: 'use storebound: apple upload / apple submit / apple listing / apple screenshots (MCP apple_* tools) (pitfall 97)' }, + { re: /\baltool\b|iTMSTransporter|\bTransporter\b/, store: 'apple', what: 'altool / Transporter upload', + fix: 'use storebound apple upload (MCP apple_upload) (pitfall 97)' }, +]; + const UPLOAD = /\b(supply|upload_to_play_store|deliver|upload_to_app_store|pilot|upload_to_testflight)\b/; /** A fastlane command line in CI / scripts: `fastlane supply`, `bundle exec fastlane run deliver`, `fastlane pilot upload`. */ const CLI_CALL = /\bfastlane\s+(?:run\s+)?(supply|upload_to_play_store|deliver|upload_to_app_store|pilot|upload_to_testflight)\b/; const PLAY = new Set(['supply', 'upload_to_play_store']); const FASTLANE_DIRS = ['', 'ios', 'android', 'ios/App', 'android/app']; const CI_FILES = ['Jenkinsfile', '.gitlab-ci.yml', 'bitrise.yml', 'codemagic.yaml', 'azure-pipelines.yml', 'Makefile', 'package.json', 'appcenter-post-build.sh']; -const CI_DIRS = ['.github/workflows', '.forgejo/workflows', '.gitea/workflows', '.circleci', 'scripts', 'ci', '.ci', 'tools']; -const SCRIPT_EXT = /\.(ya?ml|sh|bash|zsh|ps1|groovy|rb|mjs|js|ts|json)$/i; +const CI_DIRS = ['.github/workflows', '.forgejo/workflows', '.gitea/workflows', '.circleci', 'scripts', 'ci', '.ci', 'tools', 'bin', 'deploy', 'release', 'ops']; +const SCRIPT_EXT = /\.(ya?ml|sh|bash|zsh|ps1|groovy|rb|py|mjs|cjs|js|ts|json)$/i; const storeOf = (m: string): 'apple' | 'play' => (PLAY.has(m) ? 'play' : 'apple'); @@ -29,6 +43,8 @@ function lines(file: string): string[] { export function findFastlaneTraces(root: string): FastlaneTrace[] { const out: FastlaneTrace[] = []; + // storebound's own checkout calls the store APIs by design + try { if ((JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: string }).name === 'storebound') return out; } catch { /* no package.json */ } const rel = (p: string) => relative(root, p).split(sep).join('/'); const seen = new Set(); // Fastfile / Appfile (in fastlane/ or next to it): the file itself, plus each upload action it calls @@ -56,6 +72,10 @@ export function findFastlaneTraces(root: string): FastlaneTrace[] { lines(f).forEach((l, i) => { const m = CLI_CALL.exec(l); if (m) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: storeOf(m[1]!) }); + else if (!/package\.json$/.test(f) || /"[^"]*"\s*:\s*"/.test(l)) { + const d = DIRECT.find(x => x.re.test(l)); + if (d) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: d.store, via: 'api', fix: d.fix }); + } }); } return out; @@ -63,4 +83,7 @@ export function findFastlaneTraces(root: string): FastlaneTrace[] { export const FASTLANE_FIX = 'uploads outside storebound skip its checks and overwrite its listing: run `storebound import fastlane` (MCP import_fastlane), then replace the call with the storebound command (play upload / apple upload) (pitfall 96)'; -export const traceLine = (t: FastlaneTrace): string => `fastlane in ${t.file}:${t.line}${t.text ? `: ${t.text}` : ''}`; +export const traceLine = (t: FastlaneTrace): string => `${t.via === 'api' ? `${DIRECT.find(d => d.fix === t.fix)?.what ?? 'store API called directly'} in` : 'fastlane in'} ${t.file}:${t.line}${t.text ? `: ${t.text}` : ''}`; + +/** The warning for one trace: where, and the storebound way. */ +export const traceMessage = (t: FastlaneTrace): string => `${traceLine(t)} · ${t.fix ?? FASTLANE_FIX}`; diff --git a/cli/lib/scan/index.ts b/cli/lib/scan/index.ts index c692d81..81e3680 100644 --- a/cli/lib/scan/index.ts +++ b/cli/lib/scan/index.ts @@ -78,11 +78,19 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise const traces = findFastlaneTraces(dir); for (const store of ['apple', 'play'] as const) { if (config && !config[store]) continue; - const ev = traces.filter(t => t.store === store || t.store === 'both'); + const ev = traces.filter(t => !t.via && (t.store === store || t.store === 'both')); if (ev.length && !(config?.scan?.ignore ?? []).some(i => i.rule === 'fastlane-upload')) findings.push({ rule: 'fastlane-upload', guideline: 'storebound workflow', store, severity: 'medium', title: `fastlane still in the project (${ev.length} place${ev.length > 1 ? 's' : ''}); uploads there bypass storebound`, evidence: ev.map(t => ({ file: t.file, line: t.line, text: t.text })), fix: FASTLANE_FIX, skill: 'migrate-from-fastlane', ref: 'https://git.crt.fyi/CRT/storebound/src/branch/main/skills/migrate-from-fastlane/SKILL.md' }); } + // the project's own scripts call a store API or upload tool (pitfall 97) + for (const store of ['apple', 'play'] as const) { + if (config && !config[store]) continue; + const ev = traces.filter(t => t.via === 'api' && t.store === store); + if (ev.length && !(config?.scan?.ignore ?? []).some(i => i.rule === 'direct-store-api')) + findings.push({ rule: 'direct-store-api', guideline: 'storebound workflow', store, severity: 'medium', title: `scripts call the ${store === 'play' ? 'Google Play' : 'App Store Connect'} API or an upload tool directly (${ev.length} place${ev.length > 1 ? 's' : ''}); they bypass storebound`, + evidence: ev.map(t => ({ file: t.file, line: t.line, text: t.text })), fix: ev[0]!.fix!, skill: 'store-api', ref: 'https://git.crt.fyi/CRT/storebound/src/branch/main/skills/store-api/SKILL.md' }); + } for (const f of findings) { const action = f.accepted ? undefined : scanFixAction(f.rule, { configPath, declarationsChanged: declarations.changed }); if (action) f.action = action; diff --git a/cli/release.ts b/cli/release.ts index c31ef2f..fc217c1 100644 --- a/cli/release.ts +++ b/cli/release.ts @@ -21,7 +21,7 @@ import { laneOf, laneText, type PlanStep, releasePlan, type ReleasePlan, sameVer import type { Finding } from './lib/validate-rules'; import { findWorkspace, loadWorkspace, resolveApps, selectApps } from './lib/workspace'; import { appDir, appEnv, workspaceApps } from './workspace'; -import { FASTLANE_FIX, findFastlaneTraces, traceLine } from './lib/fastlane-traces'; +import { findFastlaneTraces, traceMessage } from './lib/fastlane-traces'; interface Target { name: string; dir: string; virtual: boolean; env: Record; problems: string[] } @@ -95,7 +95,7 @@ async function status(args: Args): Promise { const read = await pool(list, concurrency, async t => ({ t, s: await readLive(t) })); if (args.has('json')) { printJson({ apps: read.map(({ t, s }) => ({ - app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], warnings: [...(s.warnings ?? []), ...(t.virtual ? [] : findFastlaneTraces(t.dir).map(f => `${traceLine(f)} · ${FASTLANE_FIX}`))], storeErrors: s.storeErrors, + app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], warnings: [...(s.warnings ?? []), ...(t.virtual ? [] : findFastlaneTraces(t.dir).map(traceMessage))], storeErrors: s.storeErrors, trains: trainsOf(s, all).map(({ which, train }) => ({ which, version: train.version, ios: train.ios, android: train.android, lanes: { ios: laneOf('ios', train.ios), android: laneOf('android', train.android) } })), })), at: new Date().toISOString() }); @@ -107,7 +107,7 @@ async function status(args: Args): Promise { if (!trains.length) rows.push([t.name, '', '', s.errors[0] ? `error: ${s.errors[0]}` : unread ? 'not read (see the warnings)' : 'no version on either store yet', '']); for (const { which, train } of trains) rows.push([t.name, train.version, which === 'other' ? '' : which, cell('ios', train, s), cell('android', train, s)]); for (const w of s.warnings ?? []) warn('release', `${t.name}: ${w}`); - if (!t.virtual) for (const f of findFastlaneTraces(t.dir)) warn('release', `${t.name}: ${traceLine(f)} · ${FASTLANE_FIX}`); + if (!t.virtual) for (const f of findFastlaneTraces(t.dir)) warn('release', `${t.name}: ${traceMessage(f)}`); for (const p of [...t.problems, ...s.errors.slice(trains.length ? 0 : 1), ...Object.entries(s.storeErrors).map(([k, v]) => `${k}: ${v}`)]) warn('release', `${t.name}: ${p}`); } console.log(textTable(['app', 'version', 'train', 'App Store', 'Google Play'], rows)); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 322ab96..3e3eb02 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1177,3 +1177,15 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `findFastlaneTraces` in `cli/lib/fastlane-traces.ts` (Fastfile / Appfile, CI files and scripts with `fastlane supply|deliver|pilot`); `check` warns, `scan` reports `fastlane-upload` with file:line, `release status` warns per app (`tests/fastlane-traces.test.ts`). + +## 97. The project's own scripts called the store APIs (no fastlane, same bypass) +- **Symptom:** an app uploaded AABs, set the track and rollout, pushed listing images and read the listing back with + three hand-written scripts (a self-signed JWT against `androidpublisher.googleapis.com`); storebound said nothing. +- **Cause:** pitfall 96 looked only for fastlane; direct API calls and upload tools (altool, Transporter) were not read. +- **Do:** replace each script with the storebound command: `play upload` / `play release` (MCP `play_release_testing`), + `play images` (`play_images`), `play listing pull` (`play_listing_pull`); for Apple `apple upload` and the other + `apple` commands (MCP `apple_*`). Keep one on purpose only with `scan.ignore` `direct-store-api` and the reason. +- **Guard:** `findFastlaneTraces` in `cli/lib/fastlane-traces.ts` also reports `androidpublisher.googleapis.com`, the + googleapis androidpublisher client, `api.appstoreconnect.apple.com`, `altool` / `iTMSTransporter` in CI files and + scripts (not storebound's own checkout, never node_modules); `scan` rule `direct-store-api`, `check` and + `release status` warnings with file:line and the storebound command (`tests/fastlane-traces.test.ts`). diff --git a/tests/fastlane-traces.test.ts b/tests/fastlane-traces.test.ts index c0e30e9..5b7a4df 100644 --- a/tests/fastlane-traces.test.ts +++ b/tests/fastlane-traces.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { findFastlaneTraces, traceLine } from '../cli/lib/fastlane-traces'; +import { findFastlaneTraces, traceLine, traceMessage } from '../cli/lib/fastlane-traces'; import { runScan } from '../cli/lib/scan'; function project(files: Record): string { @@ -30,6 +30,21 @@ describe('fastlane traces', () => { expect(t.filter(x => x.file === 'android/fastlane/Fastfile')).toHaveLength(1); expect(traceLine(t.find(x => x.file === 'Jenkinsfile')!)).toBe('fastlane in Jenkinsfile:2: sh "bundle exec fastlane supply --aab app.aab"'); }); + test('scripts that call the store APIs or upload tools themselves, with the storebound way', () => { + const dir = project({ + 'scripts/play-upload.mjs': "const url = 'https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/com.example.app/edits';\n", + 'tools/asc.py': "BASE = 'https://api.appstoreconnect.apple.com/v1'\n", + 'deploy/ios.sh': 'xcrun altool --upload-app -f app.ipa\n', + 'package.json': '{"name":"an-app","scripts":{"ship":"node scripts/play-upload.mjs"}}', + }); + const t = findFastlaneTraces(dir); + expect(t.map(x => `${x.file} ${x.store} ${x.via}`).sort()).toEqual(['deploy/ios.sh apple api', 'scripts/play-upload.mjs play api', 'tools/asc.py apple api']); + expect(traceMessage(t.find(x => x.store === 'play')!)).toContain('play_release_testing'); + expect(traceMessage(t.find(x => x.file === 'deploy/ios.sh')!)).toStartWith('altool / Transporter upload in deploy/ios.sh:1'); + }); + test("storebound's own checkout is not reported", () => { + expect(findFastlaneTraces(project({ 'package.json': '{"name":"storebound"}', 'scripts/x.ts': "fetch('https://api.appstoreconnect.apple.com/v1/apps')\n" }))).toEqual([]); + }); test('a project without fastlane has no traces', () => { expect(findFastlaneTraces(project({ 'package.json': '{"scripts":{"build":"vite build"}}' }))).toEqual([]); }); From 73d308164d2adb2658d9555964997570a643cbb7 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:10:52 +0800 Subject: [PATCH 004/255] Windows builds and moved credentials: play build runs gradlew.bat through cmd /c and takes C:/ keystore paths as absolute (pitfall 98); check names credential paths missing on this machine and a keystore the stored password / alias do not open (keytool) (pitfall 99); play / apple build write /latest.json for CI; CI install from a git clone documented --- CHANGELOG.md | 6 ++++++ cli/apple/build.ts | 2 ++ cli/check.ts | 4 +++- cli/lib/credentials.ts | 33 ++++++++++++++++++++++++++++++++- cli/lib/gradle.ts | 7 ++++--- cli/play/build.ts | 4 +++- docs/getting-started.md | 6 ++++++ skills/store-pitfalls/SKILL.md | 20 ++++++++++++++++++++ tests/build-sign.test.ts | 22 +++++++++++++++++++--- 9 files changed, 95 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0b8bb..bb958cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- **Windows builds.** `play build` runs `gradlew.bat` through `cmd /c` (it was looked up on PATH only) and takes + `C:/...` keystore paths as absolute. Pitfall 98. +- **`check` verifies credential files.** Every credential path that does not exist on this machine and an upload keystore + the stored password / alias do not open (keytool) are named by field. Pitfall 99. +- `play build` / `apple build` write `/latest.json` with the AAB / IPA path for CI; CI install from a git clone documented. + - **Scripts that call the store APIs themselves are named.** `androidpublisher.googleapis.com`, the googleapis androidpublisher client, `api.appstoreconnect.apple.com`, altool and Transporter in CI files and scripts: `check` and `release status` warn, `scan` reports `direct-store-api`, each with file:line and the storebound command. Pitfall 97. diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 62c5db4..dcb71dc 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -190,6 +190,8 @@ export async function run(ctx: Ctx): Promise { const privacy = bundleTrackingIssues(ipaManifests(join(exportPath, ipa))); if (privacy.length) throw new Error(`${relative(root, join(exportPath, ipa))} was built, but Apple would refuse it at upload:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest and build again.`); const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle }; + writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)} +`); if (json) printJson(result); done(TAG, `${relative(root, result.ipa)}: ${info.bundleId} ${info.version} (${info.build}). Next: storebound apple upload ${relative(root, result.ipa)} --dry`); } diff --git a/cli/check.ts b/cli/check.ts index 2e2ebd0..df40330 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -21,7 +21,7 @@ import { playConsoleReminders } from './lib/console-plan'; import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy'; import type { Ctx } from './lib/ctx'; import { dataSafetyIssues, dataSafetyRows } from './lib/data-safety'; -import { credentialStatus, reviewDemoAccount } from './lib/credentials'; +import { credentialFileIssues, credentialStatus, reviewDemoAccount } from './lib/credentials'; import { log, printJson, warn } from './lib/log'; import { profileIssues } from './lib/profile'; import { detectProject, grepOwn, where } from './lib/scan/project'; @@ -150,6 +150,8 @@ export async function run(ctx: Ctx): Promise { // template placeholders in the credentials ("YOUR_KEY_ID", "/absolute/path/to/...", "<...>"): missing, never a credential const ph = credentialStatus(ctx.loaded.root).filter(c => c.status === 'placeholder').map(c => c.key); if (ph.length) note(`credentials: ${ph.join(', ')} still hold template placeholders (env or .storebound/credentials.json): treated as missing; put the real value in or leave it empty`); + // credential files that are not on this machine, an upload keystore the stored password / alias do not open + try { for (const i of credentialFileIssues(ctx.loaded.root)) problems.push(`credentials: ${i}`); } catch (e) { warn('check', `credential files: ${(e as Error).message}`); } // scan.ignore entries that accept no finding (a typo, a title that changed, a fixed issue) are never silent if (config.scan?.ignore?.length) { const { runScan } = await import('./lib/scan'); diff --git a/cli/lib/credentials.ts b/cli/lib/credentials.ts index 34c890c..3b36e9d 100644 --- a/cli/lib/credentials.ts +++ b/cli/lib/credentials.ts @@ -26,6 +26,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; 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 AscKey { keyId: string; issuerId: string; pem: string } @@ -324,7 +325,7 @@ export function androidSigning(root: string): AndroidSigning | null { const storePassword = value(root, 'ANDROID_KEYSTORE_PASSWORD'), keyAlias = value(root, 'ANDROID_KEY_ALIAS'); if (!storePassword || !keyAlias) throw new Error('ANDROID_KEYSTORE is set but ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS are not (env or .storebound/credentials.json)'); const abs = storeFile.replace(/^~(?=\/)/, homedir()); - return { storeFile: abs.startsWith('/') ? abs : join(root, abs), storePassword, keyAlias, keyPassword: value(root, 'ANDROID_KEY_PASSWORD') ?? storePassword }; + return { storeFile: isAbsolute(abs) ? abs : join(root, abs), storePassword, keyAlias, keyPassword: value(root, 'ANDROID_KEY_PASSWORD') ?? storePassword }; } /** App Store Connect vendor number for sales and finance reports (Payments and Financial Reports, top left): the @@ -334,3 +335,33 @@ export function ascVendorNumber(root: string): string | null { const v = value(root, 'ASC_VENDOR_NUMBER'); return v && /^\d{5,12}$/.test(v.trim()) ? v.trim() : null; } + +/** Credential paths that do not exist (a machine move left another computer's absolute paths) and an upload keystore + * the stored password / alias do not open: each names the field. `keytool` runs only when it is on PATH. */ +export function credentialFileIssues(root: string, run: (argv: string[]) => { ok: boolean; out: string } | null = keytool): string[] { + const issues: string[] = []; + const asPath = (v: string) => { const p = v.replace(/^~(?=[/\\])/, homedir()); return isAbsolute(p) ? p : join(root, p); }; + for (const k of ['PLAY_SERVICE_ACCOUNT', 'ASC_P8', 'ANDROID_KEYSTORE'] as const) { + let v: string | undefined; + try { v = value(root, k); } catch { continue; } + if (!v || /^\s*\{/.test(v) || /BEGIN [A-Z ]*PRIVATE KEY/.test(v)) continue; + if (!existsSync(asPath(v))) issues.push(`${k}: ${v} does not exist on this machine (moved from another computer?): point it at the file here`); + } + let s: AndroidSigning | null = null; + try { s = androidSigning(root); } catch (e) { issues.push((e as Error).message); } + if (s && existsSync(s.storeFile)) { + const r = run(['-list', '-keystore', s.storeFile, '-storepass', s.storePassword, '-alias', s.keyAlias]); + if (r && !r.ok) issues.push(/password|tampered|Keystore was tampered/i.test(r.out) + ? 'ANDROID_KEYSTORE_PASSWORD does not open the upload keystore (keytool -list failed): check the password (android/keystore.properties may hold the right one)' + : /alias|does not exist/i.test(r.out) ? `ANDROID_KEY_ALIAS "${s.keyAlias}" is not in the upload keystore` : `the upload keystore does not open with the stored password / alias: ${r.out.trim().split('\n')[0]}`); + } + return issues; +} + +function keytool(argv: string[]): { ok: boolean; out: string } | null { + const bin = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'keytool.exe' : 'keytool') : 'keytool'; + try { + const r = rt.spawnSync([bin, ...argv], { timeout: 20_000 }); + return { ok: r.success, out: `${r.stdout.toString()}${r.stderr.toString()}` }; + } catch { return null; } +} diff --git a/cli/lib/gradle.ts b/cli/lib/gradle.ts index 7ecfc8c..ddafbce 100644 --- a/cli/lib/gradle.ts +++ b/cli/lib/gradle.ts @@ -48,7 +48,7 @@ export function pickGradle(root: string, cfg: PlayBuildConfig = {}): GradleTarge if (fw === 'capacitor') notes.push('Capacitor: run npx cap sync android after the web build so the bundle has the current web assets'); if (fw === 'unity') notes.push('Unity: export the Gradle project from Unity (Build Settings > Android > Export Project) before each build'); if (fw === 'flutter') notes.push('Flutter: built with flutter build appbundle (build number = versionCode)'); - if (!existsSync(join(dir, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'))) notes.push('no Gradle wrapper (gradlew) in the project: using gradle from PATH'); + if (!existsSync(join(dir, 'gradlew')) && !existsSync(join(dir, 'gradlew.bat'))) notes.push('no Gradle wrapper (gradlew) in the project: using gradle from PATH'); return { framework: fw, dir, module, task: `:${module}:bundle${flavor}Release`, notes }; } @@ -57,10 +57,11 @@ export function pickGradle(root: string, cfg: PlayBuildConfig = {}): GradleTarge * override defaultConfig for bundleRelease: checked with AGP 8.13 (2026-10-12), `-Pandroid.injected.version.code=42` * on a build file with `versionCode 1` gives an AAB with versionCode 1; seen live the same day with a Capacitor app. * They stay on the command line for older plugins / IDE parity only; the AAB check after the build is the stop. */ -export function gradleArgs(t: GradleTarget, o: { versionCode?: number; versionName?: string; flavor?: string; hasWrapper: boolean }): string[] { +export function gradleArgs(t: GradleTarget, o: { versionCode?: number; versionName?: string; flavor?: string; hasWrapper: boolean }, platform: string = process.platform): string[] { if (t.framework === 'flutter') return ['flutter', 'build', 'appbundle', '--release', ...(o.flavor ? ['--flavor', o.flavor] : []), ...(o.versionCode ? ['--build-number', String(o.versionCode)] : []), ...(o.versionName ? ['--build-name', o.versionName] : [])]; - return [o.hasWrapper ? (process.platform === 'win32' ? 'gradlew.bat' : './gradlew') : 'gradle', t.task, '--no-daemon', '--console=plain', + // Windows: a bare `gradlew.bat` is searched on PATH only ("Executable not found in $PATH"); cmd /c finds it in the cwd + return [...(o.hasWrapper ? (platform === 'win32' ? ['cmd', '/c', 'gradlew.bat'] : ['./gradlew']) : platform === 'win32' ? ['cmd', '/c', 'gradle'] : ['gradle']), t.task, '--no-daemon', '--console=plain', ...(o.versionCode ? [`-Pandroid.injected.version.code=${o.versionCode}`, `-PversionCode=${o.versionCode}`] : []), ...(o.versionName ? [`-Pandroid.injected.version.name=${o.versionName}`, `-PversionName=${o.versionName}`] : [])]; } diff --git a/cli/play/build.ts b/cli/play/build.ts index a6a8ba5..91d12c9 100644 --- a/cli/play/build.ts +++ b/cli/play/build.ts @@ -59,7 +59,7 @@ export async function run(ctx: Ctx): Promise { } const versionName = a.get('version-name'); const signing = androidSigning(root); - const hasWrapper = existsSync(join(t.dir, 'gradlew')); + const hasWrapper = existsSync(join(t.dir, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew')); const argv = gradleArgs(t, { versionCode: code || undefined, versionName, flavor, hasWrapper }); const cwd = t.framework === 'flutter' ? root : t.dir; // the SDK as Android Studio finds it (ANDROID_HOME is often unset in non-interactive shells and local.properties is @@ -117,6 +117,8 @@ export async function run(ctx: Ctx): Promise { const dest = join(out, `${info.packageName}-${info.versionCode}.aab`); copyFileSync(aab, dest); const result = { aab: dest, packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, web: web.bundle }; + writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)} +`); if (json) printJson(result); done(TAG, `${relative(root, dest)}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}). Next: storebound play upload ${relative(root, dest)} --track internal --dry`); } diff --git a/docs/getting-started.md b/docs/getting-started.md index 02f2c56..ea6c825 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -152,6 +152,12 @@ plain Markdown and the CLI is a normal command. and `--command ""` to choose how the MCP server starts. It only adds or updates the `storebound` entry and its own skill folders; everything else in those files stays as it is, and re-running it is safe. +**CI servers (Jenkins, Forgejo / GitHub Actions):** use the npm package (`npx -y storebound@` or +`bunx storebound@`). From a git clone there is no `dist/` (it is built only for npm), so `node dist/storebound.mjs` +fails: run `bun install` in the clone and call `bin/storebound` (Bun runs `cli/index.ts`), e.g. symlink it onto PATH. +`storebound play build` and `apple build` write `/latest.json` (`aab` / `ipa` path, ids, version): read it instead of +globbing the output folder (or pass `--json`). + **Windows:** an MCP client cannot start `npx` directly (it is `npx.cmd`). `install` writes the working form for you; by hand use `"command": "cmd", "args": ["/c", "npx", "-y", "storebound@", "mcp"]` (or `bunx storebound mcp` with Bun). If the server shows "connection closed", check this first ([pitfall 94](../skills/store-pitfalls/SKILL.md)). diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 3e3eb02..061f84a 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1189,3 +1189,23 @@ that fails without the fix (see CONTRIBUTING.md). googleapis androidpublisher client, `api.appstoreconnect.apple.com`, `altool` / `iTMSTransporter` in CI files and scripts (not storebound's own checkout, never node_modules); `scan` rule `direct-store-api`, `check` and `release status` warnings with file:line and the storebound command (`tests/fastlane-traces.test.ts`). + +## 98. `play build` on Windows: "Executable not found in $PATH: gradlew.bat"; "C:/..." keystore "does not exist" +- **Symptom:** `storebound play build` stopped before Gradle on Windows although `android/gradlew.bat` exists; with + `ANDROID_KEYSTORE` set to an absolute `C:/...` path it said the keystore file does not exist. +- **Cause:** a bare `gradlew.bat` is searched on PATH only, never in the working folder; absolute paths were recognised + only when they started with `/`, so `C:/...` was joined onto the project folder. +- **Do:** update storebound. Until then run Gradle by hand and use a project-relative keystore path. +- **Guard:** `gradleArgs` in `cli/lib/gradle.ts` runs `cmd /c gradlew.bat` on Windows; `androidSigning` uses + `isAbsolute` (`tests/build-sign.test.ts`). + +## 99. Credentials still pointed at the old computer, and the keystore password did not open the keystore +- **Symptom:** after a machine move `ANDROID_KEYSTORE`, `PLAY_SERVICE_ACCOUNT` and `ASC_P8` held the old machine's + absolute paths, and `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` did not open the upload keystore (keytool exit 1); + `check` passed and the build failed late. +- **Do:** point each path at the file on this machine; take the password and alias that open the keystore (an + `android/keystore.properties` often holds them). Verify with `keytool -list -keystore -alias `. +- **Guard:** `credentialFileIssues` in `cli/lib/credentials.ts`: `check` names every credential path that does not + exist and runs `keytool -list` with the stored password and alias (when keytool is on PATH or in JAVA_HOME), naming + the bad field (`tests/build-sign.test.ts`). `play build` / `apple build` also write `/latest.json` so CI + needs no glob for the AAB / IPA. diff --git a/tests/build-sign.test.ts b/tests/build-sign.test.ts index 3364713..83f1f1d 100644 --- a/tests/build-sign.test.ts +++ b/tests/build-sign.test.ts @@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, ut import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { installedProfiles } from '../cli/apple/build'; -import { androidSigning, ascKeyFile } from '../cli/lib/credentials'; +import { androidSigning, ascKeyFile, credentialFileIssues } from '../cli/lib/credentials'; import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../cli/lib/csr'; import { aabInfo, appBuildFile, findBundles, gradleArgs, pickGradle, signingEnv, validatePlayBuild, versionSourceIssues, versionSourceMessage } from '../cli/lib/gradle'; import { parseBinaryPlist, parseMobileprovision, parsePlist, parseXmlPlist, toXmlPlist } from '../cli/lib/plist'; @@ -269,8 +269,10 @@ describe('gradle projects', () => { touch(rn, 'android/settings.gradle'); touch(rn, 'android/gradlew', '#!/bin/sh'); touch(rn, 'android/app/build.gradle'); const t = pickGradle(rn); expect(t).toMatchObject({ framework: 'react-native', module: 'app', task: ':app:bundleRelease' }); - expect(gradleArgs(t, { versionCode: 7, versionName: '1.2.0', hasWrapper: true })).toEqual(['./gradlew', ':app:bundleRelease', '--no-daemon', '--console=plain', + expect(gradleArgs(t, { versionCode: 7, versionName: '1.2.0', hasWrapper: true }, 'linux')).toEqual(['./gradlew', ':app:bundleRelease', '--no-daemon', '--console=plain', '-Pandroid.injected.version.code=7', '-PversionCode=7', '-Pandroid.injected.version.name=1.2.0', '-PversionName=1.2.0']); + // Windows: a bare gradlew.bat is looked up on PATH only; cmd /c runs the one in the project folder + expect(gradleArgs(t, { hasWrapper: true }, 'win32').slice(0, 4)).toEqual(['cmd', '/c', 'gradlew.bat', ':app:bundleRelease']); expect(pickGradle(rn, { flavor: 'prod' }).task).toBe(':app:bundleProdRelease'); const unity = dir('unity'); touch(unity, 'Export/settings.gradle'); touch(unity, 'Export/launcher/build.gradle'); touch(unity, 'Export/unityLibrary/build.gradle'); @@ -360,12 +362,26 @@ describe('credentials for builds', () => { delete process.env.ANDROID_KEYSTORE; expect(androidSigning(d)).toEqual({ storeFile: join(d, 'keys/upload.jks'), storePassword: 'pw', keyAlias: 'upload', keyPassword: 'pw' }); expect(androidSigning(dir('nocreds'))).toBeNull(); + // a Windows absolute path is used as it is, not joined onto the project folder + if (process.platform === 'win32') { + const w = dir('wincreds'); + touch(w, '.storebound/credentials.json', JSON.stringify({ ANDROID_KEYSTORE: 'C:/keys/upload.jks', ANDROID_KEYSTORE_PASSWORD: 'pw', ANDROID_KEY_ALIAS: 'upload' })); + expect(androidSigning(w)?.storeFile).toBe('C:/keys/upload.jks'); + } + // another machine's paths and a keystore the stored password does not open are named by field + const m = dir('moved'); + touch(m, 'keys/upload.jks', 'x'); + touch(m, '.storebound/credentials.json', JSON.stringify({ ANDROID_KEYSTORE: 'keys/upload.jks', ANDROID_KEYSTORE_PASSWORD: 'wrong', ANDROID_KEY_ALIAS: 'upload', PLAY_SERVICE_ACCOUNT: '/Users/someone/keys/play.json' })); + const issues = credentialFileIssues(m, () => ({ ok: false, out: 'keytool error: java.io.IOException: Keystore was tampered with, or password was incorrect' })); + expect(issues.some(i => i.startsWith('PLAY_SERVICE_ACCOUNT: /Users/someone/keys/play.json does not exist'))).toBe(true); + expect(issues.some(i => i.startsWith('ANDROID_KEYSTORE_PASSWORD does not open'))).toBe(true); + expect(credentialFileIssues(m, () => ({ ok: true, out: '' })).some(i => i.startsWith('ANDROID'))).toBe(false); const { pem } = newSigningKey(); const p8 = createPrivateKey(pem).export({ type: 'pkcs8', format: 'pem' }).toString(); Object.assign(process.env, { ASC_KEY_ID: 'KEYID00001', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: p8 }); const k = ascKeyFile(d); expect(readFileSync(k.keyPath, 'utf8')).toBe(p8); - expect(k.keyPath.split('/').pop()).toBe(['AuthKey', 'KEYID00001.p8'].join('_')); + expect(k.keyPath.split(/[\\/]/).pop()).toBe(['AuthKey', 'KEYID00001.p8'].join('_')); k.cleanup(); expect(() => readFileSync(k.keyPath)).toThrow(); } finally { for (const key of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8']) if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } From e1634f6abe9a6edb1a0ddf2c8a961d71b5a1ff29 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:12:26 +0800 Subject: [PATCH 005/255] MCP: multi-line --text goes to the CLI as --from-text (console_verify kept only the first line on Windows); long JSON output keeps its start and names a file with the whole output (console_plan lost its start URL); test, pitfall 100 --- CHANGELOG.md | 3 +++ cli/mcp.ts | 30 ++++++++++++++++++++++++++++-- skills/store-pitfalls/SKILL.md | 11 +++++++++++ tests/mcp-text-file.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/mcp-text-file.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bb958cb..28a0596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- **MCP `console_verify` reads multi-line page text whole** (it kept only the first line on Windows), and long JSON + output such as `console_plan` keeps its start and names a file with the whole output. Pitfall 100. + - **Windows builds.** `play build` runs `gradlew.bat` through `cmd /c` (it was looked up on PATH only) and takes `C:/...` keystore paths as absolute. Pitfall 98. - **`check` verifies credential files.** Every credential path that does not exist on this machine and an upload keystore diff --git a/cli/mcp.ts b/cli/mcp.ts index 6abbfac..1bf1f64 100644 --- a/cli/mcp.ts +++ b/cli/mcp.ts @@ -20,6 +20,9 @@ // Every tool runs the CLI as a child process in the project directory (STOREBOUND_PROJECT or the server's cwd) and // returns its output. stdout of this process carries protocol messages only; logs go to stderr. // Tool list and safety rules: mcp-tools.ts (write tools default to dry_run, nothing public or irreversible exposed). +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import pkg from '../package.json' with { type: 'json' }; import { runCommand, type Running } from './lib/runner'; import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools'; @@ -107,9 +110,10 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ const input = (m.params?.arguments ?? {}) as Record; const problems = validateInput(tool, input); if (problems.length) return ok({ content: [{ type: 'text', text: `Invalid arguments: ${problems.join('; ')}` }], isError: true }); - const { argv, dryRun } = toArgv(tool, input); + const { argv: raw, dryRun } = toArgv(tool, input); + const argv = textToFile(raw); const r = await run(argv, m.id); - let out = r.out.length > MAX_OUTPUT ? `[... first ${r.out.length - MAX_OUTPUT} characters cut ...]\n` + r.out.slice(-MAX_OUTPUT) : r.out; + let out = clip(r.out, argv.includes('--json')); if (dryRun) out += '\n\n(dry run: nothing was changed. Ask the owner before calling again with dry_run: false.)'; const structured = { exitCode: r.code, dryRun, command: `storebound ${argv.join(' ')}`, output: out }; return ok({ content: [{ type: 'text', text: `$ storebound ${argv.join(' ')}\n${out}\n[exit ${r.code}]` }], structuredContent: structured, isError: r.code !== 0 }); @@ -118,6 +122,28 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ } } +/** A multi-line --text=... goes to the CLI as --from-text : on Windows the child starts through a shell and a + * multi-line argument kept only its first line (console_verify read every App Privacy type as missing). */ +export function textToFile(argv: string[]): string[] { + return argv.flatMap(a => { + if (!a.startsWith('--text=') || !/[\r\n]/.test(a)) return [a]; + const file = join(mkdtempSync(join(tmpdir(), 'storebound-mcp-')), 'page.txt'); + writeFileSync(file, a.slice('--text='.length)); + return ['--from-text', file]; + }); +} + +/** Long output: the whole text goes to a file whose path is named; JSON keeps its start (a plan's start URL and first + * steps were cut when the tail was kept), plain text its end (where errors are). */ +export function clip(out: string, json: boolean, max = MAX_OUTPUT): string { + if (out.length <= max) return out; + const file = join(mkdtempSync(join(tmpdir(), 'storebound-mcp-')), json ? 'output.json' : 'output.txt'); + writeFileSync(file, out); + return json + ? `${out.slice(0, max)}\n[... last ${out.length - max} characters cut; the whole output: ${file}]` + : `[... first ${out.length - max} characters cut; the whole output: ${file}]\n${out.slice(-max)}`; +} + const running = new Map(); function cancel(id: Id | undefined): void { if (id !== undefined) running.get(id)?.cancel(); } diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 061f84a..4de9ad2 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1209,3 +1209,14 @@ that fails without the fix (see CONTRIBUTING.md). exist and runs `keytool -list` with the stored password and alias (when keytool is on PATH or in JAVA_HOME), naming the bad field (`tests/build-sign.test.ts`). `play build` / `apple build` also write `/latest.json` so CI needs no glob for the AAB / IPA. + +## 100. `console_verify` reported every App Privacy type missing (multi-line page text over MCP on Windows) +- **Symptom:** MCP `console_verify` with the App Privacy page text said "not on the page" for every data type and "no + Published line"; the command line it echoed held only the text's first line. The same text from a file matched. +- **Cause:** the text went to the CLI as one `--text=...` argument; on Windows the child starts through a shell, which + kept only the first line. Separately, `console_plan` output over 60 KB was cut at the start, losing the start URL + and the first steps. +- **Do:** update storebound; meanwhile save the page text to a file and run `storebound console verify
+ --from-text `. +- **Guard:** `textToFile` in `cli/mcp.ts` sends multi-line text as `--from-text `; `clip` keeps the start of + long JSON output (the end of plain text) and names a file with the whole output (`tests/mcp-text-file.test.ts`). diff --git a/tests/mcp-text-file.test.ts b/tests/mcp-text-file.test.ts new file mode 100644 index 0000000..95dd166 --- /dev/null +++ b/tests/mcp-text-file.test.ts @@ -0,0 +1,24 @@ +// MCP calls: multi-line page text reaches the CLI whole (pitfall 100), long JSON output keeps its start. +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { clip, textToFile } from '../cli/mcp'; + +describe('mcp argv and output', () => { + test('a multi-line --text= becomes --from-text with the whole text', () => { + const page = 'App Privacy\nData Types\nCrash Data\r\nUsed for App Functionality\nPublished a few seconds ago'; + const argv = textToFile(['console', 'verify', 'apple-app-privacy', `--text=${page}`, '--json']); + expect(argv.slice(0, 4)).toEqual(['console', 'verify', 'apple-app-privacy', '--from-text']); + expect(argv.some(a => /[\r\n]/.test(a))).toBe(false); + expect(readFileSync(argv[4]!, 'utf8')).toBe(page); + expect(textToFile(['console', 'verify', 'x', '--text=one line'])).toEqual(['console', 'verify', 'x', '--text=one line']); + }); + test('long JSON keeps its start, long text its end; both name the full file', () => { + const json = `{"startUrl":"https://example.com/start",${'"x":1,'.repeat(50)}"end":true}`; + const j = clip(json, true, 40); + expect(j.startsWith('{"startUrl":"https://example.com/start"')).toBe(true); + const file = /the whole output: (.+)\]$/.exec(j)![1]!; + expect(readFileSync(file, 'utf8')).toBe(json); + expect(clip('a'.repeat(100) + 'ERROR at the end', false, 20).endsWith('ERROR at the end')).toBe(true); + expect(clip('short', true, 40)).toBe('short'); + }); +}); From f7dd646e570c311c6481d8ceda6a44ccd4cf01ab Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:15:03 +0800 Subject: [PATCH 006/255] Terminal: colors, status emoji and a live spinner line for waits at a TTY; plain lines unchanged when piped, in CI, with NO_COLOR, TERM=dumb, --json or MCP; desktop download progress through the log --- CHANGELOG.md | 4 +++ cli/desktop.ts | 4 +-- cli/lib/log.ts | 69 +++++++++++++++++++++++++++++++++++++++-- tests/log-fancy.test.ts | 26 ++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/log-fancy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a0596..939cbef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **A livelier terminal.** At a terminal, lines get colors and status emoji (✅ ⚠️ ❌ 📦 🚀 🧪) and every wait ("waiting + for ...", uploads, downloads, progress) is one live spinner line with the elapsed time. Piped, CI, `NO_COLOR`, + `TERM=dumb`, `--json` and MCP keep the plain lines exactly as before. + - **MCP `console_verify` reads multi-line page text whole** (it kept only the first line on Windows), and long JSON output such as `console_plan` keeps its start and names a file with the whole output. Pitfall 100. diff --git a/cli/desktop.ts b/cli/desktop.ts index 8089388..5426361 100644 --- a/cli/desktop.ts +++ b/cli/desktop.ts @@ -26,7 +26,7 @@ import { verify } from './lib/minisign'; import { parsePlist } from './lib/plist'; import { addFolder } from './lib/registry'; import * as rt from './lib/rt'; -import { printJson, promptOut } from './lib/log'; +import { log, printJson, promptOut } from './lib/log'; import { stdinIsTTY } from './lib/tty'; type Fetch = (url: string) => Promise; @@ -58,7 +58,7 @@ export function pick(m: Manifest, keys: DownloadKey[], format?: string): [Downlo } /** Downloads one installer and keeps it only when its size, SHA-256 and minisign signature are right. */ -export async function download(d: Download, dir: string, get: Fetch = fetch, publicKey = PUBLIC_KEY, stallMs = 60_000, say: (s: string) => void = s => console.log(s)): Promise { +export async function download(d: Download, dir: string, get: Fetch = fetch, publicKey = PUBLIC_KEY, stallMs = 60_000, say: (s: string) => void = s => log('desktop', `downloading ${s.trim()}`)): Promise { const r = await withStall(get(d.url), stallMs, d.url); if (!r.ok) throw new Error(`${d.url}: HTTP ${r.status}`); const data = await readBody(r, d, stallMs, say); diff --git a/cli/lib/log.ts b/cli/lib/log.ts index 3790f6c..34bb78a 100644 --- a/cli/lib/log.ts +++ b/cli/lib/log.ts @@ -4,9 +4,72 @@ const t0 = Date.now(); const stamp = (): string => new Date().toISOString().slice(11, 19); const since = (): string => `${((Date.now() - t0) / 1000).toFixed(0)}s`; -export const log = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg}`); -export const warn = (tag: string, msg: string): void => console.warn(`${stamp()} [${tag}] WARN ${msg}`); -export const done = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg} (${since()})`); +/** + * Colors, status emoji and a spinner, only for a person at a terminal: stdout is a TTY and nothing asks for plain text + * (NO_COLOR, CI, TERM=dumb, a --json run, `storebound serve` / MCP children). Piped and logged output stays exactly + * the plain lines above, so `tail -f` and agents read the same text as before. + */ +export function fancy(env: Record = process.env, tty = !!process.stdout.isTTY): boolean { + return tty && !env.NO_COLOR && !env.CI && env.TERM !== 'dumb' && env.STOREBOUND_MACHINE !== '1' && env.STOREBOUND_MCP !== '1' && !jsonOutput(); +} + +const C = { dim: '\x1b[2m', cyan: '\x1b[36m', yellow: '\x1b[33m', green: '\x1b[32m', red: '\x1b[31m', bold: '\x1b[1m', off: '\x1b[0m' }; +const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +/** A wait line ("waiting for ...", "still processing", "3/14 ..." progress) is drawn as one live spinner line. */ +const WAIT = /^(waiting|still |polling|processing|uploading|downloading|building|[ ]*\d+%|\d+\/\d+ )/i; + +/** Emoji for a line, by what it says. */ +export function emojiFor(msg: string, level: 'log' | 'warn' | 'done'): string { + if (level === 'warn') return '⚠️ '; + if (level === 'done') return '✅'; + if (/\b(fail|error|refus|rejected|not kept)/i.test(msg)) return '❌'; + if (/^(upload|uploading)/i.test(msg)) return '📦'; + if (/\b(submit(ted)?|released?|publish(ed)?|live)\b/i.test(msg)) return '🚀'; + if (/^(dry run|would )/i.test(msg)) return '🧪'; + return '•'; +} + +let spin: { text: string; start: number; frame: number; timer: ReturnType } | null = null; +const clearSpin = (): void => { if (spin) process.stdout.write('\r\x1b[2K'); }; +function stopSpin(): void { if (spin) { clearInterval(spin.timer); clearSpin(); spin = null; } } +function drawSpin(): void { + if (!spin) return; + const s = Math.round((Date.now() - spin.start) / 1000); + const cols = (process.stdout.columns || 100) - 12; + process.stdout.write(`\r\x1b[2K${C.cyan}${FRAMES[spin.frame++ % FRAMES.length]}${C.off} ${spin.text.slice(0, cols)} ${C.dim}${s}s${C.off}`); +} +function showSpin(text: string): void { + if (spin) { spin.text = text; drawSpin(); return; } + spin = { text, start: Date.now(), frame: 0, timer: setInterval(drawSpin, 100) }; + spin.timer.unref?.(); + drawSpin(); +} +let hooked = false; +/** Any other output first removes the live spinner line, so lines never mix. */ +function hook(): void { + if (hooked) return; + hooked = true; + for (const k of ['log', 'info', 'warn', 'error'] as const) { + const orig = console[k].bind(console); + console[k] = (...a: unknown[]) => { const was = spin?.text; stopSpin(); orig(...a); if (was !== undefined && k !== 'error') showSpin(was); }; + } + process.on('exit', stopSpin); +} + +function line(level: 'log' | 'warn' | 'done', tag: string, msg: string): void { + const plain = `${stamp()} [${tag}] ${level === 'warn' ? 'WARN ' : ''}${msg}${level === 'done' ? ` (${since()})` : ''}`; + if (!fancy()) { (level === 'warn' ? console.warn : console.log)(plain); return; } + hook(); + if (level === 'log' && WAIT.test(msg)) { showSpin(`${C.bold}${tag}${C.off} ${msg}`); return; } + stopSpin(); + const color = level === 'warn' ? C.yellow : level === 'done' ? C.green : emojiFor(msg, level) === '❌' ? C.red : ''; + const text = `${C.dim}${stamp()}${C.off} ${emojiFor(msg, level)} ${C.cyan}${tag}${C.off} ${color}${msg}${color ? C.off : ''}${level === 'done' ? ` ${C.dim}(${since()})${C.off}` : ''}`; + (level === 'warn' ? console.warn : console.log)(text); +} + +export const log = (tag: string, msg: string): void => line('log', tag, msg); +export const warn = (tag: string, msg: string): void => line('warn', tag, msg); +export const done = (tag: string, msg: string): void => line('done', tag, msg); // The count lives on globalThis: the Node bundle (tools/build-node.ts) can carry this module twice (once for the // static imports, once inside a lazily initialised chunk), and both copies must see the same --json run. diff --git a/tests/log-fancy.test.ts b/tests/log-fancy.test.ts new file mode 100644 index 0000000..c3f87a3 --- /dev/null +++ b/tests/log-fancy.test.ts @@ -0,0 +1,26 @@ +// Terminal styling is for people only: piped, CI, NO_COLOR, TERM=dumb, MCP and serve children get the plain lines. +import { describe, expect, test } from 'bun:test'; +import { emojiFor, fancy } from '../cli/lib/log'; + +describe('fancy terminal output', () => { + test('only on a TTY with nothing asking for plain text', () => { + expect(fancy({}, true)).toBe(true); + expect(fancy({}, false)).toBe(false); + for (const env of [{ NO_COLOR: '1' }, { CI: 'true' }, { TERM: 'dumb' }, { STOREBOUND_MCP: '1' }, { STOREBOUND_MACHINE: '1' }]) expect(fancy(env, true)).toBe(false); + }); + test('emoji by level and message', () => { + expect(emojiFor('anything', 'done')).toBe('✅'); + expect(emojiFor('anything', 'warn')).toBe('⚠️ '); + expect(emojiFor('upload failed: 409', 'log')).toBe('❌'); + expect(emojiFor('uploading 3 files', 'log')).toBe('📦'); + expect(emojiFor('submitted for review', 'log')).toBe('🚀'); + expect(emojiFor('dry run: nothing changed', 'log')).toBe('🧪'); + }); + test('a piped run prints the plain line, no ANSI', () => { + const r = Bun.spawnSync(['bun', '-e', "const { log, warn, done } = await import('./cli/lib/log.ts'); log('t', 'waiting for x'); warn('t', 'careful'); done('t', 'ok');"], { cwd: `${import.meta.dir}/..`, stdout: 'pipe', stderr: 'pipe' }); + const out = r.stdout.toString() + r.stderr.toString(); + expect(out).not.toMatch(/\x1b\[/); + expect(out).toMatch(/\[t\] waiting for x/); + expect(out).toMatch(/\[t\] WARN careful/); + }); +}); From a8a889fb3567586ee5c0b3f8908c47d863fad868 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:17:28 +0800 Subject: [PATCH 007/255] release: a staged npm publish found on the retry is reported as waiting for the maintainer's approval instead of failing after 5 minutes --- tools/release.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/release.ts b/tools/release.ts index 9d1a6d1..0565d00 100644 --- a/tools/release.ts +++ b/tools/release.ts @@ -481,6 +481,8 @@ async function npmRelease(version: string, token: string): Promise { again = true; say(`npm: ${version} is not on the registry yet: publishing once more`); const p2 = npmRun(['publish'], token); + // 0.8.25: the first publish was staged silently; the retry's 409 "previously staged" says so (no 5-minute wait) + if (/staged/i.test(p2.out)) { say(`npm: ${version} is staged and waits for the maintainer's approval: https://www.npmjs.com/package/storebound (approve it there; then deprecate runs with: bun tools/release.ts npm-publish)`); return; } if (p2.code !== 0 && !/previously published|cannot publish over/i.test(p2.out)) fail(`npm publish (second try) failed: ${p2.out.trim().split('\n').slice(-4).join(' ')}`); } } From 2598c4953aac91b3ab69ed67b940d0797406bfc7 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:21:26 +0800 Subject: [PATCH 008/255] doctor: build dependencies (JDK with the version the project needs, Android SDK command-line tools, CocoaPods, Node, Bun, bundletool), too-old status, doctor --install --yes runs the package-manager command (never sudo); desktop Dependencies shows too old; gen-flags works on Windows paths --- CHANGELOG.md | 5 +++ apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/tr.ts | 1 + apps/desktop/src/screens/Dependencies.tsx | 1 + cli/commands.ts | 2 +- cli/data/flags.json | 7 +++- cli/doctor.ts | 41 +++++++++++++++--- cli/lib/tools.ts | 51 ++++++++++++++++++++++- tests/deps.test.ts | 36 ++++++++++++++++ tools/gen-flags.ts | 6 +-- 10 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 tests/deps.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 939cbef..14004de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- **Build dependencies in `doctor` and the desktop Dependencies screen.** JDK (with the version the project needs from + the Android Gradle plugin / jvmToolchain, "too old" when below it), Android SDK command-line tools, CocoaPods, Node.js, + Bun and bundletool join ffmpeg, Chrome, Xcode, adb and git. `storebound doctor --install --yes` runs this OS's + package-manager command (winget / brew); sudo and admin commands are printed, never run. + - **A livelier terminal.** At a terminal, lines get colors and status emoji (✅ ⚠️ ❌ 📦 🚀 🧪) and every wait ("waiting for ...", uploads, downloads, progress) is one live spinner line with the elapsed time. Piped, CI, `NO_COLOR`, `TERM=dumb`, `--json` and MCP keep the plain lines exactly as before. diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3585c39..9e21009 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -543,6 +543,7 @@ export const en = { 'deps.tools': 'Tools on this computer', 'deps.found': '{found} of {total} found', 'deps.missing': 'Missing', + 'deps.outdated': 'Too old (needs {version})', 'deps.appCopy': 'App copy', 'deps.installed': 'Installed', 'deps.download': 'Download', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index e4847bc..79f399c 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -545,6 +545,7 @@ export const tr: Record = { 'deps.tools': 'Bu bilgisayardaki araçlar', 'deps.found': '{total} araçtan {found} tanesi var', 'deps.missing': 'Eksik', + 'deps.outdated': 'Eski (gereken {version})', 'deps.appCopy': 'Uygulamanın kopyası', 'deps.installed': 'Kurulu', 'deps.download': 'İndir', diff --git a/apps/desktop/src/screens/Dependencies.tsx b/apps/desktop/src/screens/Dependencies.tsx index 3d7cc20..9c388e2 100644 --- a/apps/desktop/src/screens/Dependencies.tsx +++ b/apps/desktop/src/screens/Dependencies.tsx @@ -15,6 +15,7 @@ const TOOL_ICON: Record = { ffmpeg: 'image', ffprobe: 'search' /** Status chip of one tool. */ export function toolState(x: ToolStatus): { text: string; tone: 'ok' | 'warn' | 'accent' } { if (!x.found) return { text: t('deps.missing'), tone: 'warn' }; + if (x.status === 'outdated') return { text: t('deps.outdated', { version: x.required ?? '' }), tone: 'warn' }; return x.source === 'app' ? { text: t('deps.appCopy'), tone: 'accent' } : { text: t('deps.installed'), tone: 'ok' }; } diff --git a/cli/commands.ts b/cli/commands.ts index b58f9e6..7d1846f 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -15,7 +15,7 @@ export const PLAIN: Record Promise, string]> = { 'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [] | remove : the app folders of this machine, the same list as the desktop app'], 'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'], 'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use | default (the machine default; --clear)'], - 'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'], + 'doctor': [() => import('./doctor'), 'build and capture dependencies (JDK with the version the project needs, Android SDK, Xcode, CocoaPods, Node, Bun, ffmpeg, Chrome, adb, git, bundletool) and how to install what is missing; --install --yes; --json'], 'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'], 'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'], 'privacy-manifest': [() => import('./privacy-manifest'), 'iOS PrivacyInfo.xcprivacy + required-reason API + SDK manifest scan (--ios )'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 9c2cd6a..be6a24c 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -459,7 +459,8 @@ "xdg-mime" ], "doctor": [ - "dir" + "dir", + "install" ], "edit": [ "ops", @@ -1019,6 +1020,7 @@ "identity", "include-drafts", "include-high-income", + "install", "internal", "internal-only", "invite", @@ -1370,7 +1372,8 @@ "storebound desktop --download only save the installer for this system into ~/Downloads (checked as above)" ], "doctor": [ - "storebound doctor [--dir .] [--json]" + "storebound doctor [--dir .] [--json]", + "storebound doctor --install [--yes] runs this OS's install command for one tool (winget / brew / apt);" ], "edit": [ "storebound edit --ops '' [--dry] [--json]", diff --git a/cli/doctor.ts b/cli/doctor.ts index 566a3e2..7b2de04 100644 --- a/cli/doctor.ts +++ b/cli/doctor.ts @@ -2,6 +2,8 @@ // Dependencies screen (lib/tools.ts). Store work (listings, products, releases, reviews) needs none of these tools; // screenshots, app previews, icons and simulator / emulator captures do. Read-only: it prints commands, it runs none. // storebound doctor [--dir .] [--json] +// storebound doctor --install [--yes] runs this OS's install command for one tool (winget / brew / apt); +// without --yes only prints it. Never sudo, never an admin prompt. import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import pkg from '../package.json' with { type: 'json' }; @@ -22,6 +24,9 @@ export interface DoctorRow { path: string | null; source: ToolStatus['source']; usedFor: string; + /** The version the project needs and ok / missing / outdated. */ + required?: string | null; + status?: 'ok' | 'missing' | 'outdated'; /** Install commands for this OS (empty when found). */ install: string[]; url: string | null; @@ -38,30 +43,54 @@ export interface DoctorResult { } export function doctor(cwd: string, env: Record = process.env): DoctorResult { - const tools: DoctorRow[] = detectTools(env).map(t => ({ id: t.id, name: t.name, found: t.found, version: t.version, path: t.path, source: t.source, usedFor: t.usedFor, - install: t.found ? [] : installCommands(t.id), url: t.install.url })); + const tools: DoctorRow[] = detectTools(env, cwd).map(t => ({ id: t.id, name: t.name, found: t.found, version: t.version, path: t.path, source: t.source, usedFor: t.usedFor, + required: t.required ?? null, status: t.status, install: t.status === 'ok' ? [] : installCommands(t.id), url: t.install.url })); const pw = findPlaywright([cwd, rt.packageRoot()]); tools.splice(3, 0, { id: 'playwright', name: 'playwright', found: !!pw, version: null, path: pw, source: pw ? 'system' : null, usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' }); const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null; const sdk = androidSdk([join(cwd, 'android'), cwd], env); - return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk, credentials: credentialStatus(cwd), + return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd), foreignRsync: process.platform === 'darwin' ? foreignRsync(env.PATH) : null }; } +/** One install command of a tool, run only with --yes; sudo / admin commands are printed, never run. */ +export function installPlan(r: DoctorResult, id: string, platform: NodeJS.Platform = process.platform): { tool: DoctorRow; command: string | null; runnable: boolean } { + const tool = r.tools.find(t => t.id === id); + if (!tool) throw new Error(`doctor --install: no tool "${id}" (one of ${r.tools.map(t => t.id).join(', ')})`); + const command = installCommands(tool.id, platform)[0] ?? null; + return { tool, command, runnable: !!command && !/^sudo |\| *(ba)?sh|^Install /.test(command) }; +} + export async function run(args: Args): Promise { const r = doctor(resolve(args.str('dir', '.'))); + const want = args.get('install'); + if (want) { + const plan = installPlan(r, want); + if (plan.tool.status === 'ok') { log('doctor', `${plan.tool.name} ${plan.tool.version ?? ''} is already here${plan.tool.required ? ` (the project needs ${plan.tool.required})` : ''}`); return; } + if (!plan.command) throw new Error(`doctor --install: no install command for ${plan.tool.name} on this OS: ${plan.tool.url}`); + if (!plan.runnable || !args.has('yes')) { + log('doctor', `would run: ${plan.command}${plan.runnable ? ' (add --yes to run it)' : ' (needs admin rights: run it yourself)'}`); + return; + } + log('doctor', `installing ${plan.tool.name}: ${plan.command}`); + const p = rt.spawnSync(plan.command.split(/\s+/), { stdout: 'inherit', stderr: 'inherit', timeout: 20 * 60_000 }); + if (!p.success) throw new Error(`${plan.command} failed (exit ${p.exitCode}); open a new terminal if it installed but is not on PATH yet`); + const after = doctor(resolve(args.str('dir', '.'))).tools.find(t => t.id === plan.tool.id); + log('doctor', `${plan.tool.name}: ${after?.status ?? 'unknown'}${after?.version ? ` ${after.version}` : ''} (a new terminal may be needed for PATH)`); + return; + } if (args.has('json')) { printJson(r); return; } log('doctor', `storebound ${r.storebound} · ${r.runtime} · ${r.platform}${r.project ? ' · project config found' : ''}`); - console.log(textTable(['tool', 'status', 'version', 'used for'], r.tools.map(t => [t.name, t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.usedFor]))); + console.log(textTable(['tool', 'status', 'version', 'needs', 'used for'], r.tools.map(t => [t.name, t.status === 'outdated' ? 'too old' : t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.required ?? '', t.usedFor]))); log('doctor', r.androidSdk.path ? `Android SDK: ${r.androidSdk.path} (from ${r.androidSdk.source})` : `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`); const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key); log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`); if (r.foreignRsync) log('doctor', `rsync: ${r.foreignRsync} is first on PATH and breaks Xcode exportArchive ("Copy failed") outside storebound; storebound apple build puts /usr/bin first. Fix for everything else: brew unlink rsync, or put /usr/bin before it`); - const missing = r.tools.filter(t => !t.found); + const missing = r.tools.filter(t => !t.found || t.status === 'outdated'); if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; } - console.log('\nTo install what is missing (pick one per tool; storebound never runs these for you):'); + console.log('\nTo install what is missing (storebound doctor --install --yes runs the first one; sudo / admin ones you run yourself):'); for (const t of missing) { console.log(` ${t.name}`); for (const c of t.install) console.log(` ${c}`); diff --git a/cli/lib/tools.ts b/cli/lib/tools.ts index 3e7217e..db00065 100644 --- a/cli/lib/tools.ts +++ b/cli/lib/tools.ts @@ -8,7 +8,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { delimiter, join } from 'node:path'; import * as rt from './rt'; -export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git'; +export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git' | 'java' | 'sdkmanager' | 'cocoapods' | 'node' | 'bun' | 'bundletool'; export interface ToolStatus { id: ToolId; @@ -22,6 +22,10 @@ export interface ToolStatus { usedFor: string; /** How to get it: the desktop app downloads ffmpeg and Chrome for Testing; Xcode and the Android SDK are link-out. */ install: { kind: 'download' | 'link'; url: string; note: string }; + /** The version the project needs (JDK from the Android Gradle plugin / jvmToolchain), when one is known. */ + required?: string | null; + /** ok, missing, or found but older than `required`. */ + status?: 'ok' | 'missing' | 'outdated'; } /** Folders where tools usually live but that are often missing from PATH (apps started from the Dock or Finder get @@ -57,6 +61,8 @@ function firstLine(cmd: string[], env: Record): string | null { /** "ffmpeg version 7.1.1 Copyright ..." -> "7.1.1"; "Google Chrome for Testing 131.0.6778.85" -> "131.0.6778.85". */ export function parseVersion(line: string | null): string | null { if (!line) return null; + const java = /version "(\d[^"]*)"/.exec(line)?.[1]; + if (java) return java; return line.match(/version\s+([^\s,]+)/i)?.[1] ?? line.match(/(\d+(?:\.\d+)+)/)?.[1] ?? null; } @@ -69,7 +75,7 @@ export function systemChrome(platform = process.platform, env: Record = process.env): ToolStatus[] { +export function detectTools(env: Record = process.env, project?: string): ToolStatus[] { const PATH = toolPath(env); const spawnEnv: Record = { ...Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] !== undefined)), PATH }; const appDirs = (env.STOREBOUND_TOOL_PATH ?? '').split(delimiter).filter(Boolean); @@ -102,9 +108,44 @@ export function detectTools(env: Record = process.en } out.push(bin('adb', 'adb (Android platform tools)', 'Android emulator / device screenshots and recordings', { kind: 'link', url: 'https://developer.android.com/tools/releases/platform-tools', note: 'Install Android Studio or the SDK platform tools.' }, ['version'])); out.push(bin('git', 'git', 'release notes from history, analytics compare', { kind: 'link', url: 'https://git-scm.com/downloads', note: 'Install git from your OS package manager.' }, ['--version'])); + // build toolchain: what play build / apple build need + const javaBin = env.JAVA_HOME && existsSync(join(env.JAVA_HOME, 'bin')) ? join(env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') : which('java'); + out.push({ id: 'java', name: 'JDK (Java)', found: !!javaBin, path: javaBin, version: javaBin ? parseVersion(firstLine([javaBin, '-version'], spawnEnv)) : null, source: javaBin ? 'system' : null, + usedFor: 'play build (Gradle), keystore checks (keytool)', required: project ? requiredJdk(project) : null, + install: { kind: 'link', url: 'https://adoptium.net/temurin/releases/', note: 'Temurin JDK; the version the Android Gradle plugin asks for.' } }); + const sdk = androidSdk(project ? [join(project, 'android'), project] : [], env); + const sdkm = sdk.path ? ['cmdline-tools/latest/bin', 'tools/bin'].map(d => join(sdk.path!, d, process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager')).find(f => existsSync(f)) ?? null : null; + out.push({ id: 'sdkmanager', name: 'Android SDK command-line tools', found: !!sdkm, path: sdkm ?? sdk.path, version: null, source: sdkm ? 'system' : null, + usedFor: 'play build: SDK platforms and build tools', install: { kind: 'link', url: 'https://developer.android.com/studio#command-tools', note: 'Android Studio, or the command-line tools alone.' } }); + if (process.platform === 'darwin') out.push(bin('pod' as ToolId, 'CocoaPods', 'iOS builds of Capacitor / React Native / Flutter apps with a Podfile', { kind: 'link', url: 'https://cocoapods.org', note: 'brew install cocoapods' }, ['--version'])); + out.push(bin('node', 'Node.js', 'web builds (npm scripts), npx storebound', { kind: 'link', url: 'https://nodejs.org', note: 'The LTS release.' }, ['--version'])); + out.push(bin('bun', 'Bun', 'storebound from a git clone, web builds', { kind: 'link', url: 'https://bun.sh', note: 'bun.sh installer or a package manager.' }, ['--version'])); + out.push(bin('bundletool', 'bundletool', 'checks of the AAB (sizes, APKs per device)', { kind: 'link', url: 'https://github.com/google/bundletool/releases', note: 'Optional.' }, ['version'])); + for (const t of out) { + if ((t.id as string) === 'pod') t.id = 'cocoapods'; + t.status = !t.found ? 'missing' : t.required && t.version && majorOf(t.version) < majorOf(t.required) ? 'outdated' : 'ok'; + } return out; } +/** "17.0.9" -> 17; "1.8.0_392" -> 8. */ +export const majorOf = (v: string): number => { const n = v.replace(/^1\.(?=\d+\.)/, '').match(/\d+/); return n ? Number(n[0]) : 0; }; + +/** + * The JDK major version an Android project needs: jvmToolchain(N) / JavaVersion.VERSION_N in its Gradle files, at + * least the Android Gradle plugin's minimum (AGP 8.x: 17, 7.x: 11). Null when the project has no Gradle files. + */ +export function requiredJdk(project: string): string | null { + const files = ['android/build.gradle', 'android/build.gradle.kts', 'android/app/build.gradle', 'android/app/build.gradle.kts', 'android/gradle/libs.versions.toml', + 'build.gradle', 'build.gradle.kts', 'app/build.gradle', 'app/build.gradle.kts', 'gradle/libs.versions.toml'].map(f => join(project, f)).filter(f => existsSync(f)); + if (!files.length) return null; + const text = files.map(f => { try { return readFileSync(f, 'utf8'); } catch { return ''; } }).join('\n'); + const toolchain = /jvmToolchain\s*\(\s*(\d+)/.exec(text)?.[1] ?? /JavaVersion\.VERSION_(\d+)/.exec(text)?.[1] ?? /JavaLanguageVersion\.of\(\s*(\d+)/.exec(text)?.[1]; + const agp = /com\.android\.tools\.build:gradle:(\d+)/.exec(text)?.[1] ?? /^\s*(?:agp|androidGradlePlugin|android-gradle-plugin)\s*=\s*"(\d+)/m.exec(text)?.[1]; + const fromAgp = agp ? (Number(agp) >= 8 ? 17 : Number(agp) >= 7 ? 11 : 8) : 17; + return String(Math.max(toolchain ? Number(toolchain) : 0, fromAgp)); +} + /** * Commands that install a tool on this OS (shown by `storebound doctor` and the desktop Dependencies screen, never * run by storebound). Package managers only; the desktop app can also download ffmpeg and Chrome for Testing itself. @@ -119,6 +160,12 @@ export function installCommands(id: ToolId | 'playwright', platform: NodeJS.Plat case 'simctl': return mac ? ['Install Xcode from the App Store, then: sudo xcode-select -s /Applications/Xcode.app'] : []; case 'adb': return mac ? ['brew install --cask android-platform-tools'] : win ? ['winget install --id Google.PlatformTools'] : ['sudo apt install adb']; case 'git': return mac ? ['xcode-select --install', 'brew install git'] : win ? ['winget install --id Git.Git'] : ['sudo apt install git']; + case 'java': return mac ? ['brew install --cask temurin@17'] : win ? ['winget install --id EclipseAdoptium.Temurin.17.JDK'] : ['sudo apt install openjdk-17-jdk']; + case 'sdkmanager': return mac ? ['brew install --cask android-commandlinetools'] : win ? ['winget install --id Google.AndroidStudio'] : ['sudo snap install android-studio --classic']; + case 'cocoapods': return mac ? ['brew install cocoapods'] : []; + case 'node': return mac ? ['brew install node'] : win ? ['winget install --id OpenJS.NodeJS.LTS'] : ['sudo apt install nodejs npm']; + case 'bun': return mac ? ['brew install oven-sh/bun/bun'] : win ? ['winget install --id Oven-sh.Bun'] : ['curl -fsSL https://bun.sh/install | bash']; + case 'bundletool': return mac ? ['brew install bundletool'] : []; } } diff --git a/tests/deps.test.ts b/tests/deps.test.ts new file mode 100644 index 0000000..053ede4 --- /dev/null +++ b/tests/deps.test.ts @@ -0,0 +1,36 @@ +// Build dependencies: the JDK the project needs, version parsing, and doctor --install planning. +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { installPlan, type DoctorResult } from '../cli/doctor'; +import { installCommands, majorOf, parseVersion, requiredJdk } from '../cli/lib/tools'; + +function project(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'sb-deps-')); + for (const [p, text] of Object.entries(files)) { mkdirSync(join(dir, p, '..'), { recursive: true }); writeFileSync(join(dir, p), text); } + return dir; +} + +describe('build dependencies', () => { + test('JDK version from java -version and majors', () => { + expect(parseVersion('openjdk version "17.0.9" 2023-10-17')).toBe('17.0.9'); + expect(parseVersion('java version "1.8.0_392"')).toBe('1.8.0_392'); + expect(majorOf('17.0.9')).toBe(17); + expect(majorOf('1.8.0_392')).toBe(8); + }); + test('the JDK a project needs: AGP minimum, jvmToolchain wins when higher', () => { + expect(requiredJdk(project({ 'android/build.gradle': "classpath 'com.android.tools.build:gradle:8.7.2'" }))).toBe('17'); + expect(requiredJdk(project({ 'android/build.gradle': "classpath 'com.android.tools.build:gradle:7.4.2'" }))).toBe('11'); + expect(requiredJdk(project({ 'android/gradle/libs.versions.toml': 'agp = "8.5.0"', 'android/app/build.gradle.kts': 'kotlin { jvmToolchain(21) }' }))).toBe('21'); + expect(requiredJdk(project({ 'package.json': '{}' }))).toBeNull(); + }); + test('install commands per OS; doctor --install runs only package-manager commands, never sudo', () => { + expect(installCommands('java', 'win32')[0]).toBe('winget install --id EclipseAdoptium.Temurin.17.JDK'); + expect(installCommands('sdkmanager', 'darwin')[0]).toBe('brew install --cask android-commandlinetools'); + const r = { tools: [{ id: 'java', name: 'JDK (Java)', found: false, version: null, path: null, source: null, usedFor: '', install: [], url: null, status: 'missing' }] } as unknown as DoctorResult; + expect(installPlan(r, 'java', 'win32')).toMatchObject({ command: 'winget install --id EclipseAdoptium.Temurin.17.JDK', runnable: true }); + expect(installPlan(r, 'java', 'linux').runnable).toBe(false); + expect(() => installPlan(r, 'nope', 'win32')).toThrow('no tool "nope"'); + }); +}); diff --git a/tools/gen-flags.ts b/tools/gen-flags.ts index c98884a..aaacd81 100644 --- a/tools/gen-flags.ts +++ b/tools/gen-flags.ts @@ -9,7 +9,7 @@ // bun tools/gen-flags.ts write cli/data/flags.json // bun tools/gen-flags.ts --check exit 1 when the file is stale (tests/cli-help.test.ts runs it) import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, relative, resolve } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; const ROOT = resolve(import.meta.dir, '..'); const CLI = join(ROOT, 'cli'); @@ -48,9 +48,9 @@ function scan(entry: string, errors: string[]): Set { const f = todo.pop()!; if (seen.has(f)) continue; seen.add(f); - const rel = relative(CLI, f); + const rel = relative(CLI, f).split(sep).join('/'); // lib/embedded.ts holds file texts (templates, shots pages: their own scripts read URL params), not flag reads - if (rel === 'index.ts' || rel === 'commands.ts' || rel === join('lib', 'embedded.ts')) continue; + if (rel === 'index.ts' || rel === 'commands.ts' || rel === 'lib/embedded.ts') continue; const src = readFileSync(f, 'utf8'); for (const m of src.matchAll(READ)) if (!NOT_ARGS.test(m[1]!)) flags.add(m[3]!); for (const fl of DYNAMIC[rel] ?? []) flags.add(fl); From 9bd17c401d987dea8cce54cd570ab625194d8b2b Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:27:34 +0800 Subject: [PATCH 009/255] Bypass mode (owner opt-in, default off): storebound settings bypass on|off|status and a one-time question in init; MCP owner_step (owner-only commands, dry run first), owner-only api operations with --yes --confirm, console plans may press buttons.final; every action logged to .storebound/owner-actions.log; docs/bypass.md, agent rules, tests --- .cursor/rules/storebound.mdc | 4 +++ .github/copilot-instructions.md | 4 +++ AGENTS.md | 4 +++ CHANGELOG.md | 6 ++++ GEMINI.md | 4 +++ cli/api.ts | 3 +- cli/api/gate.ts | 7 +++-- cli/commands.ts | 1 + cli/data/flags.json | 4 +++ cli/init.ts | 11 ++++++- cli/lib/bypass.ts | 54 +++++++++++++++++++++++++++++++++ cli/lib/console-plan.ts | 2 +- cli/lib/desktop-coverage.ts | 1 + cli/mcp-tools.ts | 14 +++++++-- cli/mcp.ts | 14 ++++++--- cli/settings.ts | 20 ++++++++++++ docs/agents-core.md | 4 +++ docs/bypass.md | 30 ++++++++++++++++++ docs/desktop-coverage.md | 3 +- tests/bypass.test.ts | 39 ++++++++++++++++++++++++ tests/desktop-coverage.test.ts | 2 +- 21 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 cli/lib/bypass.ts create mode 100644 cli/settings.ts create mode 100644 docs/bypass.md create mode 100644 tests/bypass.test.ts diff --git a/.cursor/rules/storebound.mdc b/.cursor/rules/storebound.mdc index 8ba9940..18a30b6 100644 --- a/.cursor/rules/storebound.mdc +++ b/.cursor/rules/storebound.mdc @@ -20,6 +20,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: + MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and + console plans' `buttons.final`. Say in one line what you pressed; every action is logged to + `.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so. 3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety, App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start (`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5515c62..7ab0224 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,6 +20,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: + MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and + console plans' `buttons.final`. Say in one line what you pressed; every action is logged to + `.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so. 3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety, App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start (`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that diff --git a/AGENTS.md b/AGENTS.md index abbc502..ea2ee8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: + MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and + console plans' `buttons.final`. Say in one line what you pressed; every action is logged to + `.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so. 3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety, App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start (`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that diff --git a/CHANGELOG.md b/CHANGELOG.md index 14004de..89ac8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- **Bypass mode (owner opt-in, default off).** `storebound settings bypass on` (or "y" in `storebound init`) lets the AI + agent press the owner-only buttons too: MCP `owner_step` (submit, release, rollout, testers, replies, prices, ...; + dry run first), owner-only `storebound api` operations with `--yes --confirm `, console plans' + `buttons.final`. Every action is logged to `.storebound/owner-actions.log`; `bypass off` restores the old behaviour. + docs/bypass.md. + - **Build dependencies in `doctor` and the desktop Dependencies screen.** JDK (with the version the project needs from the Android Gradle plugin / jvmToolchain, "too old" when below it), Android SDK command-line tools, CocoaPods, Node.js, Bun and bundletool join ffmpeg, Chrome, Xcode, adb and git. `storebound doctor --install --yes` runs this OS's diff --git a/GEMINI.md b/GEMINI.md index 05becf9..3677fc4 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -19,6 +19,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: + MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and + console plans' `buttons.final`. Say in one line what you pressed; every action is logged to + `.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so. 3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety, App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start (`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that diff --git a/cli/api.ts b/cli/api.ts index b8b5568..a629aed 100644 --- a/cli/api.ts +++ b/cli/api.ts @@ -10,6 +10,7 @@ // owner-only operations (submit, release, delete, cancel, prices, testers, review replies, experiments: cli/api/gate.ts) // also need the operation id typed at the prompt. Under MCP or the desktop app no write is ever sent. import { readFileSync, writeFileSync } from 'node:fs'; +import { bypassOn } from './lib/bypass'; import { resolve } from 'node:path'; import type { Args } from './lib/args'; import { allOps, authFor, type BuiltRequest, buildRequest, resolveOp, send, sendAll } from './api/client'; @@ -145,7 +146,7 @@ export async function run(args: Args): Promise { kind, method: op.method, dry: args.has('dry'), yes: args.has('yes'), getOnly, tty: stdinIsTTY() && !!process.stdout.isTTY, agent: process.env.STOREBOUND_SERVE === '1' || process.env.STOREBOUND_MCP === '1', - confirm: args.get('confirm'), opId: op.id, + confirm: args.get('confirm'), opId: op.id, bypass: bypassOn(), }); if (decision.action === 'refuse') { const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null; diff --git a/cli/api/gate.ts b/cli/api/gate.ts index 599721e..d3f6ef7 100644 --- a/cli/api/gate.ts +++ b/cli/api/gate.ts @@ -83,6 +83,8 @@ export interface GateInput { confirm?: string; /** the operation id the call resolved to (what --confirm must equal) */ opId?: string; + /** the owner turned bypass mode on (storebound settings bypass on): owner-only operations take --confirm too */ + bypass?: boolean; } export type Decision = @@ -102,16 +104,17 @@ export const OWNER_NO_CONFIRM = 'refused: owner-only operation: --confirm does n export function decide(g: GateInput): Decision { if (g.getOnly && g.method.toUpperCase() !== 'GET') return { action: 'refuse', why: 'api get runs GET operations only' }; if (g.kind === 'read') return g.dry ? { action: 'dry', why: '--dry' } : { action: 'send' }; - if (g.agent) return { action: 'refuse', why: g.kind === 'owner' + 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.confirm !== undefined) { // an agent's typed confirmation: only for plain writes, only when it names this exact operation - if (g.kind === 'owner') return { action: 'refuse', why: OWNER_NO_CONFIRM }; + if (g.kind === 'owner' && !g.bypass) return { action: 'refuse', why: OWNER_NO_CONFIRM }; if (!g.opId || g.confirm !== g.opId) return { action: 'refuse', why: `refused: --confirm ${g.confirm} does not equal the operation id ${g.opId ?? '?'}. Nothing was sent.` }; return { action: 'send' }; } + if (g.agent) return { action: 'refuse', why: 'bypass mode: an agent sends writes with --yes --confirm ' }; if (!g.tty) return { action: 'refuse', why: NOT_A_TTY }; return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' }; } diff --git a/cli/commands.ts b/cli/commands.ts index 7d1846f..789219c 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -15,6 +15,7 @@ export const PLAIN: Record Promise, string]> = { 'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [] | remove : the app folders of this machine, the same list as the desktop app'], 'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'], 'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use | default (the machine default; --clear)'], + 'settings': [() => import('./settings'), 'per-machine settings: bypass on | off | status (owner opt-in: the agent also presses the owner-only buttons; default off)'], 'doctor': [() => import('./doctor'), 'build and capture dependencies (JDK with the version the project needs, Android SDK, Xcode, CocoaPods, Node, Bun, ffmpeg, Chrome, adb, git, bundletool) and how to install what is missing; --install --yes; --json'], 'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'], 'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'], diff --git a/cli/data/flags.json b/cli/data/flags.json index be6a24c..a93f66b 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -797,6 +797,7 @@ "serve": [ "stdio" ], + "settings": [], "shots capture": [ "concurrency", "device", @@ -1504,6 +1505,9 @@ "storebound scan --write-only [--dry] only that write (with --dry: the config diff), no report (desktop Fix button)", "storebound scan --originality [--against ] [--threshold 0.7] also the 4.3 / Play repetitive content" ], + "settings": [ + "storebound settings bypass on | off | status owner opt-in: the agent also presses the owner-only buttons" + ], "shots capture": [ "storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder]" ], diff --git a/cli/init.ts b/cli/init.ts index bb77b19..56c3608 100644 --- a/cli/init.ts +++ b/cli/init.ts @@ -6,11 +6,13 @@ // App Privacy answers, Data safety section, listing copy and screenshot copy; every answer is a starting point to edit. // Lookup per file: templates/profiles//, then the profile it builds on, then templates/. import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { bypassOn, readSettings, setBypass } from './lib/bypass'; +import { stdinIsTTY } from './lib/tty'; import { dirname, join, relative, resolve } from 'node:path'; import type { Args } from './lib/args'; import { type AppProfile, PROFILES } from './lib/config'; import { ignoreInRepo } from './lib/git-ignore'; -import { log } from './lib/log'; +import { log, promptOut } from './lib/log'; import { TEMPLATE_TEXT } from './lib/template-files'; import * as rt from './lib/rt'; @@ -74,5 +76,12 @@ export async function run(args: Args): Promise { // store art (rendered or pulled from the stores) is never committed const art = ignoreInRepo(join(root, 'store', 'renders'), 'storebound: store art (rendered or pulled from the stores), never committed'); if (art) log('init', `.gitignore: added ${art}`); + // bypass mode is asked once per machine, only of a person at a terminal (default no); agents never answer it + if (readSettings().bypass === undefined && stdinIsTTY() && process.stdout.isTTY && !args.has('yes')) { + promptOut().write('Bypass mode: let your AI agent also press the owner-only buttons (Submit for review, releases, rollouts, testers, replies, prices, console Create)? Every action is logged; turn it off any time with storebound settings bypass off. [y/N] '); + const a = await rt.readLine(); + setBypass(a !== null && /^y(es)?$/i.test(a.trim())); + log('init', `bypass mode: ${bypassOn() ? 'ON' : 'off'} (storebound settings bypass on | off)`); + } log('init', 'next: edit storebound.config.ts, store/*.md and .storebound/credentials.json, then `storebound check`; store/workflows/*.yml: storebound flow list'); } diff --git a/cli/lib/bypass.ts b/cli/lib/bypass.ts new file mode 100644 index 0000000..2b3d15e --- /dev/null +++ b/cli/lib/bypass.ts @@ -0,0 +1,54 @@ +// Bypass mode: the owner's opt-in to let an AI agent press the owner-only buttons too (Submit for review, releases and +// rollouts, testers, review replies, prices, deletes, cancels, console final buttons). Default OFF. Stored per machine +// in ~/.storebound/settings.json, never in a project's config or repo: +// storebound settings bypass on | off | status +// When ON: the MCP server lists `owner_step` (runs one owner command, dry run first), `storebound api` sends owner-only +// operations with --yes --confirm , console plans tell the agent it may press buttons.final. Every owner +// action an agent runs is appended to /.storebound/owner-actions.log. `bypass off` restores the old behaviour +// at once. An AI client may still ask the owner itself for legal agreements, passwords and 2FA (its own rules). +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export interface Settings { bypass?: { on: boolean; since: string } } + +export const settingsFile = (env: Record = process.env): string => + join(env.STOREBOUND_HOME ?? join(homedir(), '.storebound'), 'settings.json'); + +export function readSettings(env: Record = process.env): Settings { + try { return JSON.parse(readFileSync(settingsFile(env), 'utf8')) as Settings; } catch { return {}; } +} + +export function writeSettings(s: Settings, env: Record = process.env): void { + const f = settingsFile(env); + mkdirSync(dirname(f), { recursive: true }); + writeFileSync(f, `${JSON.stringify(s, null, 2)}\n`); +} + +/** Whether the owner turned bypass mode on for this machine. */ +export const bypassOn = (env: Record = process.env): boolean => readSettings(env).bypass?.on === true; + +export function setBypass(on: boolean, env: Record = process.env): Settings { + const s = readSettings(env); + s.bypass = { on, since: new Date().toISOString() }; + writeSettings(s, env); + return s; +} + +/** Owner-only commands `owner_step` may run in bypass mode (CLI words). Everything else has its own MCP tool. */ +export const OWNER_COMMANDS = [ + 'apple submit', 'apple resubmit', 'apple iap-submit', 'apple release', 'apple preorder', + 'play release', 'play rollout', + 'reviews', 'apple testflight', 'play products', 'play subscriptions', 'apple iap', 'apple subscriptions', 'prices', +] as const; +export type OwnerCommand = typeof OWNER_COMMANDS[number]; + +export const BYPASS_NOTICE = 'bypass mode is ON (storebound settings bypass off turns it off): the agent runs owner steps itself; each one is logged to .storebound/owner-actions.log'; + +/** One line per owner action an agent ran: when, what, the result. */ +export function logOwnerAction(root: string, entry: { command: string; dry: boolean; exit: number; summary?: string }): string { + const f = join(root, '.storebound', 'owner-actions.log'); + if (!existsSync(dirname(f))) mkdirSync(dirname(f), { recursive: true }); + appendFileSync(f, `${new Date().toISOString()}\t${entry.dry ? 'dry' : 'RAN'}\texit ${entry.exit}\t${entry.command}${entry.summary ? `\t${entry.summary.replace(/\s+/g, ' ').slice(0, 300)}` : ''}\n`); + return f; +} diff --git a/cli/lib/console-plan.ts b/cli/lib/console-plan.ts index f800c59..f333e0d 100644 --- a/cli/lib/console-plan.ts +++ b/cli/lib/console-plan.ts @@ -191,7 +191,7 @@ const DRIVING = [ 'Text fields: click the field and TYPE the text (key presses), then read the value back. Never set it programmatically (form_input, el.value = …): App Store Connect\'s New App kept its Name and SKU empty after a value set (verified 2026-10-13).', 'Owner steps: hand the owner plan.ownerBatch in ONE message before you start (the "before" items: answers, the IARC Terms of Use box) and run without stopping until the "after" items (the final button).', 'An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.', - 'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.', + 'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command. (Bypass mode on, storebound settings bypass: press buttons.final yourself after the owner\'s standing opt-in; agreements, passwords and 2FA still go to the owner.)', 'Never sign in (never type the owner\'s own password), accept an agreement or tick a declaration: the owner does that in the browser. The one password you type is the App Review demo account from .storebound/credentials.json (reviewDemoAccount) on Play\'s Sign in details card, never printed.', ]; diff --git a/cli/lib/desktop-coverage.ts b/cli/lib/desktop-coverage.ts index ba5a082..e127b05 100644 --- a/cli/lib/desktop-coverage.ts +++ b/cli/lib/desktop-coverage.ts @@ -46,6 +46,7 @@ export const DESKTOP_COVERAGE: Record = { 'import': { where: 'App folders / first-run guide: Import from fastlane', methods: ['host:project_import_fastlane'] }, 'install': { where: `${T}: Install for an AI tool`, methods: ['agent.install'] }, 'mcp': { not: 'the stdio server for AI tools; the window itself is the GUI over the same commands' }, + 'settings': { not: 'per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway' }, 'serve': { not: 'the JSON-RPC sidecar the window runs on; nothing to click' }, 'apps': { where: 'Accounts (store apps per account, key check) and App folders (list, add, remove)', methods: ['host:store_apps_refresh', 'host:account_check', 'host:projects_list', 'host:project_add', 'host:project_remove'] }, 'desktop': { not: 'opens or installs this window from a terminal' }, diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index bd3a0ba..ea8e7f1 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -2,6 +2,7 @@ // Rules: one tool per CLI command; every tool that writes anything has `dry_run` defaulting to true; nothing that // submits for review, releases to the public, resumes / completes a rollout, cancels a submission or deletes is // exposed. Those stay terminal commands the owner runs (they also ask for confirmation there). +import { OWNER_COMMANDS } from './lib/bypass'; export interface JsonSchema { type: string; description?: string; enum?: (string | number)[]; default?: unknown; items?: JsonSchema; minimum?: number; maximum?: number; pattern?: string; additionalProperties?: JsonSchema | boolean } @@ -479,8 +480,17 @@ export const OUTPUT_SCHEMA = { required: ['exitCode', 'command', 'output'], } as const; -export function listTools(): Record[] { - return TOOLS.map(t => ({ +/** Bypass mode only (storebound settings bypass on): one owner-only command, dry run by default. */ +export const OWNER_STEP: ToolDef = { + name: 'owner_step', title: 'Owner step (bypass mode)', write: true, destructive: true, network: true, + description: `Bypass mode is on: run one owner-only command yourself (${OWNER_COMMANDS.join(', ')}). Pass the CLI words as command and the rest as args (ids, build numbers, --track, --percent ...). Always call it with dry_run: true first and read the plan; then dry_run: false runs it with --yes. Every call is logged to .storebound/owner-actions.log. Legal agreements, passwords and 2FA stay with the owner.`, + command: [], props: { command: { type: 'string', enum: [...OWNER_COMMANDS], description: 'The owner-only command' }, args: { type: 'array', items: { type: 'string' }, description: 'Its arguments and options' } }, + required: ['command'], + args: i => [...String(i.command).split(' '), ...(Array.isArray(i.args) ? i.args.map(String) : [])], +}; + +export function listTools(bypass = false): Record[] { + return [...TOOLS, ...(bypass ? [OWNER_STEP] : [])].map(t => ({ name: t.name, title: t.title, description: t.description, inputSchema: inputSchema(t), outputSchema: OUTPUT_SCHEMA, annotations: { title: t.title, readOnlyHint: !t.write, destructiveHint: t.write ? !!t.destructive : false, idempotentHint: !!t.idempotent || !t.write, openWorldHint: t.network }, diff --git a/cli/mcp.ts b/cli/mcp.ts index 1bf1f64..95405cc 100644 --- a/cli/mcp.ts +++ b/cli/mcp.ts @@ -25,7 +25,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import pkg from '../package.json' with { type: 'json' }; import { runCommand, type Running } from './lib/runner'; -import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools'; +import { listTools, OWNER_STEP, TOOLS, toArgv, validateInput } from './mcp-tools'; +import { bypassOn, logOwnerAction } from './lib/bypass'; import * as rt from './lib/rt'; /** Current (modern, stateless) revision. */ @@ -102,18 +103,23 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ case 'server/discover': return ok({ supportedVersions: SUPPORTED, capabilities: { tools: {} }, instructions: INSTRUCTIONS, ttlMs: TOOLS_TTL_MS, cacheScope: 'public' }); case 'ping': return ok({}); - case 'tools/list': return ok({ tools: listTools(), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) }); + case 'tools/list': return ok({ tools: listTools(bypassOn()), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) }); case 'tools/call': { const name = String(m.params?.name ?? ''); - const tool = TOOLS.find(t => t.name === name); + const tool = TOOLS.find(t => t.name === name) ?? (name === OWNER_STEP.name && bypassOn() ? OWNER_STEP : undefined); if (!tool) return err(-32602, `Unknown tool: ${name}`); const input = (m.params?.arguments ?? {}) as Record; const problems = validateInput(tool, input); if (problems.length) return ok({ content: [{ type: 'text', text: `Invalid arguments: ${problems.join('; ')}` }], isError: true }); const { argv: raw, dryRun } = toArgv(tool, input); - const argv = textToFile(raw); + // owner_step runs the real command with --yes; its dry run is the command's own --dry + const argv = tool === OWNER_STEP && !dryRun ? [...textToFile(raw), '--yes'] : textToFile(raw); const r = await run(argv, m.id); let out = clip(r.out, argv.includes('--json')); + if (tool === OWNER_STEP) { + const file = logOwnerAction(process.env.STOREBOUND_PROJECT || process.cwd(), { command: `storebound ${argv.join(' ')}`, dry: dryRun, exit: r.code, summary: r.out.trim().split('\n').slice(-1)[0] }); + out += `\n(bypass mode: logged to ${file})`; + } if (dryRun) out += '\n\n(dry run: nothing was changed. Ask the owner before calling again with dry_run: false.)'; const structured = { exitCode: r.code, dryRun, command: `storebound ${argv.join(' ')}`, output: out }; return ok({ content: [{ type: 'text', text: `$ storebound ${argv.join(' ')}\n${out}\n[exit ${r.code}]` }], structuredContent: structured, isError: r.code !== 0 }); diff --git a/cli/settings.ts b/cli/settings.ts new file mode 100644 index 0000000..f058b7f --- /dev/null +++ b/cli/settings.ts @@ -0,0 +1,20 @@ +// Per-machine storebound settings (never in a project's config or repo). +// storebound settings bypass on | off | status owner opt-in: the agent also presses the owner-only buttons +// (docs/bypass.md); default off +import type { Args } from './lib/args'; +import { BYPASS_NOTICE, bypassOn, OWNER_COMMANDS, readSettings, setBypass, settingsFile } from './lib/bypass'; +import { log, printJson } from './lib/log'; + +export async function run(args: Args): Promise { + const [what, value] = args._; + if (what !== 'bypass') throw new Error('usage: storebound settings bypass on | off | status'); + if (value === 'on' || value === 'off') { + setBypass(value === 'on'); + log('settings', value === 'on' + ? `${BYPASS_NOTICE}. Unlocked: ${OWNER_COMMANDS.join(', ')}, owner-only API operations (storebound api ... --yes --confirm ) and console final buttons.` + : 'bypass mode is OFF: owner-only steps are the owner\'s again (MCP hides owner_step, api refuses owner operations, console plans stop at buttons.final)'); + } else if (value && value !== 'status') throw new Error('usage: storebound settings bypass on | off | status'); + const s = readSettings(); + if (args.has('json')) { printJson({ bypass: bypassOn(), since: s.bypass?.since ?? null, file: settingsFile() }); return; } + if (!value || value === 'status') log('settings', `bypass mode: ${bypassOn() ? 'ON' : 'off'}${s.bypass?.since ? ` (since ${s.bypass.since})` : ''} · ${settingsFile()}`); +} diff --git a/docs/agents-core.md b/docs/agents-core.md index de13e68..0482818 100644 --- a/docs/agents-core.md +++ b/docs/agents-core.md @@ -15,6 +15,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: + MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and + console plans' `buttons.final`. Say in one line what you pressed; every action is logged to + `.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so. 3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety, App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start (`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that diff --git a/docs/bypass.md b/docs/bypass.md new file mode 100644 index 0000000..c82b6fc --- /dev/null +++ b/docs/bypass.md @@ -0,0 +1,30 @@ +# Bypass mode + +Default **off**. The owner turns it on once per machine when they want their AI agent to press the owner-only buttons +too, instead of stopping and asking: + +```sh +storebound settings bypass on # or answer "y" to the question in storebound init +storebound settings bypass status +storebound settings bypass off # back to the owner pressing every final button, at once +``` + +It is stored in `~/.storebound/settings.json` (this machine only), never in a project's config or repo. + +## What it unlocks + +- MCP: the `owner_step` tool appears. It runs one of: `apple submit`, `apple resubmit`, `apple iap-submit`, `apple release`, + `apple preorder`, `play release`, `play rollout`, `reviews` (replies), `apple testflight` (testers), `play products`, + `play subscriptions`, `apple iap`, `apple subscriptions`, `prices` (live prices). Dry run first (`dry_run` defaults to + true), then `dry_run: false` runs it with `--yes`. +- `storebound api`: owner-only operations (submit, release, delete, ...) are sent for an agent with + `--yes --confirm `. +- Console plans: the agent may press `buttons.final` (Publish, Create app, Send for review) itself. + +## What stays + +- Every owner action an agent runs is appended to `/.storebound/owner-actions.log` (time, dry or ran, exit + code, the command, the last line of its output). +- Dry runs still come first and must pass. +- An AI client may still ask the owner for legal agreements, passwords and 2FA codes: those are its own rules, and a + storebound setting cannot change them. diff --git a/docs/desktop-coverage.md b/docs/desktop-coverage.md index 7d1e8ee..73c9dde 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). -**95 commands:** 91 covered, 0 gaps, 4 not for the desktop. +**96 commands:** 91 covered, 0 gaps, 5 not for the desktop. | command | status | where in the window | methods | owner step | |---|---|---|---|---| @@ -17,6 +17,7 @@ Owner-only parts are owner actions in the window or owner steps with their termi | `apps` | covered | Accounts (store apps per account, key check) and App folders (list, add, remove) | `host:store_apps_refresh`, `host:account_check`, `host:projects_list`, `host:project_add`, `host:project_remove` | | | `desktop` | not-for-desktop | opens or installs this window from a terminal | | | | `accounts` | covered | Accounts screen (profiles shared with the CLI) | `host:accounts_list`, `host:account_add_play`, `host:account_add_apple`, `host:account_update` | | +| `settings` | not-for-desktop | per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway | | | | `doctor` | covered | Dependencies screen | `system.tools`, `host:tools_info` | | | `scan` | covered | Checks tab: Scan, Fix | `scan`, `scan.write` | | | `api` | not-for-desktop | raw API calls by operation id: every task has its own screen, and raw writes stay a terminal step the owner types | | | diff --git a/tests/bypass.test.ts b/tests/bypass.test.ts new file mode 100644 index 0000000..2e67793 --- /dev/null +++ b/tests/bypass.test.ts @@ -0,0 +1,39 @@ +// Bypass mode: off by default; when the owner turns it on, owner-only steps open to the agent and every one is logged. +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { decide } from '../cli/api/gate'; +import { bypassOn, logOwnerAction, setBypass } from '../cli/lib/bypass'; +import { listTools, OWNER_STEP, toArgv } from '../cli/mcp-tools'; + +const owner = { kind: 'owner' as const, method: 'POST', dry: false, yes: true, tty: false, agent: true, getOnly: false, confirm: 'reviewSubmissions_createInstance', opId: 'reviewSubmissions_createInstance' }; + +describe('bypass mode', () => { + test('off by default; on / off per machine (settings.json)', () => { + const env = { STOREBOUND_HOME: mkdtempSync(join(tmpdir(), 'sb-home-')) }; + expect(bypassOn(env)).toBe(false); + setBypass(true, env); + expect(bypassOn(env)).toBe(true); + setBypass(false, env); + expect(bypassOn(env)).toBe(false); + }); + test('the api gate: owner operations refused for an agent unless bypass, then only with --yes --confirm ', () => { + expect(decide(owner).action).toBe('refuse'); + expect(decide({ ...owner, bypass: true }).action).toBe('send'); + expect(decide({ ...owner, bypass: true, confirm: 'other' }).action).toBe('refuse'); + expect(decide({ ...owner, bypass: true, confirm: undefined }).action).toBe('refuse'); + expect(decide({ ...owner, bypass: true, yes: false }).action).toBe('dry'); + }); + test('MCP lists owner_step only in bypass mode; dry run first', () => { + expect(listTools(false).some(t => t.name === 'owner_step')).toBe(false); + expect(listTools(true).some(t => t.name === 'owner_step')).toBe(true); + expect(toArgv(OWNER_STEP, { command: 'apple submit', args: ['--build', '42'] }).argv).toEqual(['apple', 'submit', '--build', '42', '--dry']); + expect(toArgv(OWNER_STEP, { command: 'play rollout', args: ['--percent', '20'], dry_run: false })).toEqual({ argv: ['play', 'rollout', '--percent', '20'], dryRun: false }); + }); + test('every owner action is logged in the project', () => { + const root = mkdtempSync(join(tmpdir(), 'sb-owner-')); + const f = logOwnerAction(root, { command: 'storebound apple submit --build 42 --yes', dry: false, exit: 0, summary: 'submitted' }); + expect(readFileSync(f, 'utf8')).toMatch(/\tRAN\texit 0\tstorebound apple submit --build 42 --yes\tsubmitted\n$/); + }); +}); diff --git a/tests/desktop-coverage.test.ts b/tests/desktop-coverage.test.ts index 7c4b686..cef5fcf 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']; +const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings']; describe('desktop coverage', () => { const { rows, extra } = analyze(); From 25c2d5cd1e56aa871b063ba3a238ca5fa33a6fba Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 01:33:30 +0700 Subject: [PATCH 010/255] storebound 0.8.26: version in package.json and every copy, CHANGELOG 0.8.26 section --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 40ac3a9..3c7a497 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.25", + "version": "0.8.26", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6e1a9e5..1a38eff 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.25", + "version": "0.8.26", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 6821e9c..861bf1c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.25", + "version": "0.8.26", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/CHANGELOG.md b/CHANGELOG.md index e980e05..8e7161a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.26 · 2026-10-21 + - **Bypass mode (owner opt-in, default off).** `storebound settings bypass on` (or "y" in `storebound init`) lets the AI agent press the owner-only buttons too: MCP `owner_step` (submit, release, rollout, testers, replies, prices, ...; dry run first), owner-only `storebound api` operations with `--yes --confirm `, console plans' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 72b47c9..1b939ad 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.25", + "version": "0.8.26", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 368d239..957e60a 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4010,7 +4010,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.25" +version = "0.8.26" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e1f9e34..bb0cad6 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.25" +version = "0.8.26" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 1ff5db7..614a48e 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.25", + "version": "0.8.26", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", diff --git a/gemini-extension.json b/gemini-extension.json index 9571d0f..41bb88e 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.25", + "version": "0.8.26", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index e5f365b..88630d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.25", + "version": "0.8.26", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", From b97f23701fd4c4bae4af8c4beb605bfe161960bb Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:38:45 +0800 Subject: [PATCH 011/255] apple build stops before the archive in an SSH / non-GUI session (errSecInternalComponent) with how to run it in the logged-in session, --allow-ssh to skip; relative credential paths resolve from the project and xcodebuild gets an absolute ASC key path; tests, pitfall 101 --- CHANGELOG.md | 4 ++++ cli/apple/build.ts | 6 +++++- cli/data/flags.json | 2 ++ cli/lib/credentials.ts | 18 +++++++++++++----- cli/lib/xcode.ts | 8 ++++++++ skills/store-pitfalls/SKILL.md | 13 +++++++++++++ tests/build-sign.test.ts | 27 +++++++++++++++++++++++++++ 7 files changed, 72 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e980e05..85300fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **`apple build` stops at once over SSH** (codesign fails there with errSecInternalComponent) and names how to run it + in the logged-in session; relative `ASC_P8` / `PLAY_SERVICE_ACCOUNT` paths are read from the project folder and + xcodebuild gets an absolute key path. Pitfall 101. + - **Bypass mode (owner opt-in, default off).** `storebound settings bypass on` (or "y" in `storebound init`) lets the AI agent press the owner-only buttons too: MCP `owner_step` (submit, release, rollout, testers, replies, prices, ...; dry run first), owner-only `storebound api` operations with `--yes --confirm `, console plans' diff --git a/cli/apple/build.ts b/cli/apple/build.ts index dcb71dc..eccc64f 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -23,7 +23,7 @@ import { parseMobileprovision, toXmlPlist } from '../lib/plist'; import { nextBuildNumber } from '../lib/release'; import { exportOptions, profileDirs } from '../lib/signing'; import { capacitorProject, webStep } from '../lib/web-assets'; -import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, xcodePath, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode'; +import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; import { findBundleId } from './signing'; @@ -168,6 +168,10 @@ export async function run(ctx: Ctx): Promise { return; } if (process.platform !== 'darwin' || !rt.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)'); + // 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\`.`); const web = await webStep({ ...webOpts, dry: false }); for (const n of target.notes) log(TAG, n); const rsync = foreignRsync(process.env.PATH); diff --git a/cli/data/flags.json b/cli/data/flags.json index a93f66b..a24b5e2 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -62,6 +62,7 @@ ], "apple appinfo": [], "apple build": [ + "allow-ssh", "allow-stale-web", "build-number", "configuration", @@ -941,6 +942,7 @@ "all-pages", "allow-existing", "allow-price-changes", + "allow-ssh", "allow-stale-web", "alpha", "apk", diff --git a/cli/lib/credentials.ts b/cli/lib/credentials.ts index 3b36e9d..f027d57 100644 --- a/cli/lib/credentials.ts +++ b/cli/lib/credentials.ts @@ -189,7 +189,13 @@ function value(root: string, name: string): string | undefined { } /** A path or the inline content itself. */ -export const inline = (v: string, marker: RegExp): string => (marker.test(v) ? v : readFileSync(v.replace(/^~(?=\/)/, homedir()), 'utf8')); +export const inline = (v: string, marker: RegExp, root?: string): string => (marker.test(v) ? v : readFileSync(credPath(v, root), 'utf8')); + +/** A credential path: ~ expanded; a relative one is taken from the project folder, not the working directory. */ +export function credPath(v: string, root?: string): string { + const h = v.replace(/^~(?=[/\\])/, homedir()); + return isAbsolute(h) || !root ? h : resolve(root, h); +} export function parseServiceAccount(text: string): ServiceAccount { const j = JSON.parse(text) as Partial; @@ -201,7 +207,7 @@ export function parseServiceAccount(text: string): ServiceAccount { export function playServiceAccount(root: string): ServiceAccount { const v = value(root, 'PLAY_SERVICE_ACCOUNT'); if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key; `storebound accounts` lists the profiles'); - return parseServiceAccount(inline(v, /^\s*\{/)); + return parseServiceAccount(inline(v, /^\s*\{/, root)); } export function ascKey(root: string): AscKey { @@ -209,8 +215,8 @@ export function ascKey(root: string): AscKey { const issuerId = value(root, 'ASC_ISSUER_ID'); if (!keyId || !issuerId) throw new Error('ASC_KEY_ID and ASC_ISSUER_ID are not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json); `storebound accounts` lists the profiles'); const p8 = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${keyId}.p8`); - if (!/BEGIN PRIVATE KEY/.test(p8) && !existsSync(p8.replace(/^~(?=\/)/, homedir()))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`); - return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/) }; + if (!/BEGIN PRIVATE KEY/.test(p8) && !existsSync(credPath(p8, root))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`); + return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/, root) }; } /** The keys and nestings a review contact is read from (first hit): the documented one, then shapes seen in real files. */ @@ -306,7 +312,9 @@ export const maskSecret = (s: string): string => (s ? '********' : '(empty)'); export function ascKeyFile(root: string): { keyPath: string; keyId: string; issuerId: string; cleanup: () => void } { const k = ascKey(root); const raw = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${k.keyId}.p8`); - if (!/BEGIN PRIVATE KEY/.test(raw)) return { keyPath: raw.replace(/^~(?=\/)/, homedir()), keyId: k.keyId, issuerId: k.issuerId, cleanup: () => {} }; + // xcodebuild refuses a relative -authenticationKeyPath ("must be an absolute path"): resolve it from the project + const asPath = (p: string) => { const h = p.replace(/^~(?=[/\\])/, homedir()); return isAbsolute(h) ? h : resolve(root, h); }; + if (!/BEGIN PRIVATE KEY/.test(raw)) return { keyPath: asPath(raw), keyId: k.keyId, issuerId: k.issuerId, cleanup: () => {} }; const dir = mkdtempSync(join(tmpdir(), 'storebound-key-')); chmodSync(dir, 0o700); const keyPath = join(dir, `AuthKey_${k.keyId}.p8`); diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index 0969ac8..3ff1171 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -242,3 +242,11 @@ export function distributionErrors(output: string, tmp: string = process.env.TMP } return logs[0] ? { log: logs[0], lines: [] } : null; } + +/** Why this is not the Mac's logged-in GUI session (SSH, or a launchd manager other than Aqua), else null: codesign + * cannot reach the login keychain there (errSecInternalComponent). */ +export function remoteBuildSession(env: Record, managerName: string): string | null { + if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'this is an SSH session'; + if (managerName && managerName !== 'Aqua') return `this is not the logged-in GUI session (launchctl manager: ${managerName})`; + return null; +} diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 4de9ad2..d74c179 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1220,3 +1220,16 @@ that fails without the fix (see CONTRIBUTING.md). --from-text `. - **Guard:** `textToFile` in `cli/mcp.ts` sends multi-line text as `--from-text `; `clip` keeps the start of long JSON output (the end of plain text) and names a file with the whole output (`tests/mcp-text-file.test.ts`). + +## 101. `apple build` over SSH: errSecInternalComponent; a relative ASC_P8 path: "must be an absolute path" +- **Symptom:** `storebound apple build` run over SSH on a Mac failed late in the archive (Embed Pods Frameworks, + codesign errSecInternalComponent). With `ASC_P8` set to a project-relative path, xcodebuild stopped with + "-authenticationKeyPath must be an absolute path". +- **Cause:** codesign in an SSH session cannot use the login keychain. The key path was handed to xcodebuild as written + (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 + `cli/lib/credentials.ts` resolves relative credential paths from the project and `ascKeyFile` hands xcodebuild an + absolute path (`tests/build-sign.test.ts`). diff --git a/tests/build-sign.test.ts b/tests/build-sign.test.ts index 83f1f1d..843df9f 100644 --- a/tests/build-sign.test.ts +++ b/tests/build-sign.test.ts @@ -488,3 +488,30 @@ describe('MCP', () => { for (const t of TOOLS.filter(t => ['apple_signing_status', 'originality'].includes(t.name))) expect(t.write).toBe(false); }); }); + +describe('apple build outside the GUI session (pitfall 101)', () => { + test('SSH or a non-Aqua launchd manager is named; the logged-in session passes', async () => { + const { remoteBuildSession } = await import('../cli/lib/xcode'); + expect(remoteBuildSession({ SSH_CONNECTION: '10.0.0.2 5000 10.0.0.3 22' }, 'Aqua')).toBe('this is an SSH session'); + expect(remoteBuildSession({}, 'Background')).toContain('launchctl manager: Background'); + expect(remoteBuildSession({}, 'Aqua')).toBeNull(); + }); + test('a project-relative ASC_P8 path becomes absolute (xcodebuild refuses a relative -authenticationKeyPath)', async () => { + const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join, isAbsolute } = await import('node:path'); + const d = mkdtempSync(join(tmpdir(), 'sb-p8-')); + mkdirSync(join(d, '.storebound')); + const { generateKeyPairSync } = await import('node:crypto'); + writeFileSync(join(d, '.storebound/AuthKey_KEYID00002.p8'), generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()); + writeFileSync(join(d, '.storebound/credentials.json'), JSON.stringify({ ASC_KEY_ID: 'KEYID00002', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: '.storebound/AuthKey_KEYID00002.p8' })); + const saved = { ...process.env }; + try { + for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8']) delete process.env[k]; + const { ascKeyFile } = await import('../cli/lib/credentials'); + const path = ascKeyFile(d).keyPath; + expect(isAbsolute(path)).toBe(true); + expect(path).toBe(join(d, '.storebound/AuthKey_KEYID00002.p8')); + } finally { process.env = saved; } + }); +}); From 5bfbcefffa9c16a44db616af792f0b8c187f7315 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:50:12 +0800 Subject: [PATCH 012/255] release: a staged npm publish is reported with what happened on 0.8.25 / 0.8.26 (published on its own ~10 min later, nothing to approve): check dist-tags first --- tools/release.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/release.ts b/tools/release.ts index 0565d00..16e3ec7 100644 --- a/tools/release.ts +++ b/tools/release.ts @@ -466,7 +466,7 @@ async function npmRelease(version: string, token: string): Promise { const pub = npmRun(['publish'], token); // npm stages a token publish for the maintainer's approval on npmjs.com (409 "previously staged version" on a retry): // nothing to wait for here, the owner approves it once - if (/staged/i.test(pub.out)) { say(`npm: ${version} is staged and waits for the maintainer's approval: https://www.npmjs.com/package/storebound (approve it there; then deprecate runs with: bun tools/release.ts npm-publish)`); return; } + if (/staged/i.test(pub.out)) { say(`npm: ${version} is staged: npm published 0.8.25 and 0.8.26 on its own about 10 minutes later with nothing waiting at https://www.npmjs.com/settings//staged-packages; check "npm view storebound dist-tags.latest" before approving anything, then run bun tools/release.ts npm-publish for the deprecate`); return; } if (pub.code !== 0 && !/previously published|cannot publish over/i.test(pub.out)) fail(`npm publish failed: ${pub.out.trim().split('\n').slice(-4).join(' ')}`); // the registry lags a little after a publish; wait (timeout, heartbeat) until it serves the version as latest const end = Date.now() + 5 * 60_000; @@ -482,7 +482,7 @@ async function npmRelease(version: string, token: string): Promise { say(`npm: ${version} is not on the registry yet: publishing once more`); const p2 = npmRun(['publish'], token); // 0.8.25: the first publish was staged silently; the retry's 409 "previously staged" says so (no 5-minute wait) - if (/staged/i.test(p2.out)) { say(`npm: ${version} is staged and waits for the maintainer's approval: https://www.npmjs.com/package/storebound (approve it there; then deprecate runs with: bun tools/release.ts npm-publish)`); return; } + if (/staged/i.test(p2.out)) { say(`npm: ${version} is staged: npm published 0.8.25 and 0.8.26 on its own about 10 minutes later with nothing waiting at https://www.npmjs.com/settings//staged-packages; check "npm view storebound dist-tags.latest" before approving anything, then run bun tools/release.ts npm-publish for the deprecate`); return; } if (p2.code !== 0 && !/previously published|cannot publish over/i.test(p2.out)) fail(`npm publish (second try) failed: ${p2.out.trim().split('\n').slice(-4).join(' ')}`); } } From ee35b1134192b0c47159eb83a97be91e8478dca4 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 03:22:23 +0800 Subject: [PATCH 013/255] Direct store API traces: comment lines are skipped and each file names a tool once (a real project listed one script five times, mostly doc comments) --- cli/lib/fastlane-traces.ts | 6 ++++-- tests/fastlane-traces.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cli/lib/fastlane-traces.ts b/cli/lib/fastlane-traces.ts index ddc0668..fc847de 100644 --- a/cli/lib/fastlane-traces.ts +++ b/cli/lib/fastlane-traces.ts @@ -72,9 +72,11 @@ export function findFastlaneTraces(root: string): FastlaneTrace[] { lines(f).forEach((l, i) => { const m = CLI_CALL.exec(l); if (m) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: storeOf(m[1]!) }); - else if (!/package\.json$/.test(f) || /"[^"]*"\s*:\s*"/.test(l)) { + // comment lines name the tools without calling them; one line per file and tool is enough (a real project + // listed the same script five times, mostly from its doc comments) + else if ((!/package\.json$/.test(f) || /"[^"]*"\s*:\s*"/.test(l)) && !/^\s*(\/\/|\/?\*|#|--|REM\b)/i.test(l)) { const d = DIRECT.find(x => x.re.test(l)); - if (d) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: d.store, via: 'api', fix: d.fix }); + if (d && !out.some(o => o.file === rel(f) && o.fix === d.fix)) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: d.store, via: 'api', fix: d.fix }); } }); } diff --git a/tests/fastlane-traces.test.ts b/tests/fastlane-traces.test.ts index 5b7a4df..7d03491 100644 --- a/tests/fastlane-traces.test.ts +++ b/tests/fastlane-traces.test.ts @@ -56,3 +56,12 @@ describe('fastlane traces', () => { expect(f?.fix).toContain('storebound import fastlane'); }); }); + +describe('direct store API traces stay short', () => { + test('comment lines are skipped and each file names a tool once', () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-fl2-')); + mkdirSync(join(dir, 'tools')); + writeFileSync(join(dir, 'tools/ship.mjs'), "/**\n * 4. `xcrun altool --upload-package` uploads it\n */\n// altool needs the app id\nconst words = ['xcrun', 'altool', '--upload-package'];\nrun(['xcrun', 'altool', '--validate-app']);\n"); + expect(findFastlaneTraces(dir)).toEqual([expect.objectContaining({ file: 'tools/ship.mjs', line: 5, via: 'api' })]); + }); +}); From 256e8e33ad0716b4588c4d84fde01ff1abc9e6d6 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 03:28:46 +0800 Subject: [PATCH 014/255] storebound aab --json (offline packageName / versionCode / versionName / SDKs / sha256, no config) and play release --json result for CI scripts that replace their own Play API calls --- CHANGELOG.md | 4 ++++ cli/aab.ts | 18 ++++++++++++++++++ cli/commands.ts | 1 + cli/data/flags.json | 4 ++++ cli/lib/desktop-coverage.ts | 1 + cli/play/release.ts | 8 +++++++- docs/desktop-coverage.md | 3 ++- tests/build-sign.test.ts | 9 +++++++++ tests/desktop-coverage.test.ts | 2 +- 9 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 cli/aab.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce895b9..7e71c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **CI-friendly Play output.** `storebound aab --json` reads packageName, versionCode, versionName, SDKs and + sha256 offline (no config, no upload); `play release ... --json` prints {versionCode, sha256, name, tracks[{track, + status, userFraction}]}. `play status --json` already lists each track's releases. + - **`apple build` stops at once over SSH** (codesign fails there with errSecInternalComponent) and names how to run it in the logged-in session; relative `ASC_P8` / `PLAY_SERVICE_ACCOUNT` paths are read from the project folder and xcodebuild gets an absolute key path. Pitfall 101. diff --git a/cli/aab.ts b/cli/aab.ts new file mode 100644 index 0000000..c4ef0eb --- /dev/null +++ b/cli/aab.ts @@ -0,0 +1,18 @@ +// Offline: what an Android App Bundle says about itself, without a config or credentials (CI reads the version code +// before it uploads, instead of unzipping the AAB or asking the Play API). +// storebound aab [--json] +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { Args } from './lib/args'; +import { aabInfo } from './lib/gradle'; +import { log, printJson } from './lib/log'; + +export async function run(args: Args): Promise { + const file = args._.find(x => x.endsWith('.aab')); + if (!file) throw new Error('usage: storebound aab [--json]'); + const data = readFileSync(resolve(file)); + const info = { ...aabInfo(data), sha256: createHash('sha256').update(data).digest('hex'), bytes: data.length }; + if (args.has('json')) { printJson(info); return; } + log('aab', `${info.packageName} ${info.versionName ?? '?'} (${info.versionCode}) · minSdk ${info.minSdk ?? '?'} · targetSdk ${info.targetSdk ?? '?'} · ${(info.bytes / 1e6).toFixed(1)} MB`); +} diff --git a/cli/commands.ts b/cli/commands.ts index 789219c..5b0f6ae 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -16,6 +16,7 @@ export const PLAIN: Record Promise, string]> = { 'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'], 'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use | default (the machine default; --clear)'], 'settings': [() => import('./settings'), 'per-machine settings: bypass on | off | status (owner opt-in: the agent also presses the owner-only buttons; default off)'], + 'aab': [() => import('./aab'), 'offline: an AAB file: packageName, versionCode, versionName, min / target SDK, sha256 (no config, no upload); --json'], 'doctor': [() => import('./doctor'), 'build and capture dependencies (JDK with the version the project needs, Android SDK, Xcode, CocoaPods, Node, Bun, ffmpeg, Chrome, adb, git, bundletool) and how to install what is missing; --install --yes; --json'], 'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'], 'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'], diff --git a/cli/data/flags.json b/cli/data/flags.json index a24b5e2..e27b0da 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -11,6 +11,7 @@ "yes" ], "commands": { + "aab": [], "accounts": [ "clear", "dir", @@ -1178,6 +1179,9 @@ ] }, "usage": { + "aab": [ + "storebound aab [--json]" + ], "accounts": [ "storebound accounts [list] [--json] every profile: Apple key, Play account (masked), which", "storebound accounts import [--dir ] [--name ] [--dry] [--yes]", diff --git a/cli/lib/desktop-coverage.ts b/cli/lib/desktop-coverage.ts index e127b05..8628439 100644 --- a/cli/lib/desktop-coverage.ts +++ b/cli/lib/desktop-coverage.ts @@ -46,6 +46,7 @@ export const DESKTOP_COVERAGE: Record = { 'import': { where: 'App folders / first-run guide: Import from fastlane', methods: ['host:project_import_fastlane'] }, 'install': { where: `${T}: Install for an AI tool`, methods: ['agent.install'] }, 'mcp': { not: 'the stdio server for AI tools; the window itself is the GUI over the same commands' }, + 'aab': { not: 'a CI helper that reads one AAB file; the window shows the builds each store has' }, 'settings': { not: 'per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway' }, 'serve': { not: 'the JSON-RPC sidecar the window runs on; nothing to click' }, 'apps': { where: 'Accounts (store apps per account, key check) and App folders (list, add, remove)', methods: ['host:store_apps_refresh', 'host:account_check', 'host:projects_list', 'host:project_add', 'host:project_remove'] }, diff --git a/cli/play/release.ts b/cli/play/release.ts index 808214a..24c3d8f 100644 --- a/cli/play/release.ts +++ b/cli/play/release.ts @@ -8,9 +8,10 @@ // Draft app (never published): Play only accepts status "draft" on any track; the first production release is sent // from the Console (see the play-first-release skill). import { readFileSync, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; -import { done, log, warn } from '../lib/log'; +import { done, log, warn, printJson } from '../lib/log'; import { aabInfo } from '../lib/gradle'; import { playTargetSdkProblem, targetSdkFile } from '../lib/store-minimums'; import { playClient } from './client'; @@ -55,6 +56,7 @@ export async function run(ctx: Ctx): Promise { if (sdkWhy) throw new Error(`${aab}: not released, ${sdkWhy}`); } const play = await playClient(root, pkg); + let released = fixed ?? ''; try { await play.edit(async (id, ctl) => { let code = fixed ?? ''; @@ -74,6 +76,7 @@ export async function run(ctx: Ctx): Promise { log('play', `uploaded versionCode ${code}`); } } + released = code; let changed = 0; for (const t of tracks) { const release: PlayTrackRelease = { versionCodes: [code], status: t.status }; @@ -100,5 +103,8 @@ export async function run(ctx: Ctx): Promise { if (config.play?.managedPublishing !== true) warn('play', `managed publishing is ${config.play?.managedPublishing === false ? 'off' : 'off by default (play.managedPublishing not set)'}: the release goes live automatically once Google approves it`); if (prod.status === 'draft') log('play', 'next: `storebound console plan play-production-countries` (the API cannot set countries), then `storebound console plan play-production-release` (its final "Send changes for review" is the owner\'s)'); } + // machine-readable result for CI scripts (a Jenkins stage reads it instead of calling the Play API itself) + if (a.has('json')) printJson({ versionCode: released ? Number(released) : null, sha256: aab ? createHash('sha256').update(readFileSync(aab)).digest('hex') : null, name: a.get('name') ?? null, + tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry') }); done('play', 'release done'); } diff --git a/docs/desktop-coverage.md b/docs/desktop-coverage.md index 73c9dde..ae4a1af 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). -**96 commands:** 91 covered, 0 gaps, 5 not for the desktop. +**97 commands:** 91 covered, 0 gaps, 6 not for the desktop. | command | status | where in the window | methods | owner step | |---|---|---|---|---| @@ -18,6 +18,7 @@ Owner-only parts are owner actions in the window or owner steps with their termi | `desktop` | not-for-desktop | opens or installs this window from a terminal | | | | `accounts` | covered | Accounts screen (profiles shared with the CLI) | `host:accounts_list`, `host:account_add_play`, `host:account_add_apple`, `host:account_update` | | | `settings` | not-for-desktop | per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway | | | +| `aab` | not-for-desktop | a CI helper that reads one AAB file; the window shows the builds each store has | | | | `doctor` | covered | Dependencies screen | `system.tools`, `host:tools_info` | | | `scan` | covered | Checks tab: Scan, Fix | `scan`, `scan.write` | | | `api` | not-for-desktop | raw API calls by operation id: every task has its own screen, and raw writes stay a terminal step the owner types | | | diff --git a/tests/build-sign.test.ts b/tests/build-sign.test.ts index 843df9f..a397a99 100644 --- a/tests/build-sign.test.ts +++ b/tests/build-sign.test.ts @@ -515,3 +515,12 @@ describe('apple build outside the GUI session (pitfall 101)', () => { } finally { process.env = saved; } }); }); + +describe('storebound aab (CI reads the version code without uploading)', () => { + test('prints packageName, versionCode, versionName as JSON', () => { + const d = dir('inspect-aab'); + writeFileSync(join(d, 'app.aab'), makeAab('com.example.game', 4321, '3.1.0')); + const r = Bun.spawnSync(['bun', join(import.meta.dir, '..', 'cli', 'index.ts'), 'aab', 'app.aab', '--json'], { cwd: d, stdout: 'pipe', stderr: 'pipe' }); + expect(JSON.parse(r.stdout.toString())).toMatchObject({ packageName: 'com.example.game', versionCode: 4321, versionName: '3.1.0' }); + }); +}); diff --git a/tests/desktop-coverage.test.ts b/tests/desktop-coverage.test.ts index cef5fcf..c12236b 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']; +const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings', 'aab']; describe('desktop coverage', () => { const { rows, extra } = analyze(); From 600b6893e5c7d92f77ce96753135cd0974fd945a Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 03:33:30 +0800 Subject: [PATCH 015/255] play listing uploads the hi-res icon (and the feature graphic) for a new Play language in the same edit (403 no high resolution icon set; play images skipped it), pitfall 102; script traces skip test files --- CHANGELOG.md | 4 ++++ cli/lib/fastlane-traces.ts | 3 ++- cli/play/images.ts | 12 ++++++++++++ cli/play/listing.ts | 10 ++++++++++ skills/store-pitfalls/SKILL.md | 10 ++++++++++ tests/fastlane-traces.test.ts | 9 +++++++++ 6 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e71c73..24629af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **A new Play language in one run.** `play listing` uploads the hi-res icon (and the feature graphic when rendered) for a + language Play does not have yet, in the same edit (Play refused the commit with 403 otherwise). Pitfall 102. +- Script traces skip test files (`*.test.*`, `*.spec.*`). + - **CI-friendly Play output.** `storebound aab --json` reads packageName, versionCode, versionName, SDKs and sha256 offline (no config, no upload); `play release ... --json` prints {versionCode, sha256, name, tracks[{track, status, userFraction}]}. `play status --json` already lists each track's releases. diff --git a/cli/lib/fastlane-traces.ts b/cli/lib/fastlane-traces.ts index fc847de..2a80a8f 100644 --- a/cli/lib/fastlane-traces.ts +++ b/cli/lib/fastlane-traces.ts @@ -68,7 +68,8 @@ export function findFastlaneTraces(root: string): FastlaneTrace[] { try { for (const n of readdirSync(dir)) if (SCRIPT_EXT.test(n)) files.push(join(dir, n)); } catch { /* none */ } } try { for (const n of readdirSync(root)) if (/\.(sh|bash|ps1)$/i.test(n)) files.push(join(root, n)); } catch { /* unreadable */ } - for (const f of new Set(files)) { + // test files quote the tools in their assertions; they upload nothing + for (const f of [...new Set(files)].filter(x => !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(x))) { lines(f).forEach((l, i) => { const m = CLI_CALL.exec(l); if (m) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: storeOf(m[1]!) }); diff --git a/cli/play/images.ts b/cli/play/images.ts index 8ee5940..de181b2 100644 --- a/cli/play/images.ts +++ b/cli/play/images.ts @@ -72,6 +72,18 @@ export function missingChange(type: string, remote: number, prune: boolean): Ima /** What one language uploads: the sets with files, the optional types without, and errors (missing required sets). */ export interface LangImages { sets: [string, string[]][]; missing: string[]; errors: string[] } +/** The graphics a NEW Play language needs before its edit can commit (403 "no high resolution icon set for language"): + * the hi-res icon and, when rendered, the feature graphic. `play listing` uploads them in the same edit as the text, + * so listing and images never wait for each other (pitfall 102). */ +export function firstGraphics(ctx: Ctx, code: string): [string, string][] { + const { config, resolve } = ctx.loaded; + const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png')); + const l = byPlayCode(config.languages).get(code); + const src = resolve(config.renders ?? config.shots?.out ?? 'store/renders'); + const li = langImages(code, l ? join(src, l.id) : join(src, '__none__'), icon, false); + return li.sets.filter(([t]) => t === 'icon' || t === 'featureGraphic').map(([t, files]) => [t, files[0]!] as [string, string]); +} + /** The image sets of one language folder (`tablets` = resolved play.tablets). Reads the folder only. */ export function langImages(code: string, dir: string, icon: string, tablets: boolean): LangImages { const out: LangImages = { sets: [], missing: [], errors: [] }; diff --git a/cli/play/listing.ts b/cli/play/listing.ts index 2d86894..fc0ded4 100644 --- a/cli/play/listing.ts +++ b/cli/play/listing.ts @@ -20,6 +20,7 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, step, warn } from '../lib/log'; import { playClient } from './client'; +import { firstGraphics } from './images'; import { stdinIsTTY } from '../lib/tty'; /** Play release notes that name iOS, iPhone, App Store, ... are refused before any edit, dry runs included (pitfall 77). */ @@ -167,6 +168,15 @@ export async function run(ctx: Ctx): Promise { for (const [i, b] of todo.entries()) { await play.call('PUT', `${play.API}/edits/${id}/listings/${b.language}`, b); log('play', step(i + 1, todo.length, b.language!)); + // a language Play does not have yet: its edit only commits with a hi-res icon (pitfall 102) + if (!now.some(n => n.language === b.language)) { + const graphics = firstGraphics(ctx, b.language!); + if (!graphics.some(([t]) => t === 'icon')) throw new Error(`${b.language}: a new Play language needs the 512x512 icon in the same edit (play.icon or store/icons/play-512.png): storebound icons makes it`); + for (const [type, f] of graphics) { + await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); + log('play', `${b.language}: ${type} uploaded with the new listing`); + } + } } }, { notSentForReview: ctx.args.has('not-sent-for-review'), summary: 'listing text' }); done('play', sent ? 'listing done' : 'listing already up to date (the edit was deleted, nothing committed)'); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index d74c179..ec8c872 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1233,3 +1233,13 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive; `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`). + +## 102. A new Play language: `play listing` 403 "no high resolution icon set for language", `play images` skips it +- **Symptom:** adding a language (tr-TR) to an existing app, `play listing --yes` failed at the edit commit with 403 + "This app has no high resolution icon set for language tr-TR", while `play images` said "skipped tr-TR: no Play + listing yet": each waited for the other. +- **Cause:** Play commits a new language only when its hi-res icon is in the same edit. +- **Do:** update storebound; `play listing` now uploads the icon (and the feature graphic when rendered) for a new + language in the same edit. Then run `play images` for the screenshots. +- **Guard:** `firstGraphics` in `cli/play/images.ts`, used by `cli/play/listing.ts` for languages Play does not have yet; + it stops with the fix when the 512x512 icon is missing. diff --git a/tests/fastlane-traces.test.ts b/tests/fastlane-traces.test.ts index 7d03491..1879ed5 100644 --- a/tests/fastlane-traces.test.ts +++ b/tests/fastlane-traces.test.ts @@ -65,3 +65,12 @@ describe('direct store API traces stay short', () => { expect(findFastlaneTraces(dir)).toEqual([expect.objectContaining({ file: 'tools/ship.mjs', line: 5, via: 'api' })]); }); }); + +describe('test files are not traces', () => { + test('a test that quotes altool is skipped', () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-fl3-')); + mkdirSync(join(dir, 'tools')); + writeFileSync(join(dir, 'tools/ship.test.mjs'), "test('calls altool', () => { run(['xcrun', 'altool']); });\n"); + expect(findFastlaneTraces(dir)).toEqual([]); + }); +}); From aae3a6c62d10c37886b2832f4932e6dbd20f816a Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 03:45:25 +0800 Subject: [PATCH 016/255] doctor: at a terminal the used-for column is cut to the window width so rows never wrap; piped output stays whole --- cli/doctor.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cli/doctor.ts b/cli/doctor.ts index 7b2de04..612d04f 100644 --- a/cli/doctor.ts +++ b/cli/doctor.ts @@ -82,7 +82,15 @@ export async function run(args: Args): Promise { } if (args.has('json')) { printJson(r); return; } log('doctor', `storebound ${r.storebound} · ${r.runtime} · ${r.platform}${r.project ? ' · project config found' : ''}`); - console.log(textTable(['tool', 'status', 'version', 'needs', 'used for'], r.tools.map(t => [t.name, t.status === 'outdated' ? 'too old' : t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.required ?? '', t.usedFor]))); + const rows = r.tools.map(t => [t.name, t.status === 'outdated' ? 'too old' : t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', (t.version ?? '').slice(0, 16), t.required ?? '', t.usedFor]); + // at a terminal the last column is cut to the window width, so rows never wrap (piped output stays whole) + const cols = process.stdout.isTTY ? process.stdout.columns : 0; + if (cols) { + const w = (i: number) => Math.max(...[['tool', 'status', 'version', 'needs'][i]!, ...rows.map(x => x[i]!)].map(s => s.length)); + const room = cols - [0, 1, 2, 3].reduce((n, i) => n + w(i) + 2, 0) - 1; + for (const x of rows) if (x[4]!.length > room) x[4] = room > 8 ? `${x[4]!.slice(0, room - 1)}…` : ''; + } + console.log(textTable(['tool', 'status', 'version', 'needs', 'used for'], rows)); log('doctor', r.androidSdk.path ? `Android SDK: ${r.androidSdk.path} (from ${r.androidSdk.source})` : `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`); const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key); From 9f40a9ea1458c847775cde185806d3e9326a1b90 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 05:24:58 +0800 Subject: [PATCH 017/255] Console plan apple-app-privacy: Edit opens Data Collection first on a published app (type list ends in Continue), each type's last step is Publish (live, no draft); Publish is the owner's unless bypass mode is on (verified 2026-10-22) --- CHANGELOG.md | 4 ++++ cli/lib/console-plan.ts | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24629af..a7816ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **App Privacy console plan follows today's page** (verified 2026-10-22): on an app published before, Edit opens the + Data Collection question first and the type list ends in Continue; each type's last step is Publish (it goes live, + no draft). Publish stays the owner's unless bypass mode is on. + - **A new Play language in one run.** `play listing` uploads the hi-res icon (and the feature graphic when rendered) for a language Play does not have yet, in the same edit (Play refused the commit with 403 otherwise). Pitfall 102. - Script traces skip test files (`*.test.*`, `*.spec.*`). diff --git a/cli/lib/console-plan.ts b/cli/lib/console-plan.ts index f333e0d..7eeac67 100644 --- a/cli/lib/console-plan.ts +++ b/cli/lib/console-plan.ts @@ -273,7 +273,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined) const add = (s: Omit) => steps.push({ n: steps.length + 1, ...s }); add({ text: appId ? 'Open the App Privacy page of the app (deep link).' : `Open App Store Connect > Apps > the app > App Privacy (sidebar): ${consoleUrl('asc.apps')}` }); add({ text: 'First time: click "Get Started" and answer the Data Collection question; then Next.', click: ['Get Started'], answers: ['collects'] }); - add({ text: 'Data types: when the Data Types section does not list exactly the collected types below, click the "Edit" next to the "Data Types" heading (never the Privacy Policy Edit), tick exactly these types and untick the rest, then Save (a draft: nothing is public before Publish). Console labels: Purchase History shows as "Purchases", Other Data Types as "Other Data".', click: ['Edit', 'Save'], answers: ids(answers, a => a.id.startsWith('type:')) }); + add({ text: 'Data types: when the Data Types section does not list exactly the collected types below, click the "Edit" next to the "Data Types" heading (never the Privacy Policy Edit), tick exactly these types and untick the rest, then Save. On an app whose answers were published before (verified 2026-10-22), Edit first opens the "Data Collection" question (Yes / No, then Next); the type list is step 2 and ends in "Continue", not "Save". Console labels: Purchase History shows as "Purchases", Other Data Types as "Other Data".', click: ['Edit', 'Next', 'Save', 'Continue'], answers: ids(answers, a => a.id.startsWith('type:')) }); add({ text: `${APPLE_ADDITIONAL_SETUP}`, click: ['OK'] }); for (const t of collected) { const set = setUpTexts(t).filter(x => !/ ayarla$/.test(x)); @@ -281,9 +281,9 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined) add({ type: t, text: `"${t}" step 1, purposes: tick and untick the 6 checkboxes as answered, then Next.`, click: ['Next'], answers: ids(answers, a => a.id.startsWith(`purpose:${t}:`)) }); add({ type: t, text: `"${t}" step 2, linked to the user: pick the radio, then Next.`, click: ['Next'], answers: [`linked:${t}`] }); add({ type: t, text: `"${t}" steps 3 and 4: two information pages ("Let’s define two important terms…", then "To help put tracking into context, here are a few examples:"), no inputs: press Next on each.`, click: ['Next', 'Next'] }); - add({ type: t, text: `"${t}" step 5, tracking: pick the radio, then Save (a draft). Never "Definitions and Examples". The dialog closes; the row shows Edit and the summary.`, click: ['Save'], answers: [`tracking:${t}`] }); + add({ type: t, text: `"${t}" step 5, tracking: pick the radio, then the last button: "Save" (first setup: a draft) or "Publish" (answers published before: this type goes live at once, verified 2026-10-22). Publish is the owner's button unless bypass mode is on (storebound settings bypass status). Never "Definitions and Examples". The dialog closes; the row shows Edit and the summary.`, click: ['Save'], answers: [`tracking:${t}`] }); } - add({ text: 'Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish, then Publish again in the "Publish Your App Privacy Responses?" dialog (it puts the answers on the App Store product page). Afterwards the page shows "Published … ago by …".' }); + add({ text: 'First setup only: the page then has a Publish button (top right) and the dialog "Publish Your App Privacy Responses?". That Publish is the owner’s unless bypass mode is on; then press it yourself. Afterwards the page shows "Published … ago by …". (Answers published before: each type was already published by its own last step.)' }); add({ text: 'Verify: read the Data Types section as text (document.querySelector("main").innerText) and pipe it to `storebound console verify apple-app-privacy --from-text -`.' }); return { start: appId ? { url: `${ASC}/apps/${appId}/distribution/privacy`, deepLink: true, reach: [] } @@ -297,7 +297,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined) 'Step 2, linked: 2 radios, "Yes, data collected from this app is linked to the user’s identity" / "No, …" (curly ’). Buttons: Back, Cancel, Next.', 'Step 3: an information page "Let’s define two important terms…" (Tracking / Third-Party Data). No inputs: press Next.', 'Step 4: an information page "To help put tracking into context, here are a few examples:". No inputs: press Next. (Two information steps in a row: keep pressing Next while the dialog shows no inputs.)', - 'Step 5, tracking: 2 radios, "Yes, we use data for tracking purposes" / "No, we do not …". Buttons: "Definitions and Examples" (help: never press), Back, Cancel, Save.', + 'Step 5, tracking: 2 radios, "Yes, we use data for tracking purposes" / "No, we do not …". Buttons: "Definitions and Examples" (help: never press), Back, Cancel, and Save (first setup) or Publish (answers published before: no draft, the type goes live; verified 2026-10-22).', 'Save closes the dialog. The type then shows "Edit" and a summary: "Used for ", "Linked to the user’s identity" (or not), "Used for tracking purposes" (or not). That summary is what `console verify` reads.', 'Labels use typographic quotes (’): match straight and curly quotes alike.', 'The "?" buttons ("More information") are help only. Publish (top right) is the owner\'s: it opens a "Publish Your App Privacy Responses?" with Cancel / Publish (the owner\'s final button); after it the page shows "Published a few seconds ago by …" (verified 2026-10-12), which `console verify` reads as the published signal.', From 9a907d87e59ce4c4d5406ba7fd02a58a4f94de60 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 05:59:41 +0800 Subject: [PATCH 018/255] Desktop: tray icon, start at logon hidden (autostart --hidden, window state never restores visible), closing hides to the tray, background signed auto-update at start and every 30 min with restart --- CHANGELOG.md | 4 + apps/desktop/src-tauri/Cargo.lock | 113 +++++++++++++++++++++++-- apps/desktop/src-tauri/Cargo.toml | 3 +- apps/desktop/src-tauri/src/lib.rs | 82 +++++++++++++++++- apps/desktop/src-tauri/tauri.conf.json | 3 +- docs/desktop.md | 8 +- 6 files changed, 200 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7816ee..4687765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **Desktop: tray app that starts at logon and updates itself.** First run registers a logon entry (`--hidden`): the app + starts silently in the tray; closing the window keeps it there, the tray menu quits. A signed update is checked a + minute after start and every 30 minutes, installed silently, and the app restarts as it was. + - **App Privacy console plan follows today's page** (verified 2026-10-22): on an app published before, Edit opens the Data Collection question first and the type list ends in Continue; each type's last step is Publish (it goes live, no draft). Publish stays the owner's unless bypass mode is on. diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 957e60a..4b7b3f9 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -227,6 +227,20 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf" +dependencies = [ + "dirs 6.0.0", + "os_info", + "smappservice-rs", + "thiserror 2.0.21", + "windows-registry", + "windows-result", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -855,6 +869,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "7.0.0" @@ -2446,6 +2469,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "notify-rust" version = "4.18.2" @@ -2724,6 +2759,30 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -2818,6 +2877,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix 0.31.3", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3926,6 +4000,18 @@ version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +[[package]] +name = "smappservice-rs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-service-management", + "thiserror 2.0.21", +] + [[package]] name = "socket2" version = "0.6.5" @@ -4025,6 +4111,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-notification", @@ -4250,7 +4337,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 7.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4300,7 +4387,7 @@ checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 7.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4370,6 +4457,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.6.1" @@ -4532,7 +4633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 7.0.0", "flate2", "futures-util", "http", @@ -5018,7 +5119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 7.0.0", "libappindicator", "muda", "objc2", @@ -5841,7 +5942,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 7.0.0", "dom_query", "dpi", "dunce", @@ -5955,7 +6056,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.8", "serde", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index bb0cad6..6ef6990 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ path = "src/main.rs" tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2.12", features = [] } +tauri = { version = "2.12", features = ["tray-icon"] } # Spawns the storebound sidecar from Rust only; no capability grants the plugin's JS API. tauri-plugin-shell = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } @@ -31,6 +31,7 @@ tauri-plugin-dialog = "2" tauri-plugin-notification = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" +tauri-plugin-autostart = "2" # Store credentials: macOS Keychain, Windows Credential Manager, Secret Service on Linux. keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] } serde = { version = "1", features = ["derive"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c16ea24..dd9272d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -21,7 +21,10 @@ include!("commands_list.rs"); use std::sync::Mutex; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent}; +use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; +use tauri_plugin_updater::UpdaterExt; use tauri_plugin_deep_link::DeepLinkExt; /// WebKitGTK's DMABUF renderer leaves the window blank on some Linux GPU / driver / VM combinations. Turn it off unless @@ -34,6 +37,60 @@ pub fn linux_webkit_env() { } } +/// Started by the logon entry (`--hidden`): the app runs in the tray and shows no window. +fn started_hidden() -> bool { + std::env::args().any(|a| a == "--hidden") +} + +/// Tray icon: a click shows the window; the menu has Open and Quit (the only real quit on Windows and Linux, where +/// closing the window keeps the app running in the tray). +fn tray(app: &tauri::App) -> tauri::Result<()> { + let open = MenuItem::with_id(app, "tray-open", "Open storebound", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "tray-quit", "Quit storebound", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&open, &PredefinedMenuItem::separator(app)?, &quit])?; + let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false); + if let Some(icon) = app.default_window_icon() { + b = b.icon(icon.clone()); + } + b.on_menu_event(|app, e| match e.id().as_ref() { + "tray-open" => show_main(app), + "tray-quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|t, e| { + if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = e { + show_main(t.app_handle()); + } + }) + .build(app)?; + Ok(()) +} + +/// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is +/// downloaded, installed and the app restarts with the same arguments (`--hidden` stays hidden). Errors are logged. +fn auto_update(app: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + loop { + match app.updater() { + Ok(u) => match u.check().await { + Ok(Some(update)) => { + logs::host("info", &format!("auto-update: {} available, installing", update.version)); + match update.download_and_install(|_, _| {}, || {}).await { + Ok(()) => app.restart(), + Err(e) => logs::host("warn", &format!("auto-update: install failed: {e}")), + } + } + Ok(None) => {} + Err(e) => logs::host("warn", &format!("auto-update: check failed: {e}")), + }, + Err(e) => logs::host("warn", &format!("auto-update: no updater: {e}")), + } + tokio::time::sleep(std::time::Duration::from_secs(30 * 60)).await; + } + }); +} + fn show_main(app: &AppHandle) { if let Some(w) = app.get_webview_window(policy::MAIN) { let _ = w.unminimize(); @@ -135,7 +192,9 @@ pub fn run() { // single-instance first: a second start (or a storebound:// link) focuses the running app instead .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app))) .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_window_state::Builder::new().build()) + // 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_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"]))) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -178,7 +237,20 @@ pub fn run() { if let Err(e) = commands::sync_machine(&state) { logs::host("warn", &format!("accounts / app folders not synced at start: {}", e.message)); } + // start at logon, in the tray: set once (first run); the owner can turn it off in the OS afterwards + let marker = state.data_dir.join("autostart-set"); + if !marker.exists() { + match app.autolaunch().enable() { + Ok(()) => { let _ = std::fs::write(&marker, "1"); logs::host("info", "autostart at logon enabled (--hidden, tray)"); } + Err(e) => logs::host("warn", &format!("autostart not enabled: {e}")), + } + } app.manage(state); + tray(app)?; + if !started_hidden() { + show_main(app.handle()); + } + auto_update(app.handle().clone()); #[cfg(target_os = "macos")] app.set_menu(menu(app.handle())?)?; #[cfg(not(target_os = "macos"))] @@ -202,9 +274,9 @@ pub fn run() { Ok(()) }) .on_window_event(|w, e| { - // macOS: the close button hides the window (the app stays in the Dock and the Window menu); - // Cmd+Q and the app menu's Quit still quit. Elsewhere closing the window quits. - if cfg!(target_os = "macos") && w.label() == policy::MAIN { + // the close button hides the window: the app keeps running in the tray (macOS: also the Dock); the tray's + // Quit (macOS: Cmd+Q) really quits + if w.label() == policy::MAIN { if let WindowEvent::CloseRequested { api, .. } = e { api.prevent_close(); let _ = w.hide(); @@ -267,6 +339,8 @@ pub fn run() { app.run(|app, e| match e { #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main(app), + // no window left is not a reason to quit (tray app); an explicit exit or the updater's restart carries a code + RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(), RunEvent::Exit => { if let Some(s) = app.try_state::() { s.sidecar.shutdown_now(); diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 614a48e..51f3128 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -20,7 +20,8 @@ "minWidth": 880, "minHeight": 560, "resizable": true, - "center": true + "center": true, + "visible": false } ], "security": { diff --git a/docs/desktop.md b/docs/desktop.md index 795c2c3..9de0347 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -482,8 +482,14 @@ The CLI and the app are one product: one version number, one set of store accoun - Releases: [desktop-release.md](desktop-release.md) (version authority, privacy gate, macOS signing and notarization, Forgejo builds for Linux and Windows, storebound.dev hosting, publish). - **Updates**: Settings > Updates checks storebound.dev (stable `/desktop/latest.json` or beta - `/desktop/beta/latest.json`) only when asked; Install shows a native dialog, then the updater downloads the bundle, + `/desktop/beta/latest.json`) when asked; Install shows a native dialog, then the updater downloads the bundle, checks its minisign signature against `plugins.updater.pubkey` and restarts (`update_check`, `update_install`). + In the background the app also checks the stable channel a minute after start and every 30 minutes, installs a + signed update silently and restarts with the same arguments (`auto_update` in `src-tauri/src/lib.rs`). +- **Tray and start at logon**: the first run registers a logon entry (`tauri-plugin-autostart`, `--hidden`): the app + starts silently in the tray, without a window. Closing the window keeps it running in the tray; the tray icon + opens it, the tray menu's Quit (macOS: Cmd+Q) quits. Turn the logon entry off in the OS (Windows: Settings > Apps > + Startup). Window state never restores "visible", so a `--hidden` start stays hidden. - **`storebound://open?path=`** (sent by `storebound desktop` in an app folder, which first puts the folder on the machine's list): the host accepts one plain absolute path (`policy::deep_link_open_path`), syncs the list, selects that app folder when it is on it, otherwise asks in a native dialog and adds it From 17535d4b7a023fe2526ca1c7100269ce4f4f1d39 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 05:05:48 +0700 Subject: [PATCH 019/255] storebound 0.8.27: version in package.json and every copy, CHANGELOG 0.8.27 section --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3c7a497..6c2381f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.26", + "version": "0.8.27", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1a38eff..c750978 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.26", + "version": "0.8.27", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 861bf1c..c444524 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.26", + "version": "0.8.27", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4687765..1982ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.27 · 2026-10-21 + - **Desktop: tray app that starts at logon and updates itself.** First run registers a logon entry (`--hidden`): the app starts silently in the tray; closing the window keeps it there, the tray menu quits. A signed update is checked a minute after start and every 30 minutes, installed silently, and the app restarts as it was. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1b939ad..4c959fa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.26", + "version": "0.8.27", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 4b7b3f9..f528243 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4096,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.26" +version = "0.8.27" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 6ef6990..1a55690 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.26" +version = "0.8.27" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 51f3128..6cba48d 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.26", + "version": "0.8.27", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", diff --git a/gemini-extension.json b/gemini-extension.json index 41bb88e..a727731 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.26", + "version": "0.8.27", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index 88630d9..220d0c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.26", + "version": "0.8.27", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", From cd0110527ce1a39214c83c30110ff33839e4a24c Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 06:22:08 +0800 Subject: [PATCH 020/255] apps relink: after a machine move, missing app folders are pointed at the same app here (last path parts, else the config's app name), ids kept; apps folders hints it; apps add names an app after its config app name --- CHANGELOG.md | 4 +++ cli/apps.ts | 23 +++++++++++-- cli/data/flags.json | 14 +++++++- cli/lib/registry.ts | 65 +++++++++++++++++++++++++++++++++-- tests/registry-relink.test.ts | 33 ++++++++++++++++++ 5 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 tests/registry-relink.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4687765..425884f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **`storebound apps relink`** after a machine move: every "folder not found" entry of the app folder list is pointed at + the same app on this machine (by its path or its app name), ids kept so the desktop keeps its accounts and settings; + `apps folders` says so when entries are missing. `apps add` names an app after its config's app name. + - **Desktop: tray app that starts at logon and updates itself.** First run registers a logon entry (`--hidden`): the app starts silently in the tray; closing the window keeps it there, the tray menu quits. A signed update is checked a minute after start and every 30 minutes, installed silently, and the app restarts as it was. diff --git a/cli/apps.ts b/cli/apps.ts index 1200151..e28de41 100644 --- a/cli/apps.ts +++ b/cli/apps.ts @@ -9,6 +9,7 @@ // the same list as the desktop app's App folders) // storebound apps add [] | apps remove add / remove a folder in that list (the folder itself stays) // storebound apps add --store apple|play --app-id [--account ] [--name ] [--bundle-id ] +// storebound apps relink [--root ]... [--dry] after a move: "folder not found" entries -> the same app here // a store app with no project on this machine: the // desktop shows it read-only ("no local project"); // apps remove apple: | takes it out @@ -17,7 +18,7 @@ import { ascKey, playServiceAccount } from './lib/credentials'; import type { AccountCheck, AppsListResult } from './lib/desktop-protocol'; import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth'; import { errorBody } from './lib/http'; -import { log, printJson, warn } from './lib/log'; +import { done, log, printJson, warn } from './lib/log'; import { appleCheck, type Bytes, dataUrl, type Get, isServiceDisabled, listAppleApps, listPlayApps, localPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps'; import { textTable } from './lib/table'; import { ascClient } from './apple/asc'; @@ -26,7 +27,8 @@ import * as rt from './lib/rt'; import { existsSync, readFileSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; import { CONFIG_NAMES } from './lib/config'; -import { addFolder, addStoreApp, readRegistry, registryFile, removeFolder, removeStoreApp } from './lib/registry'; +import { addFolder, addStoreApp, applyRelink, configAppName, configFolders, readRegistry, registryFile, relinkPlan, removeFolder, removeStoreApp } from './lib/registry'; +import { homedir } from 'node:os'; type Store = 'apple' | 'play'; @@ -175,6 +177,8 @@ async function folders(sub: string, args: Args): Promise { } if (!r.projects.length) { console.log(`no app folders yet (${tilde(registryFile())}): storebound apps add, in an app folder (or storebound desktop there)`); return; } console.log(textTable(['app', 'folder', ''], r.projects.map(p => [p.name, tilde(p.path), existsSync(p.path) ? '' : 'folder not found']))); + const missing = r.projects.filter(p => !existsSync(p.path)).length; + if (missing) console.log(`\n${missing} folder(s) not found (moved to another computer or folder?): storebound apps relink --dry finds them by path and app name`); console.log(` ${tilde(registryFile())}: the same list as the desktop app's App folders`); return; @@ -218,10 +222,25 @@ ${tilde(registryFile())}: the same list as the desktop app's App folders`); log('apps', removed ? `removed ${tilde(removed.path)} from the list (the folder and its files stay)` : `${tilde(dir)} is not in the list`); } +/** apps relink: point "folder not found" entries at the same app on this machine (after a move); ids stay. */ +function relink(args: Args): void { + const home = homedir(); + const roots = args.list('root').length ? args.list('root').map(r => resolve(r)) + : [join(home, 'Documents', 'Visual Studio Workspaces'), join(home, 'Documents'), join(home, 'Desktop'), join(home, 'Projects'), join(home, 'code')].filter(d => existsSync(d)); + const plan = relinkPlan(readRegistry(), configFolders(roots), existsSync, configAppName); + if (args.has('json')) { printJson({ roots, plan, dry: args.has('dry') }); } + else if (!plan.length) log('apps', `nothing to relink (looked under ${roots.map(tilde).join(', ')}; --root to look elsewhere)`); + else for (const s of plan) log('apps', `${s.name}: ${s.from} -> ${tilde(s.to)}`); + if (!plan.length || args.has('dry')) { if (plan.length) done('apps', `dry run: ${plan.length} folder(s) would be relinked`); return; } + applyRelink(plan); + done('apps', `${plan.length} folder(s) relinked (the desktop app picks them up on its next start)`); +} + export async function run(args: Args): Promise { const sub = args._[0]; const root = process.cwd(); const json = args.has('json'); + if (sub === 'relink') return relink(args); if (sub === 'folders' || sub === 'add' || sub === 'remove') return folders(sub, args); if (sub === 'list') { const out: AppsListResult[] = []; diff --git a/cli/data/flags.json b/cli/data/flags.json index e27b0da..6c48e61 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -317,6 +317,7 @@ "bundle-id", "icons", "name", + "root", "store" ], "art": [ @@ -621,6 +622,9 @@ "play listing": [ "include-drafts", "not-sent-for-review", + "prune", + "src", + "variant", "write" ], "play next-version-code": [], @@ -636,6 +640,9 @@ "name", "not-sent-for-review", "production", + "prune", + "src", + "variant", "version-code", "write" ], @@ -683,8 +690,11 @@ "include-drafts", "name", "not-sent-for-review", + "prune", + "src", "status", "track", + "variant", "write" ], "prices": [ @@ -1115,6 +1125,7 @@ "resolve-iaps", "resume", "review-sent", + "root", "scenes", "scheme", "sdk-dirs", @@ -1337,7 +1348,8 @@ "storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix", "storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,", "storebound apps add [] | apps remove add / remove a folder in that list (the folder itself stays)", - "storebound apps add --store apple|play --app-id [--account ] [--name ] [--bundle-id ]" + "storebound apps add --store apple|play --app-id [--account ] [--name ] [--bundle-id ]", + "storebound apps relink [--root ]... [--dry] after a move: \"folder not found\" entries -> the same app here" ], "art": [ "storebound art pull [--store apple|play] [--write] [--force] [--dry] [--json]", diff --git a/cli/lib/registry.ts b/cli/lib/registry.ts index e16a6ee..81dc840 100644 --- a/cli/lib/registry.ts +++ b/cli/lib/registry.ts @@ -12,7 +12,7 @@ // Written atomically (temp file, rename); a missing or unreadable file reads as empty (an unreadable one is kept). import { consoleLink } from './console-links'; import { createHash } from 'node:crypto'; -import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; @@ -87,7 +87,7 @@ export function withFolder(r: Registry, path: string, name: string | undefined, const abs = resolve(path); const have = r.projects.find(p => p.path === abs); if (have) return { registry: r, project: have, added: false }; - const project = { id: folderId(abs), path: abs, name: name?.trim() || basename(abs), added: now }; + const project = { id: folderId(abs), path: abs, name: name?.trim() || configAppName(abs) || basename(abs), added: now }; return { registry: { ...r, version: 1, projects: [...r.projects, project] }, project, added: true }; } @@ -144,3 +144,64 @@ export function removeFolder(pathOrId: string, file = registryFile()): RegistryP if (removed) writeRegistry(registry, file); return removed; } + +/** The app name a storebound config gives (`app: { name: '...' }`), read as text (no config load), else null. */ +export function configAppName(dir: string): string | null { + for (const n of ['storebound.config.ts', 'storebound.config.mts', 'storebound.config.js', 'storebound.config.mjs']) { + try { + const text = readFileSync(join(dir, n), 'utf8'); + const m = /\bapp\s*:\s*\{[^}]*?\bname\s*:\s*(['"`])([^'"`]+)\1/s.exec(text); + if (m) return m[2]!.trim(); + } catch { /* not this one */ } + } + return null; +} + +/** Folders with a storebound config under the roots (depth 4; node_modules, .git and _arsiv skipped). */ +export function configFolders(roots: string[], depth = 4): string[] { + const out: string[] = []; + const walk = (d: string, left: number) => { + let names: string[] = []; + try { names = readdirSync(d); } catch { return; } + if (names.some(n => /^storebound\.config\.(ts|mts|js|mjs)$/.test(n))) out.push(d); + if (left <= 0) return; + for (const n of names) { + if (n === 'node_modules' || n.startsWith('.') || n === '_arsiv') continue; + const p = join(d, n); + try { if (statSync(p).isDirectory()) walk(p, left - 1); } catch { /* unreadable */ } + } + }; + for (const r of roots) walk(r, depth); + return [...new Set(out)]; +} + +/** + * After a machine move the list keeps the old computer's paths ("folder not found"). For each missing folder: a + * config folder under the roots whose last path parts match (some-game, Acme/frontend), else one whose app name is + * the same as the entry's name. The id stays, so the desktop's per-app accounts and settings stay linked. Pure. + */ +export function relinkPlan(r: Registry, candidates: string[], exists: (p: string) => boolean, nameOf: (dir: string) => string | null): { id: string; name: string; from: string; to: string }[] { + const parts = (p: string) => p.split(/[\\/]+/).filter(Boolean).map(x => x.toLowerCase()); + const taken = new Set(r.projects.filter(p => exists(p.path)).map(p => resolve(p.path).toLowerCase())); + const plan: { id: string; name: string; from: string; to: string }[] = []; + for (const p of r.projects) { + if (exists(p.path)) continue; + const want = parts(p.path); + const free = candidates.filter(c => !taken.has(resolve(c).toLowerCase())); + let best: string | undefined; + for (const k of [3, 2, 1]) { + const tail = want.slice(-k).join('/'); + best = free.find(c => parts(c).slice(-k).join('/') === tail); + if (best) break; + } + best ??= free.find(c => nameOf(c)?.toLowerCase() === p.name.toLowerCase()); + if (best) { plan.push({ id: p.id, name: p.name, from: p.path, to: resolve(best) }); taken.add(resolve(best).toLowerCase()); } + } + return plan; +} + +export function applyRelink(plan: { id: string; to: string }[], file = registryFile()): void { + const r = readRegistry(file); + for (const s of plan) { const p = r.projects.find(x => x.id === s.id); if (p) p.path = s.to; } + writeRegistry(r, file); +} diff --git a/tests/registry-relink.test.ts b/tests/registry-relink.test.ts new file mode 100644 index 0000000..31d1cb6 --- /dev/null +++ b/tests/registry-relink.test.ts @@ -0,0 +1,33 @@ +// After a machine move the app folder list points at the old computer; relink finds each app here and keeps its id. +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { configAppName, configFolders, relinkPlan } from '../cli/lib/registry'; + +function app(root: string, rel: string, name: string): string { + const d = join(root, rel); mkdirSync(d, { recursive: true }); + writeFileSync(join(d, 'storebound.config.ts'), `export default { app: { name: '${name}' } };\n`); + return d; +} + +describe('apps relink', () => { + test('by the last path parts, else by the app name; ids stay; a taken folder is not reused', () => { + const root = mkdtempSync(join(tmpdir(), 'sb-relink-')); + const game = app(root, 'game-one', 'Game One'); + const front = app(root, 'Studio/frontend', 'Studio'); + const renamed = app(root, 'NewName/app', 'Old App'); + mkdirSync(join(root, 'node_modules/x'), { recursive: true }); writeFileSync(join(root, 'node_modules/x/storebound.config.ts'), ''); + const found = configFolders([root]); + expect(found.sort()).toEqual([game, front, renamed].sort()); + expect(configAppName(front)).toBe('Studio'); + const r = { version: 1 as const, projects: [ + { id: 'a', path: '/Users/old/Desktop/game-one', name: 'Game One', added: '' }, + { id: 'b', path: '/Users/old/Work/Studio/frontend', name: 'frontend', added: '' }, + { id: 'c', path: '/Users/old/Work/OldName/app', name: 'Old App', added: '' }, + { id: 'd', path: '/Users/old/gone', name: 'Gone', added: '' }, + ] }; + const plan = relinkPlan(r, found, () => false, configAppName); + expect(plan.map(p => [p.id, p.to])).toEqual([['a', game], ['b', front], ['c', renamed]]); + }); +}); From cbba936ed801ddcd48a72f3ee54df0ece3b28823 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 16:03:07 +0800 Subject: [PATCH 021/255] Entitlements reference check compares forward-slash paths on every OS (false 'not referenced by CODE_SIGN_ENTITLEMENTS' on Windows); test, pitfall 103 --- CHANGELOG.md | 2 ++ cli/apple/signing.ts | 8 +++++--- skills/store-pitfalls/SKILL.md | 8 ++++++++ tests/entitlements-ref.test.ts | 18 ++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 tests/entitlements-ref.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac553e3..d222a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Windows: no false "entitlements not referenced by CODE_SIGN_ENTITLEMENTS" warning (paths compared with forward slashes). Pitfall 103. + - **`storebound apps relink`** after a machine move: every "folder not found" entry of the app folder list is pointed at the same app on this machine (by its path or its app name), ids kept so the desktop keeps its accounts and settings; `apps folders` says so when entries are missing. `apps add` names an app after its config's app name. diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index 724e56c..30ce943 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -14,7 +14,7 @@ import { ensureGcSwitch, gcSwitchPlan, readGcSwitch } from './game-center'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; -import { basename, dirname, join, relative, resolve as resolvePath } from 'node:path'; +import { basename, dirname, join, relative, resolve as resolvePath, sep } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr'; @@ -83,9 +83,11 @@ export function readProjectEntitlements(root: string): ProjectEntitlements[] | n for (const n of names) { const f = join(dir, n); if (n.endsWith('.entitlements')) { - const rel = relative(root, f); + // forward slashes on every OS: the pbxproj writes App/App.entitlements, Windows paths use backslashes + const rel = relative(root, f).split(sep).join('/'); + const fwd = f.split(sep).join('/'); if (isBuildCopyPath(rel) || out.some(o => o.file === rel)) continue; - out.push(projectEntitlements(rel, readFileSync(f, 'utf8'), pbx ? refs.some(r => f.endsWith('/' + r)) : null)); + out.push(projectEntitlements(rel, readFileSync(f, 'utf8'), pbx ? refs.some(r => fwd.endsWith('/' + r.replace(/\\/g, '/'))) : null)); } else if (d > 0 && !n.startsWith('.') && !['node_modules', 'Pods', 'build', 'DerivedData'].includes(n) && !n.endsWith('.xcodeproj')) { try { if (statSync(f).isDirectory()) walk(f, d - 1); } catch { /* unreadable */ } } diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index ec8c872..6f89dc2 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1243,3 +1243,11 @@ that fails without the fix (see CONTRIBUTING.md). language in the same edit. Then run `play images` for the screenshots. - **Guard:** `firstGraphics` in `cli/play/images.ts`, used by `cli/play/listing.ts` for languages Play does not have yet; it stops with the fix when the 512x512 icon is missing. + +## 103. "App.entitlements is not referenced by CODE_SIGN_ENTITLEMENTS" on Windows although it is +- **Symptom:** on Windows, `apple status` / `apple signing` warned that `ios\App\App\App.entitlements` is not + referenced, while project.pbxproj sets `CODE_SIGN_ENTITLEMENTS = App/App.entitlements` for both configurations. +- **Cause:** the check compared the Windows path (backslashes) with the pbxproj's forward-slash path. +- **Do:** update storebound; the warning was false. +- **Guard:** `readProjectEntitlements` in `cli/apple/signing.ts` compares forward-slash paths on every OS + (`tests/entitlements-ref.test.ts`). diff --git a/tests/entitlements-ref.test.ts b/tests/entitlements-ref.test.ts new file mode 100644 index 0000000..8d1c56c --- /dev/null +++ b/tests/entitlements-ref.test.ts @@ -0,0 +1,18 @@ +// The entitlements file the pbxproj names is "referenced" on every OS (Windows paths use backslashes; pitfall 103). +import { expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readProjectEntitlements } from '../cli/apple/signing'; + +test('CODE_SIGN_ENTITLEMENTS = App/App.entitlements matches ios/App/App/App.entitlements', () => { + const root = mkdtempSync(join(tmpdir(), 'sb-ent-')); + mkdirSync(join(root, 'ios/App/App.xcodeproj'), { recursive: true }); + mkdirSync(join(root, 'ios/App/App'), { recursive: true }); + writeFileSync(join(root, 'ios/App/App.xcodeproj/project.pbxproj'), 'buildSettings = { CODE_SIGN_ENTITLEMENTS = App/App.entitlements; };\n'); + writeFileSync(join(root, 'ios/App/App/App.entitlements'), 'com.apple.developer.game-center'); + const r = readProjectEntitlements(root)!; + expect(r).toHaveLength(1); + expect(r[0]!.file).toBe('ios/App/App/App.entitlements'); + expect(r[0]!.referenced).toBe(true); +}); From 294fa322e2cbd8bd0b737853294f375b52052b88 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 15:03:45 +0700 Subject: [PATCH 022/255] storebound 0.8.28: version in package.json and every copy, CHANGELOG 0.8.28 section --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6c2381f..7000a74 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.27", + "version": "0.8.28", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c750978..7535e7a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.27", + "version": "0.8.28", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c444524..e0abd17 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.27", + "version": "0.8.28", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/CHANGELOG.md b/CHANGELOG.md index d222a34..1781c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.28 · 2026-10-22 + - Windows: no false "entitlements not referenced by CODE_SIGN_ENTITLEMENTS" warning (paths compared with forward slashes). Pitfall 103. - **`storebound apps relink`** after a machine move: every "folder not found" entry of the app folder list is pointed at diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4c959fa..4ed3263 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.27", + "version": "0.8.28", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index f528243..4dc267b 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4096,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.27" +version = "0.8.28" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1a55690..1ad40b3 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.27" +version = "0.8.28" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 6cba48d..33afde1 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.27", + "version": "0.8.28", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", diff --git a/gemini-extension.json b/gemini-extension.json index a727731..90ed811 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.27", + "version": "0.8.28", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index 220d0c1..e3dcb1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.27", + "version": "0.8.28", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", From 4b0f9cd5b8a62ba09a2b7086076e797776899721 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 20:28:16 +0800 Subject: [PATCH 023/255] apple iap: the dry run shows a live price that differs from the config (USA base and overrides) and --allow-price-changes replaces the price schedule; prices diff (the catalog diff report) --- cli/apple/iap.ts | 44 +++++++++++++++++++++++++++++------------ cli/data/flags.json | 48 ++++++++++++++++++++++++++++++++++++++------- cli/prices.ts | 4 +++- 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/cli/apple/iap.ts b/cli/apple/iap.ts index 6a580f6..0536a44 100644 --- a/cli/apple/iap.ts +++ b/cli/apple/iap.ts @@ -1,7 +1,8 @@ // In-app purchases on App Store Connect: create missing products, localizations, price schedule (USA base + your // overrides; Apple equalizes every other storefront) and availability (every territory + new ones). Idempotent: -// existing products and schedules are left alone (prices of an existing schedule are not changed; do that in the UI). -// storebound apple iap [--dry] [--only id1,id2] +// existing products are left alone; a price that differs from the config (USA base and overrides) is shown in the plan +// and changed only with --allow-price-changes (live prices are the owner's call; Acme 2026-10-22: the plan was silent). +// storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes] // Then `apple iap-review` for the review screenshots. First IAPs can only be submitted together with an app version, // from the IAP page in the web UI (see the store-iap-setup skill). // --dry reads everything (read-only) and prints one plan row per product. After a real run the store's product list @@ -18,6 +19,7 @@ import { appleTerritory, nearestPrices, samePrice } from '../lib/money'; import { createOnce, findOrCreate, getById } from '../lib/upsert'; import { ascClient, type J } from './asc'; import * as rt from '../lib/rt'; +import { appleCurrent } from '../prices'; /** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */ export const APPLE_IAP_TYPE = { consumable: 'CONSUMABLE', non_consumable: 'NON_CONSUMABLE', non_renewing: 'NON_RENEWING_SUBSCRIPTION' } as const; @@ -42,6 +44,24 @@ export async function run(ctx: Ctx): Promise { throw new Error(`${territory} has no ${price} price point (nearest ${nearestPrices(seen, price).join(', ')})`); } + const wanted = (p: (typeof products)[number]) => [{ territory: 'USA', price: p.usd }, ...(p.overrides ?? []).map(o => ({ territory: appleTerritory(o), price: o.price }))]; + /** "USA 9.99 -> 12.99" per territory whose live (current, not scheduled) manual price differs from the config. */ + async function priceDiffs(id: string, p: (typeof products)[number]): Promise { + const live = (await appleCurrent(c, id, p.id, false)).rows.filter(x => !x.startDate || x.startDate <= new Date().toISOString().slice(0, 10)); + return wanted(p).flatMap(w => { const cur = live.find(x => x.territory === w.territory); return cur && !samePrice(cur.price, w.price) ? [`${w.territory} ${cur.price} -> ${w.price}`] : []; }); + } + async function writeSchedule(id: string, manual: { territory: string; price: number }[]): Promise { + const points = await Promise.all(manual.map(m => pricePoint(id, m.territory, m.price))); + await c.asc('POST', '/v1/inAppPurchasePriceSchedules', { + data: { type: 'inAppPurchasePriceSchedules', relationships: { + inAppPurchase: { data: { type: 'inAppPurchases', id } }, + baseTerritory: { data: { type: 'territories', id: 'USA' } }, + manualPrices: { data: manual.map(m => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}' })) } } }, + included: manual.map((m, k) => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}', attributes: { startDate: null }, + relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: points[k] } } } })), + }); + } + const allowPrices = ctx.args.has('allow-price-changes'); const rows: PlanRow[] = []; const created: string[] = []; for (const [i, p] of products.entries()) { @@ -76,17 +96,13 @@ export async function run(ctx: Ctx): Promise { const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`); if (!sched?.included?.length) { - const manual = [{ territory: 'USA', price: p.usd }, ...(p.overrides ?? []).map(o => ({ territory: appleTerritory(o), price: o.price }))]; - const points = await Promise.all(manual.map(m => pricePoint(id, m.territory, m.price))); - await c.asc('POST', '/v1/inAppPurchasePriceSchedules', { - data: { type: 'inAppPurchasePriceSchedules', relationships: { - inAppPurchase: { data: { type: 'inAppPurchases', id } }, - baseTerritory: { data: { type: 'territories', id: 'USA' } }, - manualPrices: { data: manual.map(m => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}' })) } } }, - included: manual.map((m, k) => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}', attributes: { startDate: null }, - relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: points[k] } } } })), - }); + const manual = wanted(p); + await writeSchedule(id, manual); log('apple', ` prices ${manual.map(m => `${m.territory} ${m.price}`).join(', ')}`); + } else { + const diff = await priceDiffs(id, p); + if (diff.length && allowPrices) { await writeSchedule(id, wanted(p)); log('apple', ` prices changed: ${diff.join(', ')} (takes effect today; Apple equalizes the other storefronts)`); } + else if (diff.length) log('apple', ` prices differ from the config, NOT changed: ${diff.join(', ')} (live prices: re-run with --allow-price-changes after the owner's yes)`); } const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`); @@ -120,7 +136,9 @@ export async function run(ctx: Ctx): Promise { const add = locales.filter(l => !have.has(l)); const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`); const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`); - const parts = [add.length ? `localizations ${add.join(', ')}` : '', sched?.included?.length ? '' : `prices ${prices}`, avail?.data ? '' : `availability ${territories.length} territories + new ones`].filter(Boolean); + const diff = sched?.included?.length ? await priceDiffs(id, p) : []; + const priceNote = !sched?.included?.length ? `prices ${prices}` : diff.length ? `PRICE CHANGE ${diff.join(', ')}${allowPrices ? '' : ' (only with --allow-price-changes)'}` : ''; + const parts = [add.length ? `localizations ${add.join(', ')}` : '', priceNote, avail?.data ? '' : `availability ${territories.length} territories + new ones`].filter(Boolean); return parts.length ? { item: p.id, action: 'update', detail: `${parts.join('; ')} (state ${state ?? '?'})` } : { item: p.id, action: 'keep' }; } } diff --git a/cli/data/flags.json b/cli/data/flags.json index 6c48e61..fe63096 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -107,7 +107,31 @@ "keychain", "no-install" ], - "apple iap": [], + "apple iap": [ + "all", + "allow-price-changes", + "base", + "ceiling", + "csv", + "file", + "floor", + "include-high-income", + "max-level", + "max-ratio", + "min-ratio", + "no-only-decreases", + "no-subscriptions", + "offline", + "only-decreases", + "out", + "reconvert", + "source", + "store", + "threshold", + "update-bigmac", + "with-automatic", + "write" + ], "apple iap-review": [], "apple iap-submit": [ "new-version", @@ -144,8 +168,10 @@ "reconvert", "source", "store", + "threshold", "update-bigmac", - "with-automatic" + "with-automatic", + "write" ], "apple ppo": [ "experiment", @@ -253,8 +279,10 @@ "reconvert", "source", "store", + "threshold", "update-bigmac", - "with-automatic" + "with-automatic", + "write" ], "apple testflight": [ "build", @@ -309,8 +337,10 @@ "reconvert", "source", "store", + "threshold", "update-bigmac", - "with-automatic" + "with-automatic", + "write" ], "apps": [ "app-id", @@ -683,8 +713,10 @@ "reconvert", "source", "store", + "threshold", "update-bigmac", - "with-automatic" + "with-automatic", + "write" ], "play upload": [ "include-drafts", @@ -717,8 +749,10 @@ "reconvert", "source", "store", + "threshold", "update-bigmac", - "with-automatic" + "with-automatic", + "write" ], "privacy-manifest": [ "ios", @@ -1262,7 +1296,7 @@ "storebound apple game-center [status] [--dry] [--yes] [--json]" ], "apple iap": [ - "storebound apple iap [--dry] [--only id1,id2]" + "storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]" ], "apple iap-review": [ "storebound apple iap-review [--dry]" diff --git a/cli/prices.ts b/cli/prices.ts index 18a7d71..2964f8e 100644 --- a/cli/prices.ts +++ b/cli/prices.ts @@ -64,7 +64,9 @@ export async function run(ctx: Ctx): Promise { const a = ctx.args; const sub = a._[0]; if (sub === 'ppp') return (await import('./prices-ppp')).run(ctx); - if (sub !== 'export' && sub !== 'import') throw new Error('usage: storebound prices export | import [--csv store/prices.csv] [--dry] | ppp [--base ] [--dry]'); + // `prices diff`: the config's prices against both stores (the same read-only report as `catalog diff`) + if (sub === 'diff') { ctx.args._.splice(0, 1, 'diff'); return (await import('./catalog')).run(ctx); } + if (sub !== 'export' && sub !== 'import') throw new Error('usage: storebound prices export | import [--csv store/prices.csv] [--dry] | diff | ppp [--base ] [--dry]'); const store = a.get('store'); if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play'); const useApple = (!store || store === 'apple') && !!config.apple; From f7ff52dfddc0e47f9fe0a3c10f047a4b45af3fbb Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 20:32:52 +0800 Subject: [PATCH 024/255] apple iap: existing products are compared with the config: localization name / description, reference name, review note (patched), family sharing (reported; on only), availability territories; all shown in the dry plan, one line each --- cli/apple/iap.ts | 86 ++++++++++++++++++++++++++++++++--- tests/apple-iap-drift.test.ts | 64 ++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 tests/apple-iap-drift.test.ts diff --git a/cli/apple/iap.ts b/cli/apple/iap.ts index 0536a44..39cc4f7 100644 --- a/cli/apple/iap.ts +++ b/cli/apple/iap.ts @@ -24,6 +24,50 @@ import { appleCurrent } from '../prices'; /** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */ export const APPLE_IAP_TYPE = { consumable: 'CONSUMABLE', non_consumable: 'NON_CONSUMABLE', non_renewing: 'NON_RENEWING_SUBSCRIPTION' } as const; +// one plan line per change: store texts carry line breaks, which broke the dry-run table +const clip = (s: string, n = 40): string => { const a = [...s.replace(/\s+/g, ' ').trim()]; return a.length > n ? `${a.slice(0, n - 1).join('')}…` : a.join(''); }; + +export type IapLocalization = { locale: string; id?: string; name: string; description: string }; +export type IapLocalizationChange = { locale: string; id: string; attrs: { name?: string; description?: string }; note: string }; +/** Existing locales whose name/description differ from the config: the attributes to PATCH and a short plan note. */ +export function iapLocalizationChanges(store: IapLocalization[], wanted: IapLocalization[]): IapLocalizationChange[] { + const out: IapLocalizationChange[] = []; + for (const w of wanted) { + const s = store.find(x => x.locale === w.locale); + if (!s?.id) continue; + const attrs: { name?: string; description?: string } = {}; + const parts: string[] = []; + if ((s.name ?? '') !== w.name) { attrs.name = w.name; parts.push(`name "${clip(s.name ?? '')}" -> "${clip(w.name)}"`); } + if ((s.description ?? '') !== w.description) { attrs.description = w.description; parts.push(`description "${clip(s.description ?? '')}" -> "${clip(w.description)}"`); } + if (parts.length) out.push({ locale: w.locale, id: s.id, attrs, note: `localization ${w.locale}: ${parts.join(', ')}` }); + } + return out; +} + +export type IapAttributes = { name?: string; reviewNote?: string; familySharable?: boolean }; +export type IapAttributeChanges = { patch: { name?: string; reviewNote?: string; familySharable?: true }; notes: string[]; familyMismatch: string | null }; +/** Product attributes: referenceName (`name`), reviewNote (only when the config sets one), familySharable (on only; Apple cannot turn it off). */ +export function iapAttributeChanges(store: IapAttributes, wanted: IapAttributes): IapAttributeChanges { + const patch: IapAttributeChanges['patch'] = {}; + const notes: string[] = []; + let familyMismatch: string | null = null; + if (wanted.name !== undefined && (store.name ?? '') !== wanted.name) { patch.name = wanted.name; notes.push(`referenceName "${clip(store.name ?? '')}" -> "${clip(wanted.name)}"`); } + if (wanted.reviewNote !== undefined && (store.reviewNote ?? '') !== wanted.reviewNote) { patch.reviewNote = wanted.reviewNote; notes.push(`reviewNote "${clip(store.reviewNote ?? '')}" -> "${clip(wanted.reviewNote)}"`); } + const wantFam = wanted.familySharable ?? false, haveFam = store.familySharable ?? false; + if (wantFam && !haveFam) { patch.familySharable = true; notes.push('familySharable false -> true (Apple cannot turn it off again)'); } + else if (!wantFam && haveFam) familyMismatch = 'familySharable is on in the store but off in the config (Apple cannot turn it off; not changed)'; + return { patch, notes, familyMismatch }; +} + +/** Availability: null when the store covers every wanted territory and new ones; else "N -> M" (a POST replaces it). */ +export function availabilityChange(store: { territories: string[]; newTerritories: boolean } | null, territories: string[]): { from: number; to: number; note: string } | null { + const have = new Set(store?.territories ?? []); + const missing = territories.filter(t => !have.has(t)); + if (store && !missing.length && store.newTerritories) return null; + const from = store ? have.size : 0; + return { from, to: territories.length, note: `availability: ${from} -> ${territories.length} territories${store && !store.newTerritories ? ' (+ new territories on)' : ''}` }; +} + export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); @@ -50,6 +94,15 @@ export async function run(ctx: Ctx): Promise { const live = (await appleCurrent(c, id, p.id, false)).rows.filter(x => !x.startDate || x.startDate <= new Date().toISOString().slice(0, 10)); return wanted(p).flatMap(w => { const cur = live.find(x => x.territory === w.territory); return cur && !samePrice(cur.price, w.price) ? [`${w.territory} ${cur.price} -> ${w.price}`] : []; }); } + const locOf = (x: J) => ({ locale: x.attributes.locale as string, name: (x.attributes.name ?? '') as string, description: (x.attributes.description ?? '') as string }); + const wantedLocs = (p: (typeof products)[number]) => Object.entries(p.listings).flatMap(([k, l]) => { const locale = langs.get(k)?.apple; return locale ? [{ locale, name: l.title, description: l.description }] : []; }); + const wantedAttrs = (p: (typeof products)[number]): IapAttributes => ({ name: p.referenceName ?? Object.values(p.listings)[0]!.title, reviewNote: p.reviewNote, familySharable: p.familySharable }); + /** The store's availability as territory ids + the new-territories flag (null when there is none). */ + async function readAvail(avail: J | null | undefined): Promise<{ territories: string[]; newTerritories: boolean } | null> { + if (!avail?.data) return null; + const ts = (await c.all(`/v1/inAppPurchaseAvailabilities/${avail.data.id}/availableTerritories?limit=200`)).map((t: J) => t.id as string); + return { territories: ts, newTerritories: avail.data.attributes?.availableInNewTerritories === true }; + } async function writeSchedule(id: string, manual: { territory: string; price: number }[]): Promise { const points = await Promise.all(manual.map(m => pricePoint(id, m.territory, m.price))); await c.asc('POST', '/v1/inAppPurchasePriceSchedules', { @@ -82,7 +135,8 @@ export async function run(ctx: Ctx): Promise { log('apple', `${tag}: ${made.created ? 'created' : 'exists'}`); const locsOf = async () => await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`); - const have = new Set((await locsOf()).map((l: J) => l.attributes.locale)); + const storeLocs = await locsOf(); + const have = new Set(storeLocs.map((l: J) => l.attributes.locale)); for (const [langId, l] of Object.entries(p.listings)) { const locale = langs.get(langId)?.apple; if (!locale || have.has(locale)) continue; @@ -93,6 +147,22 @@ export async function run(ctx: Ctx): Promise { relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id } } } } })).data }); log('apple', ` localization ${locale}`); } + for (const ch of iapLocalizationChanges(storeLocs.map((x: J) => ({ id: x.id, ...locOf(x) })), wantedLocs(p))) { + const l = Object.entries(p.listings).find(([k]) => langs.get(k)?.apple === ch.locale)![1]; + if ([...l.description].length > APPLE_IAP_LIMITS.description) throw new Error(`${p.id} ${ch.locale}: description over ${APPLE_IAP_LIMITS.description} characters (Apple limit)`); + if ([...l.title].length > APPLE_IAP_LIMITS.name) throw new Error(`${p.id} ${ch.locale}: name over ${APPLE_IAP_LIMITS.name} characters (Apple limit)`); + await c.asc('PATCH', `/v1/inAppPurchaseLocalizations/${ch.id}`, { data: { type: 'inAppPurchaseLocalizations', id: ch.id, attributes: ch.attrs } }); + log('apple', ` ${ch.note}`); + } + if (!made.created) { + const cur = (await c.asc('GET', `/v2/inAppPurchases/${id}`)).data?.attributes ?? {}; + const ac = iapAttributeChanges(cur, wantedAttrs(p)); + if (Object.keys(ac.patch).length) { + await c.asc('PATCH', `/v2/inAppPurchases/${id}`, { data: { type: 'inAppPurchases', id, attributes: ac.patch } }); + log('apple', ` product updated: ${ac.notes.join(', ')}`); + } + if (ac.familyMismatch) log('apple', ` ${ac.familyMismatch}`); + } const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`); if (!sched?.included?.length) { @@ -106,11 +176,12 @@ export async function run(ctx: Ctx): Promise { } const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`); - if (!avail?.data) { + const availDiff = availabilityChange(await readAvail(avail), territories); + if (availDiff) { await c.asc('POST', '/v1/inAppPurchaseAvailabilities', { data: { type: 'inAppPurchaseAvailabilities', attributes: { availableInNewTerritories: true }, relationships: { inAppPurchase: { data: { type: 'inAppPurchases', id } }, availableTerritories: { data: territories.map(t => ({ type: 'territories', id: t })) } } } }); - log('apple', ` available in ${territories.length} territories + new ones`); + log('apple', ` ${avail?.data ? availDiff.note : `available in ${territories.length} territories + new ones`}`); } } if (dry) { @@ -132,13 +203,16 @@ export async function run(ctx: Ctx): Promise { const locales = Object.keys(p.listings).map(l => langs.get(l)?.apple).filter((l): l is string => !!l); const prices = [`USA ${p.usd}`, ...(p.overrides ?? []).map(o => `${appleTerritory(o)} ${o.price}`)].join(', '); if (!id) return { item: p.id, action: 'create', detail: `${APPLE_IAP_TYPE[p.type]}; localizations ${locales.join(', ')}; prices ${prices}; ${territories.length} territories + new ones` }; - const have = new Set((await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`)).map((l: J) => l.attributes.locale)); + const storeLocs = await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`); + const have = new Set(storeLocs.map((l: J) => l.attributes.locale)); + const locChanges = iapLocalizationChanges(storeLocs.map((x: J) => ({ id: x.id, ...locOf(x) })), wantedLocs(p)); + const ac = iapAttributeChanges((await c.asc('GET', `/v2/inAppPurchases/${id}`)).data?.attributes ?? {}, wantedAttrs(p)); + const availDiff = availabilityChange(await readAvail(await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`)), territories); const add = locales.filter(l => !have.has(l)); const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`); - const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`); const diff = sched?.included?.length ? await priceDiffs(id, p) : []; const priceNote = !sched?.included?.length ? `prices ${prices}` : diff.length ? `PRICE CHANGE ${diff.join(', ')}${allowPrices ? '' : ' (only with --allow-price-changes)'}` : ''; - const parts = [add.length ? `localizations ${add.join(', ')}` : '', priceNote, avail?.data ? '' : `availability ${territories.length} territories + new ones`].filter(Boolean); + const parts = [add.length ? `localizations ${add.join(', ')}` : '', priceNote, ...locChanges.map(x => x.note), ...ac.notes, ac.familyMismatch ? `MISMATCH ${ac.familyMismatch}` : '', availDiff?.note ?? ''].filter(Boolean); return parts.length ? { item: p.id, action: 'update', detail: `${parts.join('; ')} (state ${state ?? '?'})` } : { item: p.id, action: 'keep' }; } } diff --git a/tests/apple-iap-drift.test.ts b/tests/apple-iap-drift.test.ts new file mode 100644 index 0000000..cd10d4b --- /dev/null +++ b/tests/apple-iap-drift.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test'; +import { availabilityChange, iapAttributeChanges, iapLocalizationChanges } from '../cli/apple/iap'; + +describe('iapLocalizationChanges', () => { + const store = [{ id: 'l1', locale: 'en-US', name: 'Coins', description: 'A pile of coins' }, { id: 'l2', locale: 'de-DE', name: 'Muenzen', description: 'Viele' }]; + test('no difference means no change', () => { + expect(iapLocalizationChanges(store, [{ locale: 'en-US', name: 'Coins', description: 'A pile of coins' }])).toEqual([]); + }); + test('only the changed attribute is patched', () => { + const r = iapLocalizationChanges(store, [{ locale: 'en-US', name: 'Coins', description: 'More coins' }]); + expect(r).toHaveLength(1); + expect(r[0]!.id).toBe('l1'); + expect(r[0]!.attrs).toEqual({ description: 'More coins' }); + expect(r[0]!.note).toBe('localization en-US: description "A pile of coins" -> "More coins"'); + }); + test('name and description, long text truncated, missing locales skipped', () => { + const long = 'x'.repeat(100); + const r = iapLocalizationChanges(store, [{ locale: 'de-DE', name: 'Gold', description: long }, { locale: 'fr-FR', name: 'a', description: 'b' }]); + expect(r).toHaveLength(1); + expect(r[0]!.attrs).toEqual({ name: 'Gold', description: long }); + expect(r[0]!.note).toContain('name "Muenzen" -> "Gold"'); + expect(r[0]!.note.length).toBeLessThan(120); + }); +}); + +describe('iapAttributeChanges', () => { + test('equal attributes: nothing', () => { + const r = iapAttributeChanges({ name: 'com.example.coins', reviewNote: 'n', familySharable: false }, { name: 'com.example.coins', reviewNote: 'n', familySharable: false }); + expect(r.patch).toEqual({}); + expect(r.notes).toEqual([]); + expect(r.familyMismatch).toBeNull(); + }); + test('referenceName and reviewNote differ', () => { + const r = iapAttributeChanges({ name: 'Old', reviewNote: 'a' }, { name: 'New', reviewNote: 'b' }); + expect(r.patch).toEqual({ name: 'New', reviewNote: 'b' }); + expect(r.notes).toHaveLength(2); + }); + test('reviewNote not set in the config is not compared', () => { + expect(iapAttributeChanges({ name: 'A', reviewNote: 'kept' }, { name: 'A' }).patch).toEqual({}); + }); + test('familySharable can be turned on, never off', () => { + expect(iapAttributeChanges({ name: 'A', familySharable: false }, { name: 'A', familySharable: true }).patch).toEqual({ familySharable: true }); + const off = iapAttributeChanges({ name: 'A', familySharable: true }, { name: 'A', familySharable: false }); + expect(off.patch).toEqual({}); + expect(off.familyMismatch).toContain('familySharable'); + }); +}); + +describe('availabilityChange', () => { + const all = ['USA', 'DEU', 'FRA']; + test('complete availability with new territories: no change', () => { + expect(availabilityChange({ territories: ['USA', 'DEU', 'FRA', 'ITA'], newTerritories: true }, all)).toBeNull(); + }); + test('fewer territories', () => { + const r = availabilityChange({ territories: ['USA'], newTerritories: true }, all); + expect(r?.note).toBe('availability: 1 -> 3 territories'); + }); + test('new territories off', () => { + expect(availabilityChange({ territories: all, newTerritories: false }, all)?.note).toContain('new territories on'); + }); + test('no availability at all', () => { + expect(availabilityChange(null, all)).toMatchObject({ from: 0, to: 3 }); + }); +}); From f2b841d73576195b19ad18ee2dc1c416141bbe72 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 20:33:46 +0800 Subject: [PATCH 025/255] apple appinfo: live app price and availability compared with the config (price change needs --allow-price-changes, territory removals --allow-availability-changes); review-details --dry reads the store and shows old -> new (masked) --- cli/apple/appinfo.ts | 86 ++++++++++++++++++++++++++++++++----- cli/apple/review-details.ts | 27 +++++++++++- tests/appinfo-drift.test.ts | 68 +++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 tests/appinfo-drift.test.ts diff --git a/cli/apple/appinfo.ts b/cli/apple/appinfo.ts index 7eb176f..d99b83b 100644 --- a/cli/apple/appinfo.ts +++ b/cli/apple/appinfo.ts @@ -1,5 +1,5 @@ // App-level App Store settings (idempotent), from apple.info in storebound.config.ts: -// storebound apple appinfo [--dry] [--json] +// storebound apple appinfo [--dry] [--json] [--allow-price-changes] [--allow-availability-changes] // Every run first reads the current values and prints the plan (old -> new per field); --dry stops there, the real run // asks in a terminal and changes only what differs. // - categories (primary + up to two subcategories for games, secondary) @@ -22,14 +22,52 @@ const CATEGORY_RELS = ['primaryCategory', 'primarySubcategoryOne', 'primarySubca const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); /** The plan of one `apple appinfo` run, from read-only GETs. */ -export interface AppInfoPlan { appInfoId: string; items: ItemPlan[]; freePricePointId: string | null; territories: string[]; changes: number } +export interface AppInfoPlan { appInfoId: string; items: ItemPlan[]; freePricePointId: string | null; territories: string[]; changes: number; + /** Item keys that differ but are NOT applied (price without --allow-price-changes, territory removal without --allow-availability-changes). */ + blocked: string[] } + +type J = any; // eslint-disable-line @typescript-eslint/no-explicit-any + +/** The manual base price of an app price schedule as a label ('Free' or '4.99'), from GET appPriceSchedule/manualPrices + * ?include=appPricePoint. The current price is the one without a start date or the latest one already started. Pure. */ +export function currentBasePrice(page: { data?: J[]; included?: J[] } | null | undefined, today: string = new Date().toISOString().slice(0, 10)): string | null { + const points = new Map(); + for (const x of Array.isArray(page?.included) ? page!.included! : []) if (x.type === 'appPricePoints') points.set(x.id, String(x.attributes?.customerPrice ?? '')); + const started = (Array.isArray(page?.data) ? page!.data! : []).filter(p => !p.attributes?.startDate || String(p.attributes.startDate) <= today) + .sort((a, b) => String(a.attributes?.startDate ?? '').localeCompare(String(b.attributes?.startDate ?? ''))); + const cur = started[started.length - 1]; + const raw = cur ? points.get(cur.relationships?.appPricePoint?.data?.id) : undefined; + if (raw === undefined || raw === '' || Number.isNaN(Number(raw))) return null; + return Number(raw) === 0 ? 'Free' : String(Number(raw)); +} + +export interface AvailabilityNow { availableInNewTerritories: boolean; available: string[] } +export interface AvailabilityPlan { add: string[]; remove: string[]; newFlagChange: boolean; differs: boolean; from: string; to: string } + +/** Territory ids that are available now, from the pages of /v2/appAvailabilities/{id}/territoryAvailabilities. Pure. */ +export function availableTerritories(pages: { data?: J[] }[]): string[] { + return pages.flatMap(p => p.data ?? []).filter(t => t.attributes?.available === true).map(t => String(t.relationships?.territory?.data?.id ?? '')).filter(Boolean); +} + +/** Current availability against the wanted one (the listed territories, availableInNewTerritories true). Pure. */ +export function planAvailability(cur: AvailabilityNow, wanted: string[]): AvailabilityPlan { + const have = new Set(cur.available), want = new Set(wanted); + const add = wanted.filter(t => !have.has(t)), remove = cur.available.filter(t => !want.has(t)); + const newFlagChange = !cur.availableInNewTerritories; + return { add, remove, newFlagChange, differs: !!(add.length || remove.length || newFlagChange), from: `${cur.available.length} territories`, to: `${wanted.length} territories` }; +} + +/** Differing items that are held back: money needs --allow-price-changes, territory removal --allow-availability-changes. Pure. */ +export function blockedItems(o: { priceDiffers: boolean; removes: boolean; allowPrice: boolean; allowAvailability: boolean }): string[] { + return [...(o.priceDiffers && !o.allowPrice ? ['price'] : []), ...(o.removes && !o.allowAvailability ? ['availability removal'] : [])]; +} /** The plan as text lines. Pure. */ export function appInfoPlanLines(p: AppInfoPlan): string[] { return p.items.flatMap(i => itemLines(i.key, i, '')); } -async function planAppInfo(ctx: Ctx, c: Awaited>, appId: string): Promise { +export async function planAppInfo(ctx: Ctx, c: Awaited>, appId: string, opts: { allowPrice?: boolean; allowAvailability?: boolean } = {}): Promise { const info = ctx.loaded.config.apple?.info ?? {}; const appInfo = await c.editableAppInfo(); const items: ItemPlan[] = []; @@ -48,22 +86,47 @@ async function planAppInfo(ctx: Ctx, c: Awaited>, a const app = await c.maybe(`/v1/apps/${appId}`); items.push(planItem('content rights', { attributes: { contentRightsDeclaration: str(app?.data?.attributes?.contentRightsDeclaration) } }, { contentRightsDeclaration: info.contentRights })); } - let freePricePointId: string | null = null, territories: string[] = []; + let freePricePointId: string | null = null, territories: string[] = [], priceDiffers = false, removes = false; if (info.free !== false) { const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`); - if (!sched?.included?.some((x: { type: string }) => x.type === 'appPrices')) { + const findFree = async (): Promise => { const free = (await c.all(`/v1/apps/${appId}/appPricePoints?filter[territory]=USA&limit=200`)).find(p => Number(p.attributes.customerPrice) === 0); if (!free) throw new Error('no free price point for USA'); - freePricePointId = free.id; + return free.id as string; + }; + if (!sched?.included?.some((x: { type: string }) => x.type === 'appPrices')) { + freePricePointId = await findFree(); items.push(planItem('price', null, { price: 'Free', baseTerritory: 'USA' })); - } else items.push(planItem('price', { attributes: { schedule: 'exists' } }, { schedule: 'exists' })); + } else { + const now = currentBasePrice(await c.maybe(`/v1/apps/${appId}/appPriceSchedule/manualPrices?include=appPricePoint&limit=50`)); + if (now === null) items.push(planItem('price', { attributes: { schedule: 'exists' } }, { schedule: 'exists' })); + else { + const it = planItem('price', { attributes: { price: now } }, { price: 'Free' }); + items.push(it); + if (it.action !== 'same') { priceDiffers = true; if (opts.allowPrice) freePricePointId = await findFree(); } + } + } const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`); + const all = (await c.all('/v1/territories?limit=200')).map(t => t.id as string); if (!avail?.data) { - territories = (await c.all('/v1/territories?limit=200')).map(t => t.id); + territories = all; items.push(planItem('availability', null, { territories: `${territories.length} (all)`, availableInNewTerritories: 'true' })); - } else items.push(planItem('availability', { attributes: { availability: 'exists' } }, { availability: 'exists' })); + } else { + const pages: J[] = []; + for (let url: string | null = `/v2/appAvailabilities/${avail.data.id}/territoryAvailabilities?include=territory&limit=200`; url;) { const pg: J = await c.asc('GET', url); pages.push(pg); url = pg.links?.next ?? null; } + const have = availableTerritories(pages); + const ap = planAvailability({ availableInNewTerritories: avail.data.attributes?.availableInNewTerritories === true, available: have }, all); + items.push(planItem('availability', { attributes: { availability: ap.from, availableInNewTerritories: String(!ap.newFlagChange) } }, { availability: ap.differs ? ap.to : ap.from, availableInNewTerritories: 'true' })); + if (ap.differs) { + removes = ap.remove.length > 0; + // additions go ahead; removals only with the flag (otherwise today's set plus the additions is sent) + territories = opts.allowAvailability || !removes ? all : [...new Set([...have, ...all])]; + } + } } - return { appInfoId: appInfo.id, items, freePricePointId, territories, changes: items.filter(i => i.action !== 'same').length }; + const blocked = blockedItems({ priceDiffers, removes, allowPrice: !!opts.allowPrice, allowAvailability: !!opts.allowAvailability }); + const held = (i: ItemPlan) => i.key === 'price' && blocked.includes('price'); + return { appInfoId: appInfo.id, items, freePricePointId, territories, blocked, changes: items.filter(i => i.action !== 'same' && !held(i)).length }; } export async function run(ctx: Ctx): Promise { @@ -73,9 +136,10 @@ export async function run(ctx: Ctx): Promise { refuseTemplate(config, ['ageRating'], 'apple appinfo (age rating declaration)'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); - const p = await planAppInfo(ctx, c, appId); + const p = await planAppInfo(ctx, c, appId, { allowPrice: ctx.args.has('allow-price-changes'), allowAvailability: ctx.args.has('allow-availability-changes') }); if (ctx.args.has('json')) printJson({ dry, ...p }); else for (const line of appInfoPlanLines(p)) console.log(line); + for (const b of p.blocked) warn('apple', b === 'price' ? "price differs from the config: not changed (live price; re-run with --allow-price-changes after the owner's yes)" : "availability would remove territories: not removed (re-run with --allow-availability-changes after the owner's yes)"); if (dry) { done('apple', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; } if (!p.changes) { done('apple', 'appinfo already up to date (App Privacy is UI-only: store-declarations skill)'); return; } if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; } diff --git a/cli/apple/review-details.ts b/cli/apple/review-details.ts index ab9404b..123cc50 100644 --- a/cli/apple/review-details.ts +++ b/cli/apple/review-details.ts @@ -8,12 +8,30 @@ import { refuseTemplate } from '../lib/template-answers'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; -import { findReviewContact, noContactMessage, reviewDemoAccount } from '../lib/credentials'; +import { findReviewContact, maskSecret, maskUser, noContactMessage, reviewDemoAccount } from '../lib/credentials'; import { reviewDetailPlan } from '../lib/review-details'; import { done, log } from '../lib/log'; import { createOnce } from '../lib/upsert'; import { ascClient } from './asc'; +type Attrs = Record; +const shown = (k: string, v: unknown): string => { + if (v === null || v === undefined || v === '') return '(empty)'; + const t = String(v); + if (k === 'demoAccountPassword') return maskSecret(t); + if (k === 'demoAccountName' || k === 'contactEmail') return maskUser(t); + if (k === 'contactPhone') return `${t.slice(0, 3)}***`; + if (k === 'notes') return `${new TextEncoder().encode(t).length} bytes`; + return t; +}; + +/** Changed review-detail keys as `key: old -> new` lines (secrets, phone, email masked; notes as byte counts). A missing + * detail (cur null) lists every key as new. Pure. */ +export function reviewDetailDiff(cur: Attrs | null, want: Attrs): string[] { + return Object.entries(want).filter(([k, v]) => !cur || (cur[k] ?? null) !== (v ?? null)) + .map(([k, v]) => `${k}: ${cur ? shown(k, cur[k]) : '(none)'} -> ${shown(k, v)}`); +} + export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; refuseTemplate(config, ['reviewNotes', 'reviewAccess', 'accounts'], 'apple review-details (App Review notes, demo account requirement)'); @@ -40,9 +58,14 @@ export async function run(ctx: Ctx): Promise { if (!contact) throw new Error(noContactMessage(root, checked)); const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root)); - if (ctx.args.has('dry')) { log('apple', `would set review details (contact ${from ? 'copied from ' + from : 'from credentials'}, ${summary})`); return; } const read = async () => (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data ?? null; const cur = await read(); + if (ctx.args.has('dry')) { + const lines = reviewDetailDiff(cur?.attributes ?? null, attributes); + log('apple', `would ${cur ? (lines.length ? 'update' : 'keep') : 'create'} review details (contact ${from ? 'copied from ' + from : 'from credentials'}, ${summary})`); + for (const l of lines) console.log(` ${l}`); + return; + } // one review detail per version: only changed fields are sent; a new one is created once (a 409 finds it) const changed = cur ? Object.fromEntries(Object.entries(attributes).filter(([k, v]) => (cur.attributes?.[k] ?? null) !== (v ?? null))) : attributes; if (cur && !Object.keys(changed).length) { done('apple', `review details on ${version.attributes.versionString} up to date (${summary})`); return; } diff --git a/tests/appinfo-drift.test.ts b/tests/appinfo-drift.test.ts new file mode 100644 index 0000000..8eaaee6 --- /dev/null +++ b/tests/appinfo-drift.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { availableTerritories, blockedItems, currentBasePrice, planAvailability } from '../cli/apple/appinfo'; +import { reviewDetailDiff } from '../cli/apple/review-details'; + +const price = (id: string, startDate: string | null, point: string) => ({ type: 'appPrices', id, attributes: { startDate }, relationships: { appPricePoint: { data: { type: 'appPricePoints', id: point } } } }); +const point = (id: string, customerPrice: string) => ({ type: 'appPricePoints', id, attributes: { customerPrice } }); + +describe('currentBasePrice', () => { + test('paid price is shown as a number', () => { + expect(currentBasePrice({ data: [price('a', null, 'p1')], included: [point('p1', '4.99')] })).toBe('4.99'); + }); + test('zero is Free', () => { + expect(currentBasePrice({ data: [price('a', null, 'p1')], included: [point('p1', '0.0')] })).toBe('Free'); + }); + test('latest started price wins, future ones are ignored', () => { + const page = { data: [price('a', null, 'p1'), price('b', '2026-01-01', 'p2'), price('c', '2099-01-01', 'p3')], included: [point('p1', '0'), point('p2', '1.99'), point('p3', '9.99')] }; + expect(currentBasePrice(page, '2026-06-01')).toBe('1.99'); + }); + test('unreadable gives null', () => { + expect(currentBasePrice(null)).toBeNull(); + expect(currentBasePrice({ data: [price('a', null, 'zz')], included: [] })).toBeNull(); + }); +}); + +describe('availability', () => { + const ta = (t: string, available: boolean) => ({ attributes: { available }, relationships: { territory: { data: { id: t } } } }); + test('availableTerritories lists only available ones across pages', () => { + expect(availableTerritories([{ data: [ta('AAA', true), ta('BBB', false)] }, { data: [ta('CCC', true)] }])).toEqual(['AAA', 'CCC']); + }); + test('same set and flag: no difference', () => { + expect(planAvailability({ availableInNewTerritories: true, available: ['A', 'B'] }, ['A', 'B']).differs).toBe(false); + }); + test('missing territories are additions', () => { + const p = planAvailability({ availableInNewTerritories: true, available: ['A'] }, ['A', 'B', 'C']); + expect(p).toMatchObject({ add: ['B', 'C'], remove: [], differs: true, from: '1 territories', to: '3 territories' }); + }); + test('extra territories are removals; flag off is a change', () => { + const p = planAvailability({ availableInNewTerritories: false, available: ['A', 'X'] }, ['A']); + expect(p.remove).toEqual(['X']); + expect(p.newFlagChange).toBe(true); + }); + test('blockedItems', () => { + expect(blockedItems({ priceDiffers: true, removes: true, allowPrice: false, allowAvailability: false })).toEqual(['price', 'availability removal']); + expect(blockedItems({ priceDiffers: true, removes: true, allowPrice: true, allowAvailability: true })).toEqual([]); + expect(blockedItems({ priceDiffers: false, removes: false, allowPrice: false, allowAvailability: false })).toEqual([]); + }); +}); + +describe('reviewDetailDiff', () => { + const want = { contactPhone: '+15550001111', contactEmail: 'someone@example.com', demoAccountRequired: true, demoAccountName: 'demo-user', demoAccountPassword: 'hunter2', notes: 'Hello' }; + test('lists only changed keys and masks secrets', () => { + const lines = reviewDetailDiff({ ...want, demoAccountPassword: 'old-secret', notes: 'Hi' }, want); + expect(lines).toHaveLength(2); + expect(lines.join('\n')).not.toContain('hunter2'); + expect(lines.join('\n')).not.toContain('old-secret'); + expect(lines[0]).toBe('demoAccountPassword: ******** -> ********'); + expect(lines[1]).toBe('notes: 2 bytes -> 5 bytes'); + }); + test('nothing changed gives no lines', () => { + expect(reviewDetailDiff({ ...want }, want)).toEqual([]); + }); + test('new detail lists everything, phone and email masked', () => { + const lines = reviewDetailDiff(null, want); + expect(lines).toHaveLength(6); + expect(lines.join('\n')).not.toContain('5550001111'); + expect(lines.join('\n')).not.toContain('someone@example.com'); + }); +}); From e381bc2256f700346fa1fb28cc5e7a08899a2035 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 20:38:54 +0800 Subject: [PATCH 026/255] Drift on existing items: offer-code and win-back campaigns that differ are named (Apple cannot edit them), in-app event attributes patched on drafts, a removed custom page deep link cleared, config languages without a version localization named, data safety dry run says Play has no read API; appinfo reads /v1/appPriceSchedules/{id}/manualPrices; a new Play language without an icon warns instead of stopping; fake store lists for the new reads --- cli/apple/appinfo.ts | 2 +- cli/apple/cpp.ts | 8 ++-- cli/apple/events.ts | 11 +++++- cli/apple/offer-codes.ts | 28 +++++++++++++- cli/apple/screenshots.ts | 7 ++++ cli/apple/win-back.ts | 30 ++++++++++++++- cli/lib/cpp-events.ts | 38 ++++++++++++++++++ cli/play/data-safety.ts | 5 +++ cli/play/listing.ts | 2 +- tests/helpers/fake-stores.ts | 11 ++++++ tests/misc-drift.test.ts | 74 ++++++++++++++++++++++++++++++++++++ 11 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 tests/misc-drift.test.ts diff --git a/cli/apple/appinfo.ts b/cli/apple/appinfo.ts index d99b83b..9dc2360 100644 --- a/cli/apple/appinfo.ts +++ b/cli/apple/appinfo.ts @@ -98,7 +98,7 @@ export async function planAppInfo(ctx: Ctx, c: Awaited { if (!page.version || !CPP_EDITABLE.has(page.version.state)) { log(TAG, `"${d.name}": version ${page.version?.state ?? 'missing'}, not editable (a new version: App Store Connect)`); continue; } const have = (await c.all(`/v1/appCustomProductPageVersions/${page.version.id}/appCustomProductPageLocalizations?limit=200`)).map((l: J) => ({ id: l.id, locale: l.attributes.locale, promotionalText: l.attributes.promotionalText })); reqs.push(...planCppLocales(page.version.id, have, locs)); - if ((d.deepLink ?? null) !== (page.version.deepLink ?? null) && d.deepLink) reqs.push({ method: 'PATCH', path: `/v1/appCustomProductPageVersions/${page.version.id}`, note: `"${d.name}" deep link`, - body: { data: { type: 'appCustomProductPageVersions', id: page.version.id, attributes: { deepLink: d.deepLink } } } }); + const extra = extraCppLocales(have.map(h => h.locale), locs.map(l => l.locale)); + if (extra.length) warn(TAG, `"${d.name}": locale(s) ${extra.join(', ')} are on the page but not in the config languages (not removed)`); + const dl = cppDeepLinkReq(page.version.id, d.name, d.deepLink, page.version.deepLink); + if (dl) reqs.push(dl); } if (pages.length + reqs.filter(r => r.path === '/v1/appCustomProductPages').length > CPP_LIMITS.pages) throw new Error(`more than ${CPP_LIMITS.pages} custom product pages`); if (a.has('json')) printJson({ dryRun: dry, requests: reqs.map(r => describeReq(r)) }); diff --git a/cli/apple/events.ts b/cli/apple/events.ts index 6bde356..5691256 100644 --- a/cli/apple/events.ts +++ b/cli/apple/events.ts @@ -9,7 +9,7 @@ import { existsSync, readFileSync } from 'node:fs'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; -import { type AppEventDef, checkEventMedia, EVENT_EDITABLE, EVENT_LIMITS, eventCreateBody, planEventLocales, reviewItemReq } from '../lib/cpp-events'; +import { type AppEventDef, checkEventMedia, EVENT_EDITABLE, EVENT_LIMITS, eventAttrDiff, eventCreateBody, eventPatchReq, planEventLocales, reviewItemReq } from '../lib/cpp-events'; import { done, log, printJson, warn } from '../lib/log'; import { textTable } from '../lib/table'; import { imageInfo } from '../shots/spec'; @@ -59,7 +59,14 @@ export async function run(ctx: Ctx): Promise { const primary = localeOf(d.primaryLanguage ?? '') ?? want[0]?.locale ?? 'en-US'; const ev = events.find((e: J) => e.attributes.referenceName === d.referenceName); if (!ev) { created.push({ def: d, index: reqs.length }); reqs.push({ method: 'POST', path: '/v1/appEvents', note: `event "${d.referenceName}" (${d.badge}, ${d.schedule.eventStart} to ${d.schedule.eventEnd})`, body: eventCreateBody(appId, d, primary, d.schedule.territories ?? territories) }); continue; } - if (!EVENT_EDITABLE.has(ev.attributes.eventState)) { log(TAG, `"${d.referenceName}": ${ev.attributes.eventState}, not editable`); continue; } + const drift = eventAttrDiff(d, ev.attributes); + if (!EVENT_EDITABLE.has(ev.attributes.eventState)) { + if (drift.lines.length) warn(TAG, `"${d.referenceName}" is ${ev.attributes.eventState} and differs from the config: ${drift.lines.join('; ')} (not editable: change it in App Store Connect)`); + else log(TAG, `"${d.referenceName}": ${ev.attributes.eventState}, not editable`); + continue; + } + const patch = eventPatchReq(ev.id, d.referenceName, drift.attrs, drift.lines); + if (patch) reqs.push(patch); const have = (await c.all(`/v1/appEvents/${ev.id}/localizations?limit=200`)).map((l: J) => ({ id: l.id, ...l.attributes })); reqs.push(...planEventLocales(ev.id, have, want)); } diff --git a/cli/apple/offer-codes.ts b/cli/apple/offer-codes.ts index ce300f3..30ccc4d 100644 --- a/cli/apple/offer-codes.ts +++ b/cli/apple/offer-codes.ts @@ -21,6 +21,26 @@ import { findOrCreate, getById } from '../lib/upsert'; const TAG = 'offer-codes'; +/** Differences between an existing offer code campaign and the config (Apple does not allow editing a campaign). Pure. */ +export function campaignDiff(cur: { eligibility: string[]; prices: { territory: string; pointId?: string }[] }, + want: { eligibility: string[]; free: boolean; prices: { territory: string; pointId?: string }[] }): string[] { + const out: string[] = []; + const set = (x: string[]) => [...new Set(x)].sort(); + const a = set(cur.eligibility), b = set(want.eligibility); + if (a.join() !== b.join()) out.push(`eligibility ${a.join('+') || '(none)'} -> ${b.join('+') || '(none)'}`); + const curFree = cur.prices.length > 0 && cur.prices.every(x => !x.pointId); + if (cur.prices.length && curFree !== want.free) out.push(`mode ${curFree ? 'free' : 'paid'} -> ${want.free ? 'free' : 'paid'}`); + const ct = set(cur.prices.map(x => x.territory)), wt = set(want.prices.map(x => x.territory)); + const gone = ct.filter(t => !wt.includes(t)), added = wt.filter(t => !ct.includes(t)); + if (gone.length || added.length) out.push(`territories ${added.length ? `+${added.length} (${added.slice(0, 5).join(',')}${added.length > 5 ? ',...' : ''})` : ''}${gone.length ? ` -${gone.length} (${gone.slice(0, 5).join(',')}${gone.length > 5 ? ',...' : ''})` : ''}`.replace('territories ', 'territories ')); + if (!want.free && !curFree) { + const have = new Map(cur.prices.map(x => [x.territory, x.pointId] as const)); + const diff = want.prices.filter(x => have.has(x.territory) && have.get(x.territory) !== x.pointId).map(x => x.territory); + if (diff.length) out.push(`prices differ in ${diff.length} territor${diff.length === 1 ? 'y' : 'ies'} (${diff.slice(0, 5).join(',')}${diff.length > 5 ? ',...' : ''})`); + } + return out; +} + export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; @@ -71,7 +91,13 @@ export async function run(ctx: Ctx): Promise { log(TAG, `${describeReq(req)}: ${made.created ? 'done' : 'exists already'}`); if (made.created) active++; } - } else log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`); + } else { + log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`); + const curPrices = (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/prices?include=territory,pricePoint&limit=200`)) + .map((x: J) => ({ territory: x.relationships?.territory?.data?.id as string, pointId: x.relationships?.pricePoint?.data?.id as string | undefined })).filter((x: { territory?: string }) => !!x.territory); + const diffs = campaignDiff({ eligibility: cur.attributes.customerEligibilities ?? [], prices: curPrices }, { eligibility: oc.eligibility, free: !!oc.free, prices: await prices(oc) }); + if (diffs.length) warn(TAG, `${p.id}: campaign ${oc.name} differs from the config: ${diffs.join('; ')} (Apple does not allow editing: create a new campaign with another name)`); + } const haveCustom = new Set(cur ? (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode) : []); for (const cc of oc.customCodes ?? []) if (!haveCustom.has(cc.code)) codeReqs.push({ req: iapCustomCodeReq(id ?? '${new campaign}', cc), product: p.id, campaign: oc.name, oneTime: false }); // one-time batches are not matched one by one: a batch is created when the campaign has fewer batches than configured diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index afcc0d5..fbb353f 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -103,6 +103,9 @@ export async function run(ctx: Ctx): Promise { const langOf = byAppleLocale(config.languages); const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`); + const noLoc = missingVersionLocales(config.languages, locs.map((l: J) => l.attributes.locale as string)).filter(l => !only.length || only.includes(l)); + for (const l of noLoc) warn('apple', `${l}: in the config languages but the version has no localization (run apple listing first)`); + const syncSet = (locId: string, sets: J[], type: string, files: string[], tag: string) => syncScreenshotSet(c, { rel: 'appStoreVersionLocalization', type: 'appStoreVersionLocalizations', id: locId }, sets, type, files, tag, ctx.args.has('dry')); @@ -168,3 +171,7 @@ async function repairStuck(ctx: Ctx, c: Asc, root: string, locs: J[], src: strin if (held) log('apple', `${held} screenshot(s) still processing (under 5 min): left alone; run validate again in a few minutes`); done('apple', total ? `${dry ? 'dry run: would replace' : 'replaced'} ${total} stuck screenshot(s)` : 'no stuck screenshots'); } + +/** App Store locales of the config languages that the version has no localization for. Pure. */ +export const missingVersionLocales = (langs: { apple?: string }[], have: string[]): string[] => + [...new Set(langs.flatMap(l => (l.apple ? [l.apple] : [])))].filter(l => !have.includes(l)); diff --git a/cli/apple/win-back.ts b/cli/apple/win-back.ts index 9631dda..2ef5836 100644 --- a/cli/apple/win-back.ts +++ b/cli/apple/win-back.ts @@ -8,8 +8,8 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; -import { type TerritoryPoint, validateWinBack, winBackBody } from '../lib/offers'; -import { allSubscriptions, applePoints, pickPoint } from '../lib/subscriptions'; +import { type TerritoryPoint, validateWinBack, type WinBackDef, winBackBody } from '../lib/offers'; +import { allSubscriptions, APPLE_OFFER_MODE, applePoints, pickPoint } from '../lib/subscriptions'; import { textTable } from '../lib/table'; import { pages } from '../prices'; import { ascClient, type J } from './asc'; @@ -17,6 +17,17 @@ import { describeReq, execute, type Req } from './jsonapi'; const TAG = 'win-back'; +/** Differences between an existing win-back offer and the config (mode, price, dates). No PATCH exists for them. `usaPrice` = the offer's USA customer price (undefined: not read, not compared). Pure. */ +export function winBackDiff(have: { offerMode?: string; startDate?: string | null; endDate?: string | null }, usaPrice: number | undefined, want: WinBackDef): string[] { + const out: string[] = []; + const mode = APPLE_OFFER_MODE[want.type]; + if (have.offerMode && have.offerMode !== mode) out.push(`mode ${have.offerMode} -> ${mode}`); + if (want.type !== 'free' && usaPrice !== undefined && want.usd !== undefined && Math.abs(usaPrice - want.usd) > 0.005) out.push(`USA price ${usaPrice} -> ${want.usd}`); + if ((have.startDate ?? '').slice(0, 10) !== want.start) out.push(`start ${have.startDate ?? '(none)'} -> ${want.start}`); + if ((have.endDate ?? '').slice(0, 10) !== (want.end ?? '')) out.push(`end ${have.endDate ?? '(none)'} -> ${want.end ?? '(none)'}`); + return out; +} + export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const a = ctx.args; @@ -39,6 +50,21 @@ export async function run(ctx: Ctx): Promise { if (!remote) { skipped.push({ id: s.id, why: 'not on App Store Connect (storebound apple subscriptions)' }); continue; } const existing = await c.all(`/v1/subscriptions/${remote.id}/winBackOffers?limit=200`); for (const o of existing) rows.push([s.id, o.attributes.offerId, o.attributes.offerMode, o.attributes.startDate ?? '', o.attributes.endDate ?? '', o.attributes.priority ?? '']); + for (const o of s.winBack!) { + const x = existing.find((e: J) => e.attributes.offerId === o.id); + if (!x) continue; + let usaPrice: number | undefined; + if (o.type !== 'free') { + const pr = await pages(c, `/v1/winBackOffers/${x.id}/prices?include=territory,subscriptionPricePoint&limit=200`); + const usa = pr.data.find((r: J) => r.relationships?.territory?.data?.id === 'USA'); + const pid = usa?.relationships?.subscriptionPricePoint?.data?.id; + const inc = pid ? pr.included.find((i: J) => i.id === pid) : undefined; + const cp = Number(inc?.attributes?.customerPrice); + if (Number.isFinite(cp)) usaPrice = cp; + } + const diffs = winBackDiff(x.attributes, usaPrice, o); + if (diffs.length) warn(TAG, `${s.id}: win-back "${o.id}" differs from the config: ${diffs.join('; ')} (Apple does not allow editing: create a new offer with another id)`); + } const missing = s.winBack!.filter(o => !existing.some((x: J) => x.attributes.offerId === o.id)); if (!missing.length) continue; if (remote.attributes.state !== 'APPROVED') { skipped.push({ id: s.id, why: `subscription is ${remote.attributes.state}: win-back offers need an approved subscription` }); continue; } diff --git a/cli/lib/cpp-events.ts b/cli/lib/cpp-events.ts index e85e6ad..630fca9 100644 --- a/cli/lib/cpp-events.ts +++ b/cli/lib/cpp-events.ts @@ -200,3 +200,41 @@ export const reviewItemReq = (submissionId: string, kind: 'appCustomProductPageV method: 'POST', path: '/v1/reviewSubmissionItems', note: label, body: { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', submissionId), [kind]: one(kind === 'appEvent' ? 'appEvents' : 'appCustomProductPageVersions', id) } } }, }); + +/** Event attributes that differ from the config: `attrs` = the PATCH attributes (empty when nothing differs), `lines` = readable. Pure. */ +export function eventAttrDiff(ev: AppEventDef, now: { badge?: string; priority?: string; purpose?: string; deepLink?: string | null; territorySchedules?: { territories?: string[]; publishStart?: string; eventStart?: string; eventEnd?: string }[] }): { attrs: Record; lines: string[] } { + const attrs: Record = {}; + const lines: string[] = []; + const same = (a?: string, b?: string) => (a && b ? Date.parse(a) === Date.parse(b) : a === b); + if (now.badge !== undefined && now.badge !== ev.badge) { attrs.badge = ev.badge; lines.push(`badge ${now.badge} -> ${ev.badge}`); } + const pri = (ev.priority ?? 'normal').toUpperCase(); + if (now.priority !== undefined && now.priority !== pri) { attrs.priority = pri; lines.push(`priority ${now.priority} -> ${pri}`); } + const purpose = ev.purpose ?? 'APPROPRIATE_FOR_ALL_USERS'; + if (now.purpose !== undefined && now.purpose !== purpose) { attrs.purpose = purpose; lines.push(`purpose ${now.purpose} -> ${purpose}`); } + const s = now.territorySchedules?.[0]; + const sch: string[] = []; + if (!same(s?.publishStart, ev.schedule.publishStart)) sch.push(`publishStart ${s?.publishStart ?? '(none)'} -> ${ev.schedule.publishStart}`); + if (!same(s?.eventStart, ev.schedule.eventStart)) sch.push(`eventStart ${s?.eventStart ?? '(none)'} -> ${ev.schedule.eventStart}`); + if (!same(s?.eventEnd, ev.schedule.eventEnd)) sch.push(`eventEnd ${s?.eventEnd ?? '(none)'} -> ${ev.schedule.eventEnd}`); + const wt = ev.schedule.territories; + if (wt && [...new Set(s?.territories ?? [])].sort().join() !== [...new Set(wt)].sort().join()) sch.push(`territories ${(s?.territories ?? []).length} -> ${wt.length}`); + if (sch.length) { + attrs.territorySchedules = [{ territories: wt ?? s?.territories ?? [], publishStart: ev.schedule.publishStart, eventStart: ev.schedule.eventStart, eventEnd: ev.schedule.eventEnd }]; + lines.push(...sch); + } + return { attrs, lines }; +} + +/** PATCH /v1/appEvents/{id} with the changed attributes, null when none. */ +export const eventPatchReq = (eventId: string, name: string, attrs: Record, lines: string[]): Req | null => + Object.keys(attrs).length ? { method: 'PATCH', path: `/v1/appEvents/${eventId}`, note: `event "${name}": ${lines.join('; ')}`, body: { data: { type: 'appEvents', id: eventId, attributes: attrs } } } : null; + +/** PATCH of a page version's deep link: set, change, or clear (config has none, the page has one). Null when equal. Pure. */ +export function cppDeepLinkReq(versionId: string, name: string, want: string | undefined, have: string | null | undefined): Req | null { + if ((want ?? null) === (have ?? null)) return null; + return { method: 'PATCH', path: `/v1/appCustomProductPageVersions/${versionId}`, note: want ? `"${name}" deep link` : `"${name}" deep link removed`, + body: { data: { type: 'appCustomProductPageVersions', id: versionId, attributes: { deepLink: want ?? null } } } }; +} + +/** Locales on a page version that the config has no language for. Pure. */ +export const extraCppLocales = (have: string[], want: string[]): string[] => have.filter(l => !want.includes(l)); diff --git a/cli/play/data-safety.ts b/cli/play/data-safety.ts index 7610e43..c95eef3 100644 --- a/cli/play/data-safety.ts +++ b/cli/play/data-safety.ts @@ -50,6 +50,10 @@ export function changeLines(changes: DataSafetyChange[]): string[] { return [...(acc.length ? [' account and deletion answers:', ...acc.map(line)] : []), ...(rest.length ? [' data types and other answers:', ...rest.map(line)] : [])]; } +/** The dry-run line about the state it compares with. Pure. */ +export const dataSafetyDryNote = (last: { at: string } | null): string => + `Play has no read API for the Data safety form: this compares with the last upload from this project (${DATA_SAFETY_HASH} sha256${last ? `, uploaded ${last.at.slice(0, 16).replace('T', ' ')} UTC` : ', none recorded'}), not with the live form; --force re-sends the CSV`; + export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const ds = need(config.dataSafety, 'dataSafety'); @@ -69,6 +73,7 @@ export async function run(ctx: Ctx): Promise { if (ctx.args.has('dry')) { const upFile = join(root, DATA_SAFETY_UPLOADED); const before = existsSync(upFile) ? readFileSync(upFile, 'utf8') : null; + log('play', dataSafetyDryNote(last)); const changes = dataSafetyDiff(before, rows); log('play', `changes against ${before ? `the last uploaded CSV (${DATA_SAFETY_UPLOADED}; Play cannot read the form back)` : 'nothing (no upload recorded: every answer is new)'}: ${changes.length} answer(s)`); for (const l of changeLines(changes)) console.log(l); diff --git a/cli/play/listing.ts b/cli/play/listing.ts index fc0ded4..f0dce56 100644 --- a/cli/play/listing.ts +++ b/cli/play/listing.ts @@ -171,7 +171,7 @@ export async function run(ctx: Ctx): Promise { // a language Play does not have yet: its edit only commits with a hi-res icon (pitfall 102) if (!now.some(n => n.language === b.language)) { const graphics = firstGraphics(ctx, b.language!); - if (!graphics.some(([t]) => t === 'icon')) throw new Error(`${b.language}: a new Play language needs the 512x512 icon in the same edit (play.icon or store/icons/play-512.png): storebound icons makes it`); + if (!graphics.some(([t]) => t === 'icon')) warn('play', `${b.language}: no 512x512 icon to send with this new language (play.icon or store/icons/play-512.png; storebound icons makes it): Play may refuse the commit with 403 "no high resolution icon"`); for (const [type, f] of graphics) { await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); log('play', `${b.language}: ${type} uploaded with the new listing`); diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index 0a1e00d..756ebfc 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -23,6 +23,12 @@ const LISTS: Record = { 'inAppPurchases/offerCodes': { type: 'inAppPurchaseOfferCodes' }, 'inAppPurchaseOfferCodes/customCodes': { type: 'inAppPurchaseOfferCodeCustomCodes' }, 'inAppPurchaseOfferCodes/oneTimeUseCodes': { type: 'inAppPurchaseOfferCodeOneTimeUseCodes' }, + 'inAppPurchaseOfferCodes/prices': { type: 'inAppPurchaseOfferPrices' }, + 'inAppPurchasePriceSchedules/manualPrices': { type: 'inAppPurchasePrices' }, + 'inAppPurchasePriceSchedules/baseTerritory': { type: 'territories', one: true }, + 'appPriceSchedules/manualPrices': { type: 'appPrices' }, + 'inAppPurchaseAvailabilities/availableTerritories': { type: 'territories' }, + 'winBackOffers/prices': { type: 'winBackOfferPrices' }, 'apps/appStoreVersions': { type: 'appStoreVersions' }, 'appStoreVersions/appStoreVersionLocalizations': { type: 'appStoreVersionLocalizations' }, 'appStoreVersions/appStoreReviewDetail': { type: 'appStoreReviewDetails', one: true }, @@ -259,6 +265,11 @@ export class FakeAsc { const self = this.res.get(id); if (!rel) return self && self.type === type ? json({ data: this.view(self) }) : json({ errors: [{ status: '404' }] }, 404); if (rel === 'relationships') return json({ data: this.children(id, rel2!).map(r => ({ type: r.type, id: r.id })) }); + // an availability's territories are the ids its create named (a to-many relationship, not child resources) + if (rel === 'availableTerritories' && self) { + const ids = self.rels.availableTerritories; + return json({ data: (Array.isArray(ids) ? ids : ids ? [ids] : []).map(t => ({ type: 'territories', id: t })), links: {} }); + } const l = LISTS[`${type}/${rel}`]; if (!l) return json({ errors: [{ status: '599', detail: `fake: no list ${type}/${rel}` }] }, 599); const kids = this.filtered(this.children(id, l.type), u.searchParams); diff --git a/tests/misc-drift.test.ts b/tests/misc-drift.test.ts new file mode 100644 index 0000000..82cfe30 --- /dev/null +++ b/tests/misc-drift.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test'; +import { campaignDiff } from '../cli/apple/offer-codes'; +import { winBackDiff } from '../cli/apple/win-back'; +import { cppDeepLinkReq, type AppEventDef, eventAttrDiff, eventPatchReq, extraCppLocales } from '../cli/lib/cpp-events'; +import { missingVersionLocales } from '../cli/apple/screenshots'; +import { dataSafetyDryNote } from '../cli/play/data-safety'; + +describe('offer code campaign drift', () => { + const cur = { eligibility: ['NON_SPENDER'], prices: [{ territory: 'USA', pointId: 'p1' }, { territory: 'DEU', pointId: 'p2' }] }; + test('equal: no differences', () => { + expect(campaignDiff(cur, { eligibility: ['NON_SPENDER'], free: false, prices: cur.prices })).toEqual([]); + }); + test('eligibility, territories, price and mode differ', () => { + const d = campaignDiff(cur, { eligibility: ['NON_SPENDER', 'CHURNED_SPENDER'], free: false, prices: [{ territory: 'USA', pointId: 'p9' }, { territory: 'FRA', pointId: 'p3' }] }); + expect(d.join('|')).toContain('eligibility'); + expect(d.join('|')).toContain('+1 (FRA)'); + expect(d.join('|')).toContain('-1 (DEU)'); + expect(d.join('|')).toContain('prices differ in 1 territory'); + expect(campaignDiff(cur, { eligibility: ['NON_SPENDER'], free: true, prices: cur.prices }).join()).toContain('mode paid -> free'); + }); +}); + +describe('win-back drift', () => { + const want = { id: 'wb', type: 'pay-as-you-go', usd: 2.99, paidMonths: 3, lapsedMonths: { min: 1, max: 12 }, start: '2027-01-01', end: '2027-02-01' } as const; + test('equal', () => expect(winBackDiff({ offerMode: 'PAY_AS_YOU_GO', startDate: '2027-01-01', endDate: '2027-02-01' }, 2.99, want)).toEqual([])); + test('mode, price, dates', () => { + const d = winBackDiff({ offerMode: 'FREE_TRIAL', startDate: '2027-01-05', endDate: null }, 1.99, want); + expect(d.length).toBe(4); + expect(winBackDiff({ offerMode: 'PAY_AS_YOU_GO', startDate: '2027-01-01', endDate: '2027-02-01' }, undefined, want)).toEqual([]); + }); +}); + +describe('event attribute drift', () => { + const ev: AppEventDef = { referenceName: 'Spring', badge: 'CHALLENGE', priority: 'high', schedule: { publishStart: '2027-03-01T00:00:00Z', eventStart: '2027-03-05T00:00:00Z', eventEnd: '2027-03-10T00:00:00Z' }, listings: {} }; + const now = { badge: 'CHALLENGE', priority: 'HIGH', purpose: 'APPROPRIATE_FOR_ALL_USERS', territorySchedules: [{ territories: ['USA'], publishStart: '2027-03-01T00:00:00.000Z', eventStart: '2027-03-05T00:00:00.000Z', eventEnd: '2027-03-10T00:00:00.000Z' }] }; + test('same instants in another format: no drift', () => { + const d = eventAttrDiff(ev, now); + expect(d.lines).toEqual([]); + expect(eventPatchReq('e1', 'Spring', d.attrs, d.lines)).toBeNull(); + }); + test('badge, priority and end differ: one PATCH', () => { + const d = eventAttrDiff({ ...ev, badge: 'MAJOR_UPDATE', priority: 'normal', schedule: { ...ev.schedule, eventEnd: '2027-03-12T00:00:00Z' } }, now); + expect(d.lines.length).toBe(3); + const r = eventPatchReq('e1', 'Spring', d.attrs, d.lines)!; + expect(r.method).toBe('PATCH'); + expect(r.path).toBe('/v1/appEvents/e1'); + expect((r.body as { data: { attributes: Record } }).data.attributes.territorySchedules).toHaveLength(1); + }); +}); + +describe('custom product page drift', () => { + test('deep link removed is cleared', () => { + const r = cppDeepLinkReq('v1', 'Page', undefined, 'example://old')!; + expect(r.note).toContain('removed'); + expect((r.body as { data: { attributes: { deepLink: unknown } } }).data.attributes.deepLink).toBeNull(); + }); + test('equal or both empty: nothing', () => { + expect(cppDeepLinkReq('v1', 'Page', 'example://a', 'example://a')).toBeNull(); + expect(cppDeepLinkReq('v1', 'Page', undefined, null)).toBeNull(); + }); + test('extra locales on the page', () => expect(extraCppLocales(['en-US', 'fr-FR'], ['en-US'])).toEqual(['fr-FR'])); +}); + +describe('screenshots and data safety notes', () => { + test('version without a localization for a config language', () => { + expect(missingVersionLocales([{ apple: 'en-US' }, { apple: 'tr' }, {}], ['en-US'])).toEqual(['tr']); + }); + test('data safety dry note says Play cannot be read and --force re-sends', () => { + const n = dataSafetyDryNote({ at: '2027-01-01T10:00:00Z' }); + expect(n).toContain('no read API'); + expect(n).toContain('--force'); + expect(dataSafetyDryNote(null)).toContain('none recorded'); + }); +}); From abc195aee60c43eef402833e463437339065b358 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 20:49:43 +0800 Subject: [PATCH 027/255] Subscriptions drift on both stores (Apple prices in the dry run, localizations, group names, attributes, territories, intro offers reported; Play offer phases and prices, other-regions price, dropped regions gated, grace period, --prune-languages); pitfall 104, CHANGELOG; test fixtures for the new reads --- CHANGELOG.md | 14 +++ cli/apple/subscriptions.ts | 122 +++++++++++++++++-- cli/data/flags.json | 28 +++-- cli/lib/subscriptions.ts | 189 ++++++++++++++++++++++++++++-- cli/play/subscriptions.ts | 97 ++++++++++----- skills/store-pitfalls/SKILL.md | 14 +++ tests/pitfalls.test.ts | 5 +- tests/subscriptions-drift.test.ts | 148 +++++++++++++++++++++++ 8 files changed, 558 insertions(+), 59 deletions(-) create mode 100644 tests/subscriptions-drift.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d222a34..61b6a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +- **Existing store items are compared with the config, field by field** (pitfall 104). The dry run shows every + difference (old -> new); texts are sent, money and availability need a flag, fields Apple cannot edit are named. + - `apple iap`: price (`--allow-price-changes`), localization names and descriptions, reference name, review note, + family sharing (reported), territories. + - `apple subscriptions`: prices in the dry run, localizations, group names, reference name, review note, level, + territories; intro offers, period and family sharing reported. + - `play subscriptions`: offer phases and prices, other-regions price, regions no longer sold (all price-gated), + grace period, languages missing from the config kept unless `--prune-languages`. + - `apple appinfo`: app price (`--allow-price-changes`) and territories (removals `--allow-availability-changes`); + `apple review-details --dry` shows old -> new. + - Offer-code and win-back campaigns that differ are named; in-app event attributes patched on drafts; a removed + custom page deep link cleared; config languages without a version localization named. +- `prices diff` (the `catalog diff` report). + - Windows: no false "entitlements not referenced by CODE_SIGN_ENTITLEMENTS" warning (paths compared with forward slashes). Pitfall 103. - **`storebound apps relink`** after a machine move: every "folder not found" entry of the app folder list is pointed at diff --git a/cli/apple/subscriptions.ts b/cli/apple/subscriptions.ts index b9250da..d892670 100644 --- a/cli/apple/subscriptions.ts +++ b/cli/apple/subscriptions.ts @@ -20,10 +20,11 @@ import { type OfferCodeDef, sellsOn, type Subscription, type SubscriptionGroup } import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, step, warn } from '../lib/log'; -import { allowPriceChanges, type HeldPrice, holdPriceChanges, withoutYes } from '../lib/price-gate'; +import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges, withoutYes } from '../lib/price-gate'; import { parsePriceCsv, type PriceRow } from '../lib/prices'; import { - allSubscriptions, appleAvailabilityBody, appleCustomCodeBody, appleOfferCodeBody, applePromoOfferBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, applePoints, applePriceChanges, + allSubscriptions, appleAvailabilityBody, appleAvailabilityDiff, appleGroupLocDiff, appleGroupLocPatchBody, appleIntroDiff, appleIntroOffers, appleLocDiff, appleLocPatchBody, + type AppleAttrDiff, appleSubAttrDiff, appleSubPatchBody, appleCustomCodeBody, appleOfferCodeBody, applePromoOfferBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, applePoints, applePriceChanges, applePricesPatch, appleSubLocBody, appleSubPrices, appleSubscriptionBody, appleSubscriptionPlan, type AppleSubState, appleWantedPrices, type ApplePoint, pickPoint, } from '../lib/subscriptions'; @@ -69,6 +70,75 @@ async function readState(c: Asc, appId: string): Promise { const heldOf = (id: string, changes: { territory: string; from: number | null; to: number; startDate?: string | null }[]): HeldPrice => ({ id, changes: changes.map(ch => ({ region: ch.territory, from: ch.from === null ? '-' : String(ch.from), to: `${ch.to}${ch.startDate ? ` from ${ch.startDate}` : ''}` })) }); +/** Everything an existing subscription has that differs from the config (pure data; the run prints and applies it). */ +export interface SubDrift { + attrs: AppleAttrDiff; + locs: { id: string; loc: string; patch: Record }[]; + availability: { missing: string[]; newTerritories: boolean } | null; + intro: string[]; +} + +/** Reads the live subscription (attributes, localizations, availability, introductory offers) and diffs it with the config. Read-only. */ +export async function readSubDrift(c: Asc, s: Subscription, id: string, locale: Map, territories: string[]): Promise { + const live = (await c.maybe(`/v1/subscriptions/${id}`))?.data?.attributes ?? {}; + const attrs = appleSubAttrDiff(s, { name: live.name, subscriptionPeriod: live.subscriptionPeriod, familySharable: live.familySharable, reviewNote: live.reviewNote, groupLevel: live.groupLevel }); + const locs: SubDrift['locs'] = []; + const haveLocs = await c.all(`/v1/subscriptions/${id}/subscriptionLocalizations?limit=200`); + for (const [lang, l] of Object.entries(s.listings)) { + const loc = locale.get(lang); + const h = loc ? haveLocs.find((x: J) => x.attributes.locale === loc) : undefined; + const patch = h ? appleLocDiff(l, { name: h.attributes.name, description: h.attributes.description }) : null; + if (h && patch && loc) locs.push({ id: h.id, loc, patch }); + } + let availability: SubDrift['availability'] = null; + const av = (await c.maybe(`/v1/subscriptions/${id}/subscriptionAvailability`))?.data; + if (av?.id) { + let haveT: string[] | null = null; + try { haveT = (await c.all(`/v1/subscriptionAvailabilities/${av.id}/availableTerritories?limit=200`)).map((t: J) => t.id as string); } catch { /* unreadable: left alone */ } + if (haveT) availability = appleAvailabilityDiff({ territories: haveT, availableInNewTerritories: av.attributes?.availableInNewTerritories }, territories); + } + const hasIntro = !!(await c.asc('GET', `/v1/subscriptions/${id}/introductoryOffers?limit=1`)).data?.length; + const intro = hasIntro ? appleIntroDiff(s, appleIntroOffers(await pages(c, `/v1/subscriptions/${id}/introductoryOffers?include=subscriptionPricePoint,territory&limit=200`))) : []; + return { attrs, locs, availability, intro }; +} + +/** Group localizations whose name / customAppName differ from the config. Read-only. */ +export async function readGroupLocDrift(c: Asc, g: SubscriptionGroup, groupId: string, locale: Map): Promise<{ id: string; loc: string; patch: Record }[]> { + const have = await c.all(`/v1/subscriptionGroups/${groupId}/subscriptionGroupLocalizations?limit=200`); + const out: { id: string; loc: string; patch: Record }[] = []; + for (const [lang, l] of Object.entries(g.listings)) { + const loc = locale.get(lang); + const h = loc ? have.find((x: J) => x.attributes.locale === loc) : undefined; + const patch = h ? appleGroupLocDiff(l, { name: h.attributes.name, customAppName: h.attributes.customAppName }) : null; + if (h && patch && loc) out.push({ id: h.id, loc, patch }); + } + return out; +} + +/** The lines the dry run prints for one subscription's drift. Pure. */ +export function driftLines(id: string, d: SubDrift): string[] { + const av = d.availability; + return [ + ...d.attrs.text.map(x => `${id}: ${x}`), ...d.attrs.report, + ...d.locs.map(l => `${id} ${l.loc}: ${Object.keys(l.patch).join(' + ')} differ from the config`), + ...(av ? [`${id}: availability${av.missing.length ? ` ${av.missing.length} territories missing (${av.missing.slice(0, 5).join(', ')})` : ''}${av.newTerritories ? ' new territories off' : ''}`] : []), + ...d.intro, + ]; +} + +/** Today's prices vs the config for one existing subscription (read-only). */ +async function priceState(c: Asc, s: Subscription, id: string, cur: { hasPrices: boolean } | undefined, csv: PriceRow[]) { + const usaPoints = applePoints(await pages(c, `/v1/subscriptions/${id}/pricePoints?filter[territory]=USA&include=territory&limit=200`)); + const usa = pickPoint(usaPoints, 'USA', s.usd); + const equalized = applePoints(await pages(c, `/v1/subscriptionPricePoints/${usa.id}/equalizations?include=territory&limit=200`)); + const known: ApplePoint[] = [usa, ...equalized]; + // a subscription the first read missed (list lag) is read live, so nothing is sent twice + const hasPrices = cur ? cur.hasPrices : !!(await c.asc('GET', `/v1/subscriptions/${id}/prices?limit=1`)).data?.length; + const current = hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : []; + const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id)); + return { usaPoints, known, current, changes }; +} + export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const only = ctx.args.list('only'); @@ -83,7 +153,29 @@ export async function run(ctx: Ctx): Promise { const state = await readState(c, appId); const plan = appleSubscriptionPlan(groups, config.languages, state); for (const [i, s] of plan.entries()) log(TAG, `${step(i + 1, plan.length, s.what)}`); - if (dry) { done(TAG, `subscriptions planned (dry run, nothing written; prices are compared per territory on a real run${csv.length ? `, ${csv.length} CSV rows` : ''})`); return; } + if (dry) { + // read-only comparison of everything that exists: text, attributes, availability, intro offers and prices (old -> new) + const locale = new Map(config.languages.map(l => [l.id, l.apple] as const)); + const territories: string[] = (await c.all('/v1/territories?limit=200')).map((t: J) => t.id); + const heldDry: HeldPrice[] = []; + for (const g of groups) { + const sg = state.groups.find(x => x.referenceName === g.name); + if (sg) for (const d of await readGroupLocDrift(c, g, sg.id, locale)) log(TAG, `group "${g.name}" ${d.loc}: ${Object.keys(d.patch).join(' + ')} differ from the config`); + for (const s of g.subscriptions) { + const cur = state.subscriptions.find(x => x.productId === s.id); + if (!cur) continue; + for (const l of driftLines(s.id, await readSubDrift(c, s, cur.id, locale, territories))) log(TAG, l); + try { + const { changes } = await priceState(c, s, cur.id, cur, csv); + if (changes.length) { log(TAG, `${s.id}: ${changes.length} price change(s)`); heldDry.push(heldOf(s.id, changes)); } + } catch (e) { warn(TAG, `${s.id}: prices not compared (${String((e as Error).message ?? e).slice(0, 160)})`); } + } + } + const h = heldSection(heldDry, ctx.args.has('allow-price-changes')); + if (h) console.log(h); + done(TAG, `subscriptions planned (dry run, nothing written${csv.length ? `, ${csv.length} CSV rows` : ''})`); + return; + } const yes = ctx.args.has('yes'); // price changes of existing subscriptions: only with --allow-price-changes, their own question per subscription @@ -115,6 +207,12 @@ export async function run(ctx: Ctx): Promise { create: async () => (await c.asc('POST', '/v1/subscriptionGroupLocalizations', appleGroupLocBody(groupId, loc, l))).data }); log(TAG, ` group ${loc}`); } + if (state.groups.some(x => x.id === groupId)) { + for (const d of await readGroupLocDrift(c, g, groupId, locale)) { + await c.asc('PATCH', `/v1/subscriptionGroupLocalizations/${d.id}`, appleGroupLocPatchBody(d.id, d.patch)); + log(TAG, ` group ${d.loc}: ${Object.keys(d.patch).join(' + ')} updated`); + } + } for (const [i, s] of g.subscriptions.entries()) { const tag = step(i + 1, g.subscriptions.length, s.id); const cur = state.subscriptions.find(x => x.productId === s.id); @@ -139,16 +237,18 @@ export async function run(ctx: Ctx): Promise { log(TAG, ` ${loc}`); } if (!cur?.availability && !(await c.maybe(`/v1/subscriptions/${id}/subscriptionAvailability`))?.data) { await c.asc('POST', '/v1/subscriptionAvailabilities', appleAvailabilityBody(id, territories)); log(TAG, ` available in ${territories.length} territories + new ones`); } + if (cur) { + // what exists but differs from the config: text and attributes are patched, availability is set again, intro offers + // and the period / family sharing are only reported (Apple cannot change them) + const d = await readSubDrift(c, s, subId, locale, territories); + if (Object.keys(d.attrs.patch).length) { await c.asc('PATCH', `/v1/subscriptions/${subId}`, appleSubPatchBody(subId, d.attrs.patch)); log(TAG, ` attributes updated: ${d.attrs.text.join(', ')}`); } + for (const l of d.locs) { await c.asc('PATCH', `/v1/subscriptionLocalizations/${l.id}`, appleLocPatchBody(l.id, l.patch)); log(TAG, ` ${l.loc}: ${Object.keys(l.patch).join(' + ')} updated`); } + if (d.availability) { await c.asc('POST', '/v1/subscriptionAvailabilities', appleAvailabilityBody(subId, territories)); log(TAG, ` availability set again: ${territories.length} territories + new ones`); } + for (const x of [...d.attrs.report, ...d.intro]) warn(TAG, x); + } // prices: USA point -> equalizations -> overrides / CSV; only what differs from today's prices - const usaPoints = applePoints(await pages(c, `/v1/subscriptions/${id}/pricePoints?filter[territory]=USA&include=territory&limit=200`)); - const usa = pickPoint(usaPoints, 'USA', s.usd); - const equalized = applePoints(await pages(c, `/v1/subscriptionPricePoints/${usa.id}/equalizations?include=territory&limit=200`)); - const known: ApplePoint[] = [usa, ...equalized]; - // a subscription the first read missed (list lag) is read live, so nothing is sent twice - const hasPrices = cur ? cur.hasPrices : !!(await c.asc('GET', `/v1/subscriptions/${id}/prices?limit=1`)).data?.length; - const current = hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : []; - const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id)); + const { usaPoints, known, current, changes } = await priceState(c, s, id, cur, csv); if (!changes.length) log(TAG, ' prices up to date'); else if (current.length && !(await allowPriceChanges(ctx, [heldOf(s.id, changes)], `Change the prices of the existing subscription ${s.id} on App Store Connect (${changes.length} territories)? New subscribers pay the new price.`))) { log(TAG, ' owner: price change of an existing subscription (--allow-price-changes)'); diff --git a/cli/data/flags.json b/cli/data/flags.json index fe63096..f581df0 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -61,7 +61,10 @@ "plist", "udid" ], - "apple appinfo": [], + "apple appinfo": [ + "allow-availability-changes", + "allow-price-changes" + ], "apple build": [ "allow-ssh", "allow-stale-web", @@ -403,6 +406,8 @@ "write" ], "check": [ + "allow-availability-changes", + "allow-price-changes", "aso", "capacitor", "cordova", @@ -420,6 +425,8 @@ "unity" ], "console fill": [ + "allow-availability-changes", + "allow-price-changes", "capacitor", "cdp", "chrome-profile", @@ -441,6 +448,8 @@ ], "console plan": [ "all", + "allow-availability-changes", + "allow-price-changes", "capacitor", "cdp", "chrome-profile", @@ -464,6 +473,8 @@ "use-chrome-session" ], "console verify": [ + "allow-availability-changes", + "allow-price-changes", "capacitor", "cordova", "cordova-android", @@ -605,6 +616,8 @@ "threshold" ], "play app-id": [ + "allow-availability-changes", + "allow-price-changes", "capacitor", "cdp", "chrome-profile", @@ -710,6 +723,7 @@ "offline", "only-decreases", "out", + "prune-languages", "reconvert", "source", "store", @@ -780,6 +794,8 @@ "workspace" ], "report": [ + "allow-availability-changes", + "allow-price-changes", "capacitor", "cordova", "cordova-android", @@ -985,6 +1001,7 @@ "agent", "all", "all-pages", + "allow-availability-changes", "allow-existing", "allow-price-changes", "allow-ssh", @@ -1146,6 +1163,7 @@ "project", "propose", "prune", + "prune-languages", "q", "rating", "react-native", @@ -1263,7 +1281,7 @@ "storebound apple adhoc export --archive [--plist build/ExportOptions.plist] [--out build/adhoc] [--dry]" ], "apple appinfo": [ - "storebound apple appinfo [--dry] [--json]" + "storebound apple appinfo [--dry] [--json] [--allow-price-changes] [--allow-availability-changes]" ], "apple build": [ "storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]" @@ -1311,9 +1329,6 @@ "apple next-build": [ "storebound apple next-build -> 42" ], - "apple offer-codes": [ - "storebound apple offer-codes [--only id1,id2] [--codes] [--dry] [--yes] [--json]" - ], "apple ppo": [ "storebound apple ppo status [--json]", "storebound apple ppo create --variant b[,c] [--name \"...\"] [--traffic 30] [--dry]", @@ -1338,9 +1353,6 @@ "apple resubmit": [ "storebound apple resubmit --build [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]" ], - "apple review-details": [ - "storebound apple review-details [--contact-from ] [--dry]" - ], "apple screenshots": [ "storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]", "storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)" diff --git a/cli/lib/subscriptions.ts b/cli/lib/subscriptions.ts index 70ac790..08ffdec 100644 --- a/cli/lib/subscriptions.ts +++ b/cli/lib/subscriptions.ts @@ -297,6 +297,92 @@ export function applePriceChanges(current: { territory: string; price: number; s return out; } +// ---- Apple: what exists vs the config (drift) ---- + +export interface AppleSubAttrs { name?: string; subscriptionPeriod?: string; familySharable?: boolean; reviewNote?: string | null; groupLevel?: number } +/** `patch` = attributes PATCH /v1/subscriptions/{id} can change, `text` = lines for those, `report` = differences Apple cannot change. */ +export interface AppleAttrDiff { patch: Record; text: string[]; report: string[] } + +/** Subscription attributes on App Store Connect vs the config. Name (reference name), review note and group level are + * patched; the period and family sharing cannot change after creation and are only reported. An attribute the read + * did not return is left alone. Pure. */ +export function appleSubAttrDiff(s: Subscription, cur: AppleSubAttrs): AppleAttrDiff { + const out: AppleAttrDiff = { patch: {}, text: [], report: [] }; + const want = APPLE_PERIOD[s.period]; + if (cur.subscriptionPeriod !== undefined && cur.subscriptionPeriod !== want) out.report.push(`${s.id}: period is ${cur.subscriptionPeriod} on App Store Connect, the config says ${want} (a period cannot change; use a new product id)`); + const fam = s.familySharable ?? false; + if (cur.familySharable !== undefined && cur.familySharable !== fam) out.report.push(`${s.id}: familySharable is ${cur.familySharable} on App Store Connect, the config says ${fam} (cannot be changed through the API)`); + const name = s.referenceName ?? Object.values(s.listings)[0]?.title; + if (name !== undefined && cur.name !== undefined && cur.name !== name) { out.patch.name = name; out.text.push(`reference name "${cur.name}" -> "${name}"`); } + if (s.reviewNote !== undefined && cur.reviewNote !== undefined && (cur.reviewNote ?? '') !== s.reviewNote) { out.patch.reviewNote = s.reviewNote; out.text.push('review note changed'); } + if (cur.groupLevel !== undefined && cur.groupLevel !== s.level) { out.patch.groupLevel = s.level; out.text.push(`level ${cur.groupLevel} -> ${s.level}`); } + return out; +} + +export const appleSubPatchBody = (subId: string, attributes: Record) => ({ data: { type: 'subscriptions', id: subId, attributes } }); + +/** Subscription localization text vs the config: the attributes to PATCH, or null when equal (or not returned). Pure. */ +export function appleLocDiff(want: { title: string; description: string }, cur: { name?: string; description?: string }): Record | null { + const patch: Record = {}; + if (cur.name !== undefined && cur.name !== want.title) patch.name = want.title; + if (cur.description !== undefined && cur.description !== want.description) patch.description = want.description; + return Object.keys(patch).length ? patch : null; +} +export const appleLocPatchBody = (locId: string, attributes: Record) => ({ data: { type: 'subscriptionLocalizations', id: locId, attributes } }); + +/** Subscription group localization (name, customAppName when configured) vs the config. Pure. */ +export function appleGroupLocDiff(want: { name: string; customAppName?: string }, cur: { name?: string; customAppName?: string | null }): Record | null { + const patch: Record = {}; + if (cur.name !== undefined && cur.name !== want.name) patch.name = want.name; + if (want.customAppName !== undefined && (cur.customAppName ?? '') !== want.customAppName) patch.customAppName = want.customAppName; + return Object.keys(patch).length ? patch : null; +} +export const appleGroupLocPatchBody = (locId: string, attributes: Record) => ({ data: { type: 'subscriptionGroupLocalizations', id: locId, attributes } }); + +/** Subscription availability vs "every territory + new ones": territories missing, availableInNewTerritories off. null = same. Pure. */ +export function appleAvailabilityDiff(cur: { territories: string[]; availableInNewTerritories?: boolean }, wanted: string[]): { missing: string[]; newTerritories: boolean } | null { + const have = new Set(cur.territories); + const missing = wanted.filter(t => !have.has(t)); + const newTerritories = cur.availableInNewTerritories === false; + return missing.length || newTerritories ? { missing, newTerritories } : null; +} + +export interface AppleIntroOffer { territory: string; mode?: string; duration?: string; periods?: number; price?: number } + +/** GET /v1/subscriptions/{id}/introductoryOffers?include=subscriptionPricePoint,territory -> offers per territory. */ +export function appleIntroOffers(resp: JsonApi): AppleIntroOffer[] { + const points = new Map((resp.included ?? []).filter(x => x.type === 'subscriptionPricePoints').map(x => [x.id, Number(x.attributes?.customerPrice)] as const)); + return (resp.data ?? []).map(o => { + const pid = o.relationships?.subscriptionPricePoint?.data?.id; + const price = pid ? points.get(pid) : undefined; + return { + territory: o.relationships?.territory?.data?.id ?? '', mode: o.attributes?.offerMode as string | undefined, duration: o.attributes?.duration as string | undefined, + periods: o.attributes?.numberOfPeriods as number | undefined, ...(price !== undefined && !Number.isNaN(price) ? { price } : {}), + }; + }); +} + +/** Introductory offers on App Store Connect vs the config (Apple cannot PATCH them: report only), as `intro differs: ...` + * lines. A subscription without any offer yet is created by the run, not a difference. Pure. */ +export function appleIntroDiff(s: Subscription, cur: AppleIntroOffer[]): string[] { + if (!cur.length) return []; + if (!s.intro) return [`intro differs: ${s.id} has an introductory offer on App Store Connect, the config has none`]; + const o = s.intro; + const out: string[] = []; + const distinct = (f: (x: AppleIntroOffer) => T | undefined): T[] => [...new Set(cur.map(f).filter((x): x is T => x !== undefined))]; + const check = (what: string, have: (string | number)[], want: string | number) => { + if (have.some(h => h !== want)) out.push(`intro differs: ${s.id} ${what} ${have.join('/')} on App Store Connect, ${want} in the config`); + }; + check('mode', distinct(x => x.mode), APPLE_OFFER_MODE[o.type]); + check('duration', distinct(x => x.duration), APPLE_OFFER_DURATION[introDuration(s)] ?? introDuration(s)); + check('periods', distinct(x => x.periods), o.type === 'pay-as-you-go' ? o.periods ?? 1 : 1); + if (o.type !== 'free' && o.usd !== undefined) { + const usa = cur.find(x => x.territory === 'USA')?.price; + if (usa !== undefined && cents(usa) !== cents(o.usd)) out.push(`intro differs: ${s.id} price USA ${usa} on App Store Connect, ${o.usd} in the config`); + } + return out; +} + // ---- Apple: plan ---- export interface AppleSubState { @@ -430,10 +516,20 @@ export function playOfferBody(pkg: string, s: Subscription, regions: PlayRegionP const moneyKey = (m?: Money): string => (m ? `${m.currencyCode} ${cents(fromMoney(m))}` : '-'); -export interface PlaySubDiff { create: boolean; listings: string[]; basePlan: 'new' | 'same' | 'changed'; priceChanges: { regionCode: string; from: string; to: string }[]; activate: boolean } +export interface PlaySubDiff { + create: boolean; listings: string[]; basePlan: 'new' | 'same' | 'changed'; priceChanges: { regionCode: string; from: string; to: string }[]; activate: boolean; + /** Languages on Play that the config does not list (a PATCH drops them only with --prune-languages). Set only when there are some. */ + listingsRemoved?: string[]; + /** Grace period on Play vs the config. Set only when it differs. */ + grace?: { from: string; to: string }; + /** Regions sold on Play that the config no longer lists (also one "not sold" row in priceChanges, owner-gated). */ + droppedRegions?: string[]; +} /** Current Play subscription (or null) vs the wanted body: what a patch would change. Price changes on an existing - * base plan apply to new subscribers; existing ones keep their price until you run a price migration in the Console. */ + * base plan apply to new subscribers; existing ones keep their price until you run a price migration in the Console. + * Money (regional prices, the other-regions USD / EUR price, regions no longer sold) all land in `priceChanges`, so + * the price gate holds them; text (listings, grace period) is its own field. */ export function playSubDiff(cur: PlaySubscription | null, want: PlaySubscription): PlaySubDiff { const wb = want.basePlans![0]!; if (!cur) return { create: true, listings: (want.listings ?? []).map(l => l.languageCode), basePlan: 'new', priceChanges: [], activate: true }; @@ -442,25 +538,100 @@ export function playSubDiff(cur: PlaySubscription | null, want: PlaySubscription const c = curL.get(l.languageCode); return !c || c.title !== l.title || (c.description ?? '') !== (l.description ?? '') || JSON.stringify(c.benefits ?? []) !== JSON.stringify(l.benefits ?? []); }).map(l => l.languageCode); + const wantCodes = new Set((want.listings ?? []).map(l => l.languageCode)); + const removed = [...curL.keys()].filter(c => !wantCodes.has(c)); + const extras = removed.length ? { listingsRemoved: removed } : {}; const cb = cur.basePlans?.find(b => b.basePlanId === wb.basePlanId); - if (!cb) return { create: false, listings, basePlan: 'new', priceChanges: [], activate: true }; + if (!cb) return { create: false, listings, basePlan: 'new', priceChanges: [], activate: true, ...extras }; const cr = new Map((cb.regionalConfigs ?? []).map(r => [r.regionCode, r.price] as const)); const priceChanges = (wb.regionalConfigs ?? []).filter(r => moneyKey(cr.get(r.regionCode)) !== moneyKey(r.price)) .map(r => ({ regionCode: r.regionCode, from: moneyKey(cr.get(r.regionCode)), to: moneyKey(r.price) })); - const typeSame = cb.autoRenewingBasePlanType?.billingPeriodDuration === wb.autoRenewingBasePlanType?.billingPeriodDuration - && (cb.autoRenewingBasePlanType?.gracePeriodDuration ?? '') === (wb.autoRenewingBasePlanType?.gracePeriodDuration ?? cb.autoRenewingBasePlanType?.gracePeriodDuration ?? ''); + const wantRegions = new Set((wb.regionalConfigs ?? []).map(r => r.regionCode)); + const gone = [...cr.keys()].filter(c => !wantRegions.has(c)); + for (const c of gone) priceChanges.push({ regionCode: c, from: moneyKey(cr.get(c)), to: 'not sold' }); + const co = cb.otherRegionsConfig, wo = wb.otherRegionsConfig; + if (wo && (moneyKey(co?.usdPrice) !== moneyKey(wo.usdPrice) || moneyKey(co?.eurPrice) !== moneyKey(wo.eurPrice))) + priceChanges.push({ regionCode: 'other regions', from: `${moneyKey(co?.usdPrice)} / ${moneyKey(co?.eurPrice)}`, to: `${moneyKey(wo.usdPrice)} / ${moneyKey(wo.eurPrice)}` }); + const curGrace = cb.autoRenewingBasePlanType?.gracePeriodDuration ?? ''; + const wantGrace = wb.autoRenewingBasePlanType?.gracePeriodDuration ?? ''; + // a config without a grace period leaves the one on Play alone + const graceChanged = wantGrace !== '' && wantGrace !== curGrace; if (cb.autoRenewingBasePlanType && cb.autoRenewingBasePlanType.billingPeriodDuration !== wb.autoRenewingBasePlanType?.billingPeriodDuration) throw new Error(`${want.productId}: base plan ${wb.basePlanId} bills every ${cb.autoRenewingBasePlanType.billingPeriodDuration} on Play, the config says ${wb.autoRenewingBasePlanType?.billingPeriodDuration} (a billing period cannot change; use a new basePlanId)`); - return { create: false, listings, basePlan: priceChanges.length || !typeSame ? 'changed' : 'same', priceChanges, activate: cb.state !== 'ACTIVE' }; + return { + create: false, listings, basePlan: priceChanges.length || graceChanged ? 'changed' : 'same', priceChanges, activate: cb.state !== 'ACTIVE', ...extras, + ...(graceChanged ? { grace: { from: curGrace || '-', to: wantGrace } } : {}), ...(gone.length ? { droppedRegions: gone } : {}), + }; } -/** The patch body keeps base plans the config does not manage (Play would otherwise drop them) and output-only fields out. */ -export function playPatchSubscription(cur: PlaySubscription, want: PlaySubscription): PlaySubscription { +/** The patch body keeps base plans the config does not manage (Play would otherwise drop them) and output-only fields out. + * Languages on Play that the config does not list stay in the body unless `pruneLanguages`. */ +export function playPatchSubscription(cur: PlaySubscription, want: PlaySubscription, o: { pruneLanguages?: boolean } = {}): PlaySubscription { const wb = want.basePlans![0]!; const keep = (cur.basePlans ?? []).filter(b => b.basePlanId !== wb.basePlanId).map(({ state: _s, ...b }) => b); const curMine = cur.basePlans?.find(b => b.basePlanId === wb.basePlanId); const { state: _st, ...mine } = { ...(curMine ?? {}), ...wb, autoRenewingBasePlanType: { ...(curMine?.autoRenewingBasePlanType ?? {}), ...wb.autoRenewingBasePlanType! } }; - return { ...want, basePlans: [...keep, mine as NonNullable[number]] }; + const wantCodes = new Set((want.listings ?? []).map(l => l.languageCode)); + const kept = o.pruneLanguages ? [] : (cur.listings ?? []).filter(l => !wantCodes.has(l.languageCode)); + return { ...want, ...(kept.length ? { listings: [...(want.listings ?? []), ...kept] } : {}), basePlans: [...keep, mine as NonNullable[number]] }; +} + +/** The text side of a Play subscription diff (no money; empty = nothing). Pure. */ +export function playSubTextChanges(d: PlaySubDiff, prune = false): string[] { + return [ + d.listings.length ? `listings ${d.listings.join(', ')}` : '', + d.listingsRemoved?.length ? (prune ? `listings removed: ${d.listingsRemoved.join(', ')}` : `listings removed from the config: ${d.listingsRemoved.join(', ')} (kept on Play; --prune-languages removes them)`) : '', + d.grace ? `grace ${d.grace.from} -> ${d.grace.to}` : '', + ].filter(Boolean); +} + +// ---- Play: offers already on the base plan ---- + +export interface PlayOfferLike { + offerId?: string; state?: string; + phases?: { recurrenceCount?: number; duration?: string; regionalConfigs?: { regionCode: string; free?: unknown; relativeDiscount?: number; absoluteDiscount?: Money; price?: Money }[]; + otherRegionsConfig?: { free?: unknown; relativeDiscount?: number; absoluteDiscount?: unknown; otherRegionsPrices?: { usdPrice?: Money; eurPrice?: Money } } }[]; + [k: string]: unknown; +} + +type Entry = Record | undefined; +/** "free", "-50%", "-USD 100" or "USD 299": the kind (free / discount / price) and the shown value of one phase entry. */ +function phaseEntry(c: Entry): { kind: string; text: string } { + if (!c) return { kind: '-', text: '-' }; + if (c.free !== undefined) return { kind: 'free', text: 'free' }; + if (typeof c.relativeDiscount === 'number') return { kind: 'discount', text: `-${Math.round(c.relativeDiscount * 100)}%` }; + if (c.absoluteDiscount) return { kind: 'absolute', text: `-${moneyKey(c.absoluteDiscount as Money)}` }; + if (c.price) return { kind: 'price', text: moneyKey(c.price as Money) }; + const o = c.otherRegionsPrices as { usdPrice?: Money; eurPrice?: Money } | undefined; + if (o) return { kind: 'price', text: `${moneyKey(o.usdPrice)} / ${moneyKey(o.eurPrice)}` }; + return { kind: '-', text: '-' }; +} + +export interface PlayOfferDrift { phase: string[]; prices: { region: string; from: string; to: string }[] } + +/** Offer on Play vs the offer the config would create: `phase` = free / paid kind, duration, periods (text), `prices` = + * per region the discount, price or other-regions price (money: only behind the price gate). Pure. */ +export function playOfferDiff(cur: PlayOfferLike, want: PlayOfferLike): PlayOfferDrift { + const out: PlayOfferDrift = { phase: [], prices: [] }; + const cp = cur.phases ?? [], wp = want.phases ?? []; + if (cp.length !== wp.length) out.phase.push(`${cp.length} phase(s) -> ${wp.length}`); + for (const [i, w] of wp.entries()) { + const c = cp[i]; + if (!c) continue; + const at = wp.length > 1 ? `phase ${i + 1} ` : ''; + if ((c.duration ?? '') !== (w.duration ?? '')) out.phase.push(`${at}duration ${c.duration ?? '-'} -> ${w.duration ?? '-'}`); + if ((c.recurrenceCount ?? 1) !== (w.recurrenceCount ?? 1)) out.phase.push(`${at}periods ${c.recurrenceCount ?? 1} -> ${w.recurrenceCount ?? 1}`); + const cm = new Map((c.regionalConfigs ?? []).map(r => [r.regionCode, r as Entry] as const)); + const rows: [string, Entry, Entry][] = (w.regionalConfigs ?? []).map(r => [r.regionCode, cm.get(r.regionCode), r as Entry]); + rows.push(['other regions', c.otherRegionsConfig as Entry, w.otherRegionsConfig as Entry]); + for (const [region, cc, wc] of rows) { + const a = phaseEntry(cc), b = phaseEntry(wc); + if (a.text === b.text) continue; + if (a.kind !== b.kind && a.kind !== '-') out.phase.push(`${at}${region} ${a.text} -> ${b.text}`); + else out.prices.push({ region, from: a.text, to: b.text }); + } + } + return out; } // ---- Apple: reading responses ---- diff --git a/cli/play/subscriptions.ts b/cli/play/subscriptions.ts index 65f7d85..ab037b0 100644 --- a/cli/play/subscriptions.ts +++ b/cli/play/subscriptions.ts @@ -20,8 +20,8 @@ import { done, log, step, warn } from '../lib/log'; import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges } from '../lib/price-gate'; import { type Money, toMoney } from '../lib/money'; import { - allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, - playSubscriptionBody, + allSubscriptions, basePlanId, offerId, type PlayOfferLike, playOfferBody, playOfferDiff, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, + playSubscriptionBody, playSubTextChanges, } from '../lib/subscriptions'; import { subscriptionCsv } from '../apple/subscriptions'; import { createOnce } from '../lib/upsert'; @@ -58,7 +58,19 @@ export async function run(ctx: Ctx): Promise { return { convertedRegionPrices: conv.convertedRegionPrices, convertedOtherRegionsPrice: conv.convertedOtherRegionsPrice, regionVersion: conv.regionVersion ?? { version: '2022/02' } }; }; - const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType; version: string; offerMissing: boolean; extraMissing: string[] }[] = []; + const prune = ctx.args.has('prune-languages'); + // the offer the config would create (pay-up-front prices come from convertRegionPrices of the offer price) + const buildOffer = async (s: typeof subs[number], regions: { regionCode: string; price: Money }[], o: { type: string; usd?: number }, extra?: NonNullable[number]) => { + let offerRegions; + if (o.type === 'pay-up-front') { + const oc = await convert(o.usd!); + offerRegions = { regions: Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), other: oc.convertedOtherRegionsPrice }; + } + return playOfferBody(pkg, s, regions, offerRegions, extra); + }; + + interface OfferDrift { offerId: string; phase: string[]; prices: HeldPrice['changes']; body: ReturnType } + const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType; version: string; offerMissing: boolean; extraMissing: string[]; offerDrift: OfferDrift[] }[] = []; for (const [i, s] of subs.entries()) { const conv = await convert(s.usd); const regions = playSubRegions(s, conv.convertedRegionPrices, csv); @@ -67,42 +79,63 @@ export async function run(ctx: Ctx): Promise { const diff = playSubDiff(cur, body); let offerMissing = !!s.intro; let extraMissing = (s.offers ?? []).map(o => o.id); + const offerDrift: OfferDrift[] = []; if ((s.intro || s.offers?.length) && cur?.basePlans?.some(b => b.basePlanId === basePlanId(s))) { - const offers = await play.call<{ subscriptionOffers?: { offerId: string }[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.id)}/basePlans/${basePlanId(s)}/offers`); - const have = new Set((offers.subscriptionOffers ?? []).map(o => o.offerId)); - offerMissing = !!s.intro && !have.has(offerId(s)); - extraMissing = extraMissing.filter(x => !have.has(x)); + const offers = await play.call<{ subscriptionOffers?: PlayOfferLike[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.id)}/basePlans/${basePlanId(s)}/offers`); + const haveOffers = new Map((offers.subscriptionOffers ?? []).map(o => [o.offerId ?? '', o] as const)); + offerMissing = !!s.intro && !haveOffers.has(offerId(s)); + extraMissing = extraMissing.filter(x => !haveOffers.has(x)); + // offers that exist: phases (free / paid, duration, periods) and prices per region vs what the config would create + const regionPrices = body.basePlans![0]!.regionalConfigs!.map(r => ({ regionCode: r.regionCode, price: r.price! })); + const check: [string, { type: string; usd?: number }, NonNullable[number] | undefined][] = []; + if (s.intro && haveOffers.has(offerId(s))) check.push([offerId(s), s.intro, undefined]); + for (const o of s.offers ?? []) if (haveOffers.has(o.id)) check.push([o.id, o, o]); + for (const [oid, o, extra] of check) { + const want = await buildOffer(s, regionPrices, o, extra); + const d = playOfferDiff(haveOffers.get(oid)!, want as unknown as PlayOfferLike); + if (d.phase.length || d.prices.length) offerDrift.push({ offerId: oid, phase: d.phase, prices: d.prices, body: want }); + } } + const text = playSubTextChanges(diff, prune); const what = diff.create ? `create, base plan ${basePlanId(s)} (${s.period}) in ${regions.length} regions` - : [diff.listings.length ? `listings ${diff.listings.join(', ')}` : '', diff.basePlan === 'new' ? `new base plan ${basePlanId(s)}` : diff.basePlan === 'changed' ? `base plan ${basePlanId(s)}: ${diff.priceChanges.length} regional price change(s)` : '', - diff.activate ? 'activate base plan' : ''].filter(Boolean).join(' · ') || 'up to date'; + : [...text.filter(x => !x.startsWith('grace')), diff.basePlan === 'new' ? `new base plan ${basePlanId(s)}` : diff.basePlan === 'changed' && diff.priceChanges.length ? `base plan ${basePlanId(s)}: ${diff.priceChanges.length} regional price change(s)` : '', + ...text.filter(x => x.startsWith('grace')), diff.activate ? 'activate base plan' : ''].filter(Boolean).join(' · ') || 'up to date'; log(TAG, `${step(i + 1, subs.length, s.id)}: ${what}${offerMissing ? ` · intro offer ${offerId(s)} (${s.intro!.type})` : ''}${extraMissing.length ? ` · offers ${extraMissing.join(', ')}` : ''}`); if (diff.priceChanges.length) log(TAG, ' owner: price change of an existing base plan (--allow-price-changes)'); for (const p of diff.priceChanges.slice(0, 5)) log(TAG, ` ${p.regionCode}: ${p.from} -> ${p.to}`); if (diff.priceChanges.length > 5) log(TAG, ` ... ${diff.priceChanges.length - 5} more`); - work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing }); + for (const d of offerDrift) { + log(TAG, ` offer ${d.offerId} differs${d.phase.length ? `: ${d.phase.join('; ')}` : ''}${d.prices.length ? ` · ${d.prices.length} price(s), owner (--allow-price-changes)` : ''}`); + for (const p of d.prices.slice(0, 5)) log(TAG, ` ${p.region}: ${p.from} -> ${p.to}`); + } + work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing, offerDrift }); } + // money held behind --allow-price-changes: base plan prices (incl. other regions and regions dropped) and offer prices + const moneyOf = (w: typeof work[number]): HeldPrice[] => [ + ...(w.diff.basePlan === 'changed' && w.diff.priceChanges.length ? [{ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) }] : []), + ...w.offerDrift.filter(d => d.prices.length).map(d => ({ id: `${w.s.id} offer ${d.offerId}`, changes: d.prices })), + ]; if (dry) { - const h = heldSection(work.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length).map(w => ({ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) })), ctx.args.has('allow-price-changes')); + const h = heldSection(work.flatMap(moneyOf), ctx.args.has('allow-price-changes')); if (h) console.log(h); done(TAG, 'subscriptions planned (dry run, nothing written)'); return; } - const todo = work.filter(w => w.diff.create || w.diff.listings.length || w.diff.basePlan !== 'same' || w.diff.activate || w.offerMissing || w.extraMissing.length); + const todo = work.filter(w => w.diff.create || w.diff.listings.length || w.diff.basePlan !== 'same' || w.diff.activate || w.offerMissing || w.extraMissing.length || w.offerDrift.length || (prune && w.diff.listingsRemoved?.length)); if (!todo.length) { done(TAG, 'subscriptions up to date'); return; } const yes = ctx.args.has('yes'); - const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length); + const priced = todo.filter(w => moneyOf(w).length); // the terminal question covers creates and other updates; price changes of existing base plans need // --allow-price-changes and their own question listing each old -> new (lib/price-gate.ts) if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') { if (!(await confirm(ctx, `Write ${todo.length} subscription(s) to Google Play? Activated base plans and offers can be bought as soon as the app is published. Price changes of existing base plans are not part of this answer (--allow-price-changes).`))) return; } - const liveOk = await allowPriceChanges(ctx, priced.map(w => ({ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) })), - `Change the base plan prices of ${priced.length} existing subscription(s) on Google Play? New subscribers pay the new price.`); - if (!liveOk && priced.length) log(TAG, 'writing what is new; price changes of existing base plans wait for the owner (--allow-price-changes)'); + const liveOk = await allowPriceChanges(ctx, priced.flatMap(moneyOf), + `Change the prices (base plan, other regions, regions sold, offers) of ${priced.length} existing subscription(s) on Google Play? New subscribers pay the new price.`); + if (!liveOk && priced.length) log(TAG, 'writing what is new; price changes of existing base plans and offers wait for the owner (--allow-price-changes)'); const held: HeldPrice[] = []; - for (const { s, body, diff, version, offerMissing, extraMissing } of todo) { + for (const { s, body, diff, version, offerMissing, extraMissing, offerDrift } of todo) { const v = encodeURIComponent(version); const id = encodeURIComponent(s.id); const cur = existing.get(s.id); @@ -113,16 +146,19 @@ export async function run(ctx: Ctx): Promise { create: () => play.call('POST', `${play.API}/subscriptions?productId=${id}®ionsVersion.version=${v}`, body) }); log(TAG, `${s.id}: ${r.created ? 'created' : 'exists already (made by an earlier run); run again to compare it with the config'}`); if (!r.created) continue; - } else if (diff.listings.length || diff.basePlan !== 'same') { + } else if (diff.listings.length || diff.basePlan !== 'same' || (prune && diff.listingsRemoved?.length)) { + const pruneLanguages = prune; if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) { held.push({ id: s.id, changes: diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) }); - if (diff.listings.length) { - // the listings only; the base plans as Play has them - await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings®ionsVersion.version=${v}`, { ...playPatchSubscription(cur!, body), basePlans: cur!.basePlans?.map(({ state: _s, ...b }) => b) }); - log(TAG, `${s.id}: listings updated (prices unchanged)`); + if (diff.listings.length || diff.grace || (prune && diff.listingsRemoved?.length)) { + // the text only (listings, grace period); the base plans as Play has them + const plans = cur!.basePlans?.map(({ state: _s, ...b }) => (diff.grace && b.basePlanId === basePlanId(s) + ? { ...b, autoRenewingBasePlanType: { ...b.autoRenewingBasePlanType!, gracePeriodDuration: diff.grace.to } } : b)); + await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings${diff.grace ? ',basePlans' : ''}®ionsVersion.version=${v}`, { ...playPatchSubscription(cur!, body, { pruneLanguages }), basePlans: plans }); + log(TAG, `${s.id}: ${playSubTextChanges(diff, prune).join(' · ')} updated (prices unchanged)`); } } else { - await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans®ionsVersion.version=${v}`, playPatchSubscription(cur!, body)); + await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans®ionsVersion.version=${v}`, playPatchSubscription(cur!, body, { pruneLanguages })); log(TAG, `${s.id}: updated`); } } @@ -131,14 +167,9 @@ export async function run(ctx: Ctx): Promise { log(TAG, `${s.id}: base plan ${basePlanId(s)} activated`); } const regions = body.basePlans![0]!.regionalConfigs!.map(r => ({ regionCode: r.regionCode, price: r.price! })); + const offerUrl = `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}/offers`; const createOffer = async (o: { type: string; usd?: number }, extra?: NonNullable[number]) => { - let offerRegions; - if (o.type === 'pay-up-front') { - const oc = await convert(o.usd!); - offerRegions = { regions: Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), other: oc.convertedOtherRegionsPrice }; - } - const ob = playOfferBody(pkg, s, regions, offerRegions, extra); - const offerUrl = `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}/offers`; + const ob = await buildOffer(s, regions, o, extra); // offer ids are unique per base plan: a 409 or an unclear answer finds the existing offer, never a second one const r = await createOnce({ what: `${s.id} offer ${ob.offerId}`, tag: TAG, unique: true, find: () => getOrNull<{ state?: string }>(play, `${offerUrl}/${encodeURIComponent(ob.offerId)}`), @@ -149,6 +180,12 @@ export async function run(ctx: Ctx): Promise { }; if (s.intro && offerMissing) await createOffer(s.intro); for (const o of (s.offers ?? []).filter(x => extraMissing.includes(x.id))) await createOffer(o, o); + // offers that exist but differ: phases (text) are updated; their prices only behind the price gate + for (const d of offerDrift) { + if (d.prices.length && !liveOk) { held.push({ id: `${s.id} offer ${d.offerId}`, changes: d.prices }); continue; } + await play.call('PATCH', `${offerUrl}/${encodeURIComponent(d.offerId)}?updateMask=phases®ionsVersion.version=${v}`, d.body); + log(TAG, `${s.id}: offer ${d.offerId} updated (${[...d.phase, ...(d.prices.length ? [`${d.prices.length} price(s)`] : [])].join('; ')})`); + } } holdPriceChanges(held); done(TAG, held.length ? 'subscriptions done, price changes left for the owner' : 'subscriptions done'); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 6f89dc2..c44253a 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1251,3 +1251,17 @@ that fails without the fix (see CONTRIBUTING.md). - **Do:** update storebound; the warning was false. - **Guard:** `readProjectEntitlements` in `cli/apple/signing.ts` compares forward-slash paths on every OS (`tests/entitlements-ref.test.ts`). + +## 104. An existing store item differs from the config and the run said nothing +- **Symptom:** `apple iap --yes` updated only missing localizations of live consumables while the config's USD prices + (12.99 / 129.99 / 649.99) differed from the store (9.99 / 99.99 / 500.00); the dry run showed no price, and changed + names and descriptions were never sent. An audit found the same class in 24 places (subscriptions, offers, app + price and availability, events, custom pages, review details). +- **Cause:** existing items were checked for existence only, not compared field by field. +- **Do:** read the dry run: every difference of an existing item is a row (old -> new). Text changes are sent; + prices, dropped regions and removed territories need `--allow-price-changes` / `--allow-availability-changes` + (the owner's call); fields Apple cannot edit (family sharing on, offer-code and win-back campaigns, intro offers, + subscription period) are named, never changed. +- **Guard:** `iapLocalizationChanges` / `iapAttributeChanges` / `availabilityChange` (`cli/apple/iap.ts`), the + subscription helpers in `cli/lib/subscriptions.ts`, `planAppInfo` / `reviewDetailDiff`, `campaignDiff`, + `winBackDiff`, `eventAttrDiff`; tests `apple-iap-drift`, `subscriptions-drift`, `appinfo-drift`, `misc-drift`. diff --git a/tests/pitfalls.test.ts b/tests/pitfalls.test.ts index 1d2ba3e..a75c6c6 100644 --- a/tests/pitfalls.test.ts +++ b/tests/pitfalls.test.ts @@ -66,7 +66,10 @@ describe('store list lag after create', () => { { match: new RegExp(`^/v1/apps/6400000101/inAppPurchasesV2\\?filter\\[productId\\]=${GEMS}`), answer: page([]) }, { match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseLocalizations/, answer: page([]) }, { match: /^\/v2\/inAppPurchases\/i-pro\/iapPriceSchedule/, answer: notFound() }, - { match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseAvailability/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av' } } }, + { match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseAvailability/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av', attributes: { availableInNewTerritories: true } } } }, + { match: /^\/v1\/inAppPurchaseAvailabilities\/av\/availableTerritories/, answer: page([{ type: 'territories', id: 'USA' }, { type: 'territories', id: 'TUR' }]) }, + // the product as the store has it: the same reference name as the config, so only localizations and prices change + { match: /^\/v2\/inAppPurchases\/i-pro(\?|$)/, answer: { data: { type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, name: 'No Ads', familySharable: false, state: 'MISSING_METADATA' } } } }, ]); const r = await capture(() => iap(p.ctx(['--dry']))); expect(r.out).toContain('dry run: apple iap: 2 change(s); nothing written'); diff --git a/tests/subscriptions-drift.test.ts b/tests/subscriptions-drift.test.ts new file mode 100644 index 0000000..0145cb2 --- /dev/null +++ b/tests/subscriptions-drift.test.ts @@ -0,0 +1,148 @@ +// Existing subscription items that differ from the config are shown in the dry run and updated (text) or held behind the +// price gate (money). Pure helpers of cli/lib/subscriptions.ts with neutral data. +import { describe, expect, test } from 'bun:test'; +import type { Subscription } from '../cli/lib/config'; +import { toMoney } from '../cli/lib/money'; +import { + appleAvailabilityDiff, appleGroupLocDiff, appleIntroDiff, appleIntroOffers, appleLocDiff, appleSubAttrDiff, appleSubscriptionBody, + type PlayOfferLike, playOfferBody, playOfferDiff, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, playSubscriptionBody, playSubTextChanges, +} from '../cli/lib/subscriptions'; + +const LANGS = [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }] as never; +const s: Subscription = { + id: 'com.example.app.pro_monthly', period: 'P1M', level: 1, usd: 4.99, gracePeriod: 'P7D', familySharable: false, reviewNote: 'Sign in with the demo account', + listings: { en: { title: 'Pro', description: 'All features', benefits: ['A'] }, de: { title: 'Pro', description: 'Alle Funktionen' } }, + intro: { type: 'free', duration: 'P1W' }, +}; +const conv = { US: { regionCode: 'US', price: toMoney('USD', 4.99) }, TR: { regionCode: 'TR', price: toMoney('TRY', 99.99) } }; +const other = { usdPrice: toMoney('USD', 4.99), eurPrice: toMoney('EUR', 4.99) }; +const want = (): PlaySubscription => playSubscriptionBody('com.example.app', s, LANGS, playSubRegions(s, conv), other); +const live = (): PlaySubscription => { const c = JSON.parse(JSON.stringify(want())) as PlaySubscription; c.basePlans![0]!.state = 'ACTIVE'; return c; }; + +describe('apple subscription attributes', () => { + const ok = { name: 'Pro', subscriptionPeriod: 'ONE_MONTH', familySharable: false, reviewNote: 'Sign in with the demo account', groupLevel: 1 }; + test('equal: nothing', () => { + expect(appleSubAttrDiff(s, ok)).toEqual({ patch: {}, text: [], report: [] }); + expect(appleSubAttrDiff(s, {})).toEqual({ patch: {}, text: [], report: [] }); + }); + test('name, review note and level are patched; period and family sharing only reported', () => { + const d = appleSubAttrDiff(s, { ...ok, name: 'Old', reviewNote: null, groupLevel: 2, subscriptionPeriod: 'ONE_YEAR', familySharable: true }); + expect(d.patch).toEqual({ name: 'Pro', reviewNote: 'Sign in with the demo account', groupLevel: 1 }); + expect(d.report.length).toBe(2); + expect(d.report[0]).toContain('ONE_YEAR'); + expect(d.report[1]).toContain('familySharable'); + expect(Object.keys(appleSubscriptionBody('g', s).data.attributes)).toContain('reviewNote'); + }); + test('a config without referenceName compares with the first listing title', () => { + expect(appleSubAttrDiff(s, { name: 'Pro' }).patch).toEqual({}); + expect(appleSubAttrDiff({ ...s, referenceName: 'Pro monthly' }, { name: 'Pro' }).patch).toEqual({ name: 'Pro monthly' }); + }); +}); + +describe('apple localizations', () => { + test('subscription text', () => { + expect(appleLocDiff({ title: 'Pro', description: 'All features' }, { name: 'Pro', description: 'All features' })).toBeNull(); + expect(appleLocDiff({ title: 'Pro', description: 'All features' }, { name: 'Pro', description: 'Old' })).toEqual({ description: 'All features' }); + expect(appleLocDiff({ title: 'Pro', description: 'x' }, {})).toBeNull(); + }); + test('group name and customAppName (only when configured)', () => { + expect(appleGroupLocDiff({ name: 'Pro' }, { name: 'Pro', customAppName: 'Anything' })).toBeNull(); + expect(appleGroupLocDiff({ name: 'Pro', customAppName: 'Example' }, { name: 'Old', customAppName: null })).toEqual({ name: 'Pro', customAppName: 'Example' }); + }); +}); + +describe('apple availability', () => { + test('missing territories and new territories off', () => { + expect(appleAvailabilityDiff({ territories: ['USA', 'TUR'], availableInNewTerritories: true }, ['USA', 'TUR'])).toBeNull(); + expect(appleAvailabilityDiff({ territories: ['USA'], availableInNewTerritories: true }, ['USA', 'TUR', 'DEU'])).toEqual({ missing: ['TUR', 'DEU'], newTerritories: false }); + expect(appleAvailabilityDiff({ territories: ['USA'], availableInNewTerritories: false }, ['USA'])).toEqual({ missing: [], newTerritories: true }); + }); +}); + +describe('apple intro offers', () => { + const resp = (mode: string, duration: string, periods: number, price?: string) => ({ + data: [{ id: 'i1', attributes: { offerMode: mode, duration, numberOfPeriods: periods }, + relationships: { territory: { data: { id: 'USA' } }, ...(price ? { subscriptionPricePoint: { data: { id: 'pp1' } } } : {}) } }], + included: price ? [{ id: 'pp1', type: 'subscriptionPricePoints', attributes: { customerPrice: price } }] : [], + }); + test('equal or no offer yet: nothing', () => { + expect(appleIntroDiff(s, appleIntroOffers(resp('FREE_TRIAL', 'ONE_WEEK', 1)))).toEqual([]); + expect(appleIntroDiff(s, [])).toEqual([]); + }); + test('differences are reported as "intro differs"', () => { + const d = appleIntroDiff(s, appleIntroOffers(resp('PAY_UP_FRONT', 'ONE_MONTH', 1))); + expect(d.length).toBe(2); + expect(d.every(x => x.startsWith('intro differs: '))).toBe(true); + const paid: Subscription = { ...s, intro: { type: 'pay-as-you-go', periods: 3, usd: 1.99 } }; + expect(appleIntroDiff(paid, appleIntroOffers(resp('PAY_AS_YOU_GO', 'ONE_MONTH', 3, '0.99')))).toEqual([expect.stringContaining('price USA 0.99')]); + expect(appleIntroDiff({ ...s, intro: undefined }, appleIntroOffers(resp('FREE_TRIAL', 'ONE_WEEK', 1)))[0]).toContain('config has none'); + }); +}); + +describe('play subscription diff', () => { + test('equal: the old shape, no extra fields', () => { + expect(playSubDiff(live(), want())).toEqual({ create: false, listings: [], basePlan: 'same', priceChanges: [], activate: false }); + }); + test('other regions USD / EUR price is a gated price change', () => { + const cur = live(); + cur.basePlans![0]!.otherRegionsConfig = { usdPrice: toMoney('USD', 3.99), eurPrice: toMoney('EUR', 4.99) }; + const d = playSubDiff(cur, want()); + expect(d.basePlan).toBe('changed'); + expect(d.priceChanges).toEqual([{ regionCode: 'other regions', from: 'USD 399 / EUR 499', to: 'USD 499 / EUR 499' }]); + }); + test('a region sold on Play but not in the config is an owner-gated "not sold" row', () => { + const cur = live(); + cur.basePlans![0]!.regionalConfigs!.push({ regionCode: 'JP', newSubscriberAvailability: true, price: toMoney('JPY', 750) }); + const d = playSubDiff(cur, want()); + expect(d.droppedRegions).toEqual(['JP']); + expect(d.priceChanges).toEqual([{ regionCode: 'JP', from: 'JPY 75000', to: 'not sold' }]); + expect(d.basePlan).toBe('changed'); + }); + test('grace period is its own line and needs no price gate', () => { + const cur = live(); + cur.basePlans![0]!.autoRenewingBasePlanType!.gracePeriodDuration = 'P3D'; + const d = playSubDiff(cur, want()); + expect(d.grace).toEqual({ from: 'P3D', to: 'P7D' }); + expect(d.priceChanges).toEqual([]); + expect(playSubTextChanges(d)).toEqual(['grace P3D -> P7D']); + // a config without a grace period leaves Play's alone + const noGrace = want(); + delete noGrace.basePlans![0]!.autoRenewingBasePlanType!.gracePeriodDuration; + expect(playSubDiff(cur, noGrace).grace).toBeUndefined(); + }); + test('languages on Play but not in the config are reported and kept unless pruned', () => { + const cur = live(); + cur.listings!.push({ languageCode: 'fr-FR', title: 'Pro', description: 'Tout' }); + const d = playSubDiff(cur, want()); + expect(d.listingsRemoved).toEqual(['fr-FR']); + expect(d.listings).toEqual([]); + expect(playSubTextChanges(d, true)).toEqual(['listings removed: fr-FR']); + expect(playSubTextChanges(d)[0]).toContain('kept on Play'); + expect(playPatchSubscription(cur, want()).listings!.map(l => l.languageCode)).toContain('fr-FR'); + expect(playPatchSubscription(cur, want(), { pruneLanguages: true }).listings!.map(l => l.languageCode)).not.toContain('fr-FR'); + }); +}); + +describe('play offers that exist', () => { + const regions = [{ regionCode: 'US', price: toMoney('USD', 4.99) }, { regionCode: 'TR', price: toMoney('TRY', 99.99) }]; + const body = (sub: Subscription) => playOfferBody('com.example.app', sub, regions) as unknown as PlayOfferLike; + test('equal: nothing', () => { + expect(playOfferDiff(JSON.parse(JSON.stringify(body(s))), body(s))).toEqual({ phase: [], prices: [] }); + }); + test('duration and periods are phase (text) changes', () => { + const cur = body({ ...s, intro: { type: 'free', duration: 'P1M' } }); + const d = playOfferDiff(cur, body(s)); + expect(d.phase).toEqual(['duration P1M -> P1W']); + expect(d.prices).toEqual([]); + }); + test('free to discounted is a phase change, a new discount percentage is money', () => { + const payg: Subscription = { ...s, intro: { type: 'pay-as-you-go', periods: 2, usd: 2.49 } }; + const cur = body(s); + const d = playOfferDiff(cur, body(payg)); + expect(d.phase.some(x => x.includes('free -> -50%'))).toBe(true); + expect(d.phase.some(x => x.startsWith('periods 1 -> 2'))).toBe(true); + const cheaper = playOfferDiff(body(payg), body({ ...payg, intro: { type: 'pay-as-you-go', periods: 2, usd: 1.99 } })); + expect(cheaper.phase).toEqual([]); + expect(cheaper.prices.map(p => `${p.region} ${p.from} -> ${p.to}`)).toContain('US -50% -> -60%'); + }); +}); From e77f2b68f573c01cafa18e411d80a03c3fe93ada Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 19:51:07 +0700 Subject: [PATCH 028/255] storebound 0.8.29: version in package.json and every copy, CHANGELOG 0.8.29 section --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7000a74..010f08d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.28", + "version": "0.8.29", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7535e7a..6b3d13b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.28", + "version": "0.8.29", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index e0abd17..2a01cf5 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.28", + "version": "0.8.29", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f90f7..6b71f5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.29 · 2026-10-22 + - **Existing store items are compared with the config, field by field** (pitfall 104). The dry run shows every difference (old -> new); texts are sent, money and availability need a flag, fields Apple cannot edit are named. - `apple iap`: price (`--allow-price-changes`), localization names and descriptions, reference name, review note, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4ed3263..e942f78 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.28", + "version": "0.8.29", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 4dc267b..a701c60 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4096,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.28" +version = "0.8.29" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1ad40b3..a015e93 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.28" +version = "0.8.29" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 33afde1..de6c1e0 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.28", + "version": "0.8.29", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", diff --git a/gemini-extension.json b/gemini-extension.json index 90ed811..b878a45 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.28", + "version": "0.8.29", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index e3dcb1f..d3e6370 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.28", + "version": "0.8.29", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", From 27db41c3f0ad3d39cc8274f85944c1e2a35e4a3f Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 21:24:24 +0800 Subject: [PATCH 029/255] Desktop portfolio v2: greeting with what is moving, store-style app cards (rendered screenshots or the icon on its tint), two-store release pipeline, crash sparkline, one next-step button; What changed feed and Fix first queue; Cards / Table switch keeps the old table --- apps/desktop/src/i18n/en.ts | 47 ++++ apps/desktop/src/i18n/tr.ts | 47 ++++ apps/desktop/src/screens/Portfolio.tsx | 369 ++++++++++++++++++++++--- apps/desktop/src/styles.css | 91 ++++++ 4 files changed, 519 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 9e21009..c400f03 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1865,4 +1865,51 @@ export const en = { 'gs.syncHint': 'Dry run first, then you confirm; nothing is submitted or published.', 'gs.synced': 'Game services in step', 'gs.setup': 'Setup plan', + 'pf.morning': "Good morning.", + 'pf.afternoon': "Good afternoon.", + 'pf.evening': "Good evening.", + 'pf.moving': "{count} releases are moving", + 'pf.moving_one': "1 release is moving", + 'pf.movingNone': "Nothing is moving right now", + 'pf.notMovingUnknown': "Read the stores to see what is moving", + 'pf.readAgo': "stores read {when}", + 'pf.layout': "Layout", + 'pf.cards': "Cards", + 'pf.table': "Table", + 'pf.next': "next", + 'pf.crash': "crash", + 'pf.ctaFix': "Fix {count} blockers", + 'pf.ctaFix_one': "Fix 1 blocker", + 'pf.ctaRollout': "Roll out (now {share})", + 'pf.ctaRelease': "Release {version}", + 'pf.ctaSubmit': "Submit {version}", + 'pf.ctaReview': "Follow review of {version}", + 'pf.ctaWarnings': "Look at {count} warnings", + 'pf.ctaWarnings_one': "Look at 1 warning", + 'pf.ctaUpload': "Upload a build", + 'pf.ctaOpen': "Open releases", + 'pf.feed': "What changed", + 'pf.feedSub': "Releases, health and folders, newest first", + 'pf.feedEmpty': "Nothing changed yet", + 'pf.feedEmptyText': "Read the stores to see releases and health here.", + 'pf.evLive': "{version} is live on {store}", + 'pf.evRollout': "{version} is rolling out to {share} on {store}", + 'pf.evReview': "{version} went into review on {store}", + 'pf.evApproved': "{version} was approved on {store}", + 'pf.evRejected': "{version} was rejected on {store}", + 'pf.evHalted': "{version} rollout halted on {store}", + 'pf.evTesting': "{version} is in testing on {store}", + 'pf.evDraft': "{version} draft on {store}", + 'pf.evCrash': "crash rate {rate} on Google Play", + 'pf.evAdded': "folder added to the workspace", + 'pf.more': "Show {count} more", + 'pf.less': "Show less", + 'pf.fixFirst': "Fix first", + 'pf.fixFirstSub': "{count} items, blockers first", + 'pf.fixFirstSub_one': "1 item", + 'pf.blocker': "Blocker", + 'pf.warning': "Warning", + 'pf.note': "Note", + 'pf.fixIn': "Fix in {tab}", + 'pf.openTab': "Open {tab}", } as const; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 79f399c..afc278c 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -1867,4 +1867,51 @@ export const tr: Record = { 'gs.syncHint': 'Önce deneme çalıştırması, sonra sen onaylarsın; hiçbir şey gönderilmez ya da yayınlanmaz.', 'gs.synced': 'Oyun hizmetleri eşit', 'gs.setup': 'Kurulum planı', + 'pf.morning': "Günaydın.", + 'pf.afternoon': "İyi günler.", + 'pf.evening': "İyi akşamlar.", + 'pf.moving': "{count} sürüm ilerliyor", + 'pf.moving_one': "1 sürüm ilerliyor", + 'pf.movingNone': "Şu an ilerleyen sürüm yok", + 'pf.notMovingUnknown': "Neyin ilerlediğini görmek için mağazaları oku", + 'pf.readAgo': "mağazalar {when} okundu", + 'pf.layout': "Görünüm", + 'pf.cards': "Kartlar", + 'pf.table': "Tablo", + 'pf.next': "sıradaki", + 'pf.crash': "çökme", + 'pf.ctaFix': "{count} engeli düzelt", + 'pf.ctaFix_one': "1 engeli düzelt", + 'pf.ctaRollout': "Dağıtımı sürdür (şu an {share})", + 'pf.ctaRelease': "{version} sürümünü yayınla", + 'pf.ctaSubmit': "{version} sürümünü gönder", + 'pf.ctaReview': "{version} incelemesini izle", + 'pf.ctaWarnings': "{count} uyarıya bak", + 'pf.ctaWarnings_one': "1 uyarıya bak", + 'pf.ctaUpload': "Derleme yükle", + 'pf.ctaOpen': "Sürümleri aç", + 'pf.feed': "Neler değişti", + 'pf.feedSub': "Sürümler, sağlık ve klasörler, en yenisi üstte", + 'pf.feedEmpty': "Henüz değişiklik yok", + 'pf.feedEmptyText': "Sürümleri ve sağlığı burada görmek için mağazaları oku.", + 'pf.evLive': "{version}, {store} üzerinde yayında", + 'pf.evRollout': "{version}, {store} üzerinde {share} kullanıcıya dağıtılıyor", + 'pf.evReview': "{version}, {store} incelemesine girdi", + 'pf.evApproved': "{version}, {store} tarafından onaylandı", + 'pf.evRejected': "{version}, {store} tarafından reddedildi", + 'pf.evHalted': "{version} dağıtımı {store} üzerinde durduruldu", + 'pf.evTesting': "{version}, {store} üzerinde testte", + 'pf.evDraft': "{version}, {store} üzerinde taslak", + 'pf.evCrash': "Google Play çökme oranı {rate}", + 'pf.evAdded': "klasör çalışma alanına eklendi", + 'pf.more': "{count} tane daha göster", + 'pf.less': "Daha az göster", + 'pf.fixFirst': "Önce bunları düzelt", + 'pf.fixFirstSub': "{count} madde, önce engeller", + 'pf.fixFirstSub_one': "1 madde", + 'pf.blocker': "Engel", + 'pf.warning': "Uyarı", + 'pf.note': "Not", + 'pf.fixIn': "{tab} sekmesinde düzelt", + 'pf.openTab': "{tab} sekmesini aç", }; diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index 91dc8d1..b6e1a9e 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -1,15 +1,17 @@ -// Portfolio: every app of every studio on both stores. KPIs, a toolbar (search, studio chips with counts, sort, -// grouping), one row per app (live version and state per store with its place on the release track, next version, -// crash rate, catalog parity, config check), grouped by studio; then what needs attention and the workspace state. +// Portfolio: every app of every studio on both stores. A greeting that says what is moving, KPIs, a toolbar (search, +// studio chips with counts, sort, grouping, cards / table), one store-listing card per app (rendered screenshots, the +// release track per store, crash rate, one next step) or the dense table (live version and state per store, next +// version, crash rate, catalog parity, config check), grouped by studio; then what changed and what to fix first. import { plainError } from '../../../../cli/lib/google-errors'; import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError'; -import { useEffect, useMemo, useState } from 'react'; -import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api'; +import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from 'react'; +import { api, type AppStatus, newJob, reads, type RenderImage, type StoreRelease, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; import { ago, pct, share } from '../lib/format'; import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity'; -import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; -import { useNav } from '../nav'; +import { type Attention, attention, healthSeries, type HealthSnapshot, hueOf, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; +import { type AppTab, useNav } from '../nav'; +import type { IconName } from '../ui/icons'; import { useData } from '../store'; import { exportName, portfolioCsv } from '../lib/exports'; import { useExports } from '../ui/Exports'; @@ -95,35 +97,304 @@ function HealthCell({ c, android }: { c: { h: HealthSnapshot; at: string } | nul ); } -function AttentionList({ items }: { items: Attention[] }) { - const nav = useNav(); - if (!items.length) return {t('portfolio.allClearText')}; - return ( -
- {items.slice(0, 8).map((a, i) => ( - - ))} -
- ); -} - /** Newest store activity of an app (release record date), else when its folder was added. */ function updatedAt(a: UnifiedApp, l: AppStatus | undefined): string | null { const dates = (l?.release.all ?? []).map(r => r.date).filter((x): x is string => !!x); return dates.sort().at(-1) ?? a.project?.added ?? null; } +// ---- App cards ---- + +/** Every release record of an app (live read first). */ +function releasesOf(l: AppStatus | undefined): StoreRelease[] { + if (!l) return []; + return l.release.all ?? [l.release.current?.ios, l.release.current?.android, l.release.next?.ios, l.release.next?.android].filter((x): x is StoreRelease => !!x); +} + +// Up to three phone screenshots of the app folder's rendered store art (`shots gallery`, then one `project.render` +// data: URL each), loaded once per window and one app at a time. No renders: an empty list (the card shows the icon). +const shotCache = new Map>(); +let shotChain: Promise = Promise.resolve(); +function loadShots(projectId: string): Promise { + const hit = shotCache.get(projectId); + if (hit) return hit; + const job = shotChain.then(async () => { + const r = await reads.renders(projectId).catch(() => null); + const res = r?.data; + if (!res) return []; + const v = res.variants.find(x => !x.id) ?? res.variants[0]; + const lang = v?.langs.find(l => l.lang === 'en' || l.lang.startsWith('en-')) ?? v?.langs[0]; + const rows = (lang?.rows ?? []).filter(row => !/feature|ipad|preview|tv|watch|mac|tablet/i.test(row.size) && row.tiles.some(x => x.kind === 'image')); + rows.sort((a, b) => (a.store === 'apple' ? 0 : 1) - (b.store === 'apple' ? 0 : 1)); + const files = (rows[0]?.tiles ?? []).filter(x => x.kind === 'image' && x.h >= x.w).slice(0, 3).map(x => x.src); + const out: string[] = []; + for (const file of files) { + const img = await api.call('project.render', projectId, { args: { file } }).catch(() => null); + if (img?.data?.dataUrl) out.push(img.data.dataUrl); + } + return out; + }).catch(() => [] as string[]); + shotChain = job; + shotCache.set(projectId, job); + return job; +} + +function useShots(projectId: string | null): string[] { + const [shots, setShots] = useState([]); + useEffect(() => { + if (!projectId) return; + let alive = true; + loadShots(projectId).then(s => { if (alive) setShots(s); }, () => {}); + return () => { alive = false; }; + }, [projectId]); + return shots; +} + +// The hue of an app icon (saturation-weighted mean colour), for the card's soft gradient; the name's hue without one. +const tintCache = new Map(); +function useTint(src: string | null, name: string): number { + const fallback = hueOf(name); + const [hue, setHue] = useState(() => (src ? tintCache.get(src) : undefined) ?? fallback); + useEffect(() => { + if (!src) { setHue(fallback); return; } + const cached = tintCache.get(src); + if (cached !== undefined) { setHue(cached); return; } + let alive = true; + const img = new Image(); + img.onload = () => { + try { + const c = document.createElement('canvas'); + c.width = c.height = 16; + const g = c.getContext('2d'); + if (!g) return; + g.drawImage(img, 0, 0, 16, 16); + const px = g.getImageData(0, 0, 16, 16).data; + let r = 0, gr = 0, b = 0, w = 0; + for (let i = 0; i < px.length; i += 4) { + const R = px[i]!, G = px[i + 1]!, B = px[i + 2]!, A = px[i + 3]! / 255; + const s = (Math.max(R, G, B) - Math.min(R, G, B)) / 255 * A; + r += R * s; gr += G * s; b += B * s; w += s; + } + if (w < 0.5) return; + r /= w; gr /= w; b /= w; + const mx = Math.max(r, gr, b), mn = Math.min(r, gr, b), d = mx - mn; + if (d < 1) return; + let h = mx === r ? ((gr - b) / d) % 6 : mx === gr ? (b - r) / d + 2 : (r - gr) / d + 4; + h = Math.round(h * 60 + 360) % 360; + tintCache.set(src, h); + if (alive) setHue(h); + } catch { /* keep the name's hue */ } + }; + img.src = src; + return () => { alive = false; }; + }, [src, fallback]); + return hue; +} + +interface Cta { text: string; tab: AppTab; icon: IconName; primary: boolean } + +/** The one next step of an app, from its state: blockers first, then the release that waits for a move. */ +export function nextStep(o: { project: boolean; att: Attention[]; all: StoreRelease[]; next: string | null }): Cta { + const bad = o.att.filter(a => a.tone === 'bad'); + if (bad.length) return { text: t('pf.ctaFix', { count: bad.length }), tab: bad[0]!.tab ?? 'checks', icon: 'alertCircle', primary: true }; + const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { text: t('pf.ctaRollout', { share: share(rolling.fraction ?? 0) }), tab: 'releases', icon: 'rocket', primary: true }; + const approved = o.all.find(r => r.phase === 'approved'); + if (approved) return { text: t('pf.ctaRelease', { version: approved.version ?? '' }), tab: 'releases', icon: 'rocket', primary: true }; + const ready = o.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (ready) return { text: t('pf.ctaSubmit', { version: ready.version ?? '' }), tab: 'releases', icon: 'send', primary: true }; + const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { text: t('pf.ctaReview', { version: review.version ?? '' }), tab: 'releases', icon: 'clock', primary: false }; + if (o.att.length) return { text: t('pf.ctaWarnings', { count: o.att.length }), tab: o.att[0]!.tab ?? 'checks', icon: 'alert', primary: false }; + if (o.project && !o.next) return { text: t('pf.ctaUpload'), tab: 'releases', icon: 'upload', primary: false }; + return { text: t('pf.ctaOpen'), tab: 'releases', icon: 'arrowRight', primary: false }; +} + +function Track({ store, all, on }: { store: 'ios' | 'android'; all: StoreRelease[]; on: boolean }) { + const { now, next } = storeNow(all, store); + const r = next ?? now; + const lane = laneOf(store, on ? r : null); + const name = store === 'ios' ? 'App Store' : 'Google Play'; + const state = !on ? t('app.notOnStore') : !r ? t('portfolio.noVersion') : `${r.version ?? ''} · ${t(`phase.${r.phase}` as Key)}${lane.share !== null && lane.share < 1 ? ` ${share(lane.share)}` : ''}`; + return ( +
+ {store === 'ios' ? 'App Store' : 'Play'} + + {on && r ? `${r.version ?? ''}${lane.share !== null && lane.share < 1 ? ` · ${share(lane.share)}` : ''}` : '–'} +
+ ); +} + +function CardHealth({ c, android }: { c: { h: HealthSnapshot; at: string } | null | undefined; android: boolean }) { + const play = c?.h.play; + if (!android || !c || !play || isError(play)) { + return {!android || (c && !play) ? t('portfolio.healthNoPlay') : c && play && isError(play) ? t('portfolio.unreadable') : t('portfolio.healthNotRead')}; + } + const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); + return ( + + + {t('pf.crash')}{pct(s.current)} + + ); +} + +interface CardRow { app: UnifiedApp; e: AppStatus | undefined; l: AppStatus | undefined; att: Attention[] } + +function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; health: { h: HealthSnapshot; at: string } | null | undefined; studio?: ReactNode }) { + const nav = useNav(); + const { app, e, l, att } = r; + const projectId = app.project && !app.project.missing ? app.project.id : null; + const shots = useShots(projectId); + const hue = useTint(icon, app.name); + const all = releasesOf(l); + const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android); + const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? ''; + const next = l?.release.next?.version ?? null; + const cta = nextStep({ project: !!app.project, att, all, next }); + const name = e?.app?.name && app.project ? e.app.name : app.name; + const go = (tab: AppTab) => nav.go({ screen: 'app', appId: app.key, tab }); + const style = { '--tint-h': String(hue) } as CSSProperties; + return ( +
+ +
+
+ +
+ + {sub}{!app.project && {t('apps.storeOnly')}} +
+
+ {t('pf.next')} + {next ?? '–'} +
+
+ {studio} +
+ + + +
+
+ + +
+
+
+ ); +} + +// ---- What changed ---- + +interface Ev { key: string; app: UnifiedApp; text: string; at: string; icon: IconName; tone: Tone } + +function events(rows: CardRow[], health: Record): Ev[] { + const out: Ev[] = []; + for (const r of rows) { + for (const rel of releasesOf(r.l)) { + if (!rel.date) continue; + const store = rel.store === 'ios' ? 'App Store' : 'Google Play', version = rel.version ?? ''; + const ev = (key: Key, icon: IconName, tone: Tone, vars: Record = {}) => + out.push({ key: `${r.app.key}|${rel.store}|${version}|${rel.phase}`, app: r.app, text: t(key, { version, store, ...vars }), at: rel.date!, icon, tone }); + switch (rel.phase) { + case 'live': ev('pf.evLive', 'check', 'ok'); break; + case 'rollout': ev('pf.evRollout', 'rocket', 'accent', { share: share(rel.fraction ?? 1) }); break; + case 'review': case 'sent': ev('pf.evReview', 'clock', 'warn'); break; + case 'approved': ev('pf.evApproved', 'check', 'ok'); break; + case 'rejected': ev('pf.evRejected', 'alertCircle', 'bad'); break; + case 'halted': ev('pf.evHalted', 'pause', 'bad'); break; + case 'testing': ev('pf.evTesting', 'flask', 'info'); break; + case 'draft': ev('pf.evDraft', 'edit', 'neutral'); break; + default: break; + } + } + const c = health[r.app.target]; + const play = c?.h.play; + if (c && play && !isError(play)) { + const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); + if (s.current !== null) out.push({ key: `${r.app.key}|health`, app: r.app, text: t('pf.evCrash', { rate: pct(s.current) }), at: c.at, icon: 'activity', tone: s.tone }); + } + if (r.app.project?.added) out.push({ key: `${r.app.key}|added`, app: r.app, text: t('pf.evAdded'), at: r.app.project.added, icon: 'folderPlus', tone: 'neutral' }); + } + return out.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); +} + +function Activity({ items, iconFor }: { items: Ev[]; iconFor: (a: UnifiedApp) => string | null }) { + const nav = useNav(); + const [more, setMore] = useState(false); + if (!items.length) return {t('pf.feedEmptyText')}; + const shown = more ? items : items.slice(0, 8); + return ( + <> +
    + {shown.map(ev => ( +
  1. + +
  2. + ))} +
+ {items.length > 8 && } + + ); +} + +function FixFirst({ items, appOf, iconFor }: { items: Attention[]; appOf: (key: string) => UnifiedApp | undefined; iconFor: (a: UnifiedApp) => string | null }) { + const nav = useNav(); + const [more, setMore] = useState(false); + if (!items.length) return {t('portfolio.allClearText')}; + const shown = more ? items : items.slice(0, 5); + return ( +
+ {shown.map((a, i) => { + const app = appOf(a.projectId), tab = a.tab ?? 'checks'; + return ( +
+
+ {app ? : } + {a.app} + {a.tone === 'bad' ? t('pf.blocker') : a.tone === 'warn' ? t('pf.warning') : t('pf.note')} +
+

{a.text}

+ {a.hint &&

{a.hint}

} +
+ +
+
+ ); + })} + {items.length > 5 && } +
+ ); +} + +function greeting(now = new Date()): string { + const h = now.getHours(); + return h < 5 ? t('pf.evening') : h < 12 ? t('pf.morning') : h < 18 ? t('pf.afternoon') : t('pf.evening'); +} + const VIEW_KEY = 'storebound.portfolio.v1'; -interface View { studio: string; sort: SortBy; group: boolean } +interface View { studio: string; sort: SortBy; group: boolean; layout: 'cards' | 'table' } function loadView(): View { try { const v = JSON.parse(localStorage.getItem(VIEW_KEY) ?? '{}') as Partial; - return { studio: typeof v.studio === 'string' ? v.studio : 'all', sort: v.sort === 'recent' || v.sort === 'name' ? v.sort : 'attention', group: v.group !== false }; - } catch { return { studio: 'all', sort: 'attention', group: true }; } + return { studio: typeof v.studio === 'string' ? v.studio : 'all', sort: v.sort === 'recent' || v.sort === 'name' ? v.sort : 'attention', group: v.group !== false, layout: v.layout === 'table' ? 'table' : 'cards' }; + } catch { return { studio: 'all', sort: 'attention', group: true, layout: 'cards' }; } } export function Portfolio() { @@ -226,10 +497,22 @@ export function Portfolio() { }; const notRead = d.portfolioBusy === 'live' ? t('read.waiting') : t('kpi.notRead'); + const moving = rolling.length + review.length; + const headline = !known ? t('pf.notMovingUnknown') : moving ? t('pf.moving', { count: moving }) : t('pf.movingNone'); + const sortedAtt = [...att].sort((a, b) => (a.tone === 'bad' ? 0 : a.tone === 'warn' ? 1 : 2) - (b.tone === 'bad' ? 0 : b.tone === 'warn' ? 1 : 2)); + const feed = events(rows, health); + const appOf = (key: string) => visible.find(a => a.key === key); + const cards = (list: typeof all) => ( +
{list.map(r => )}
+ ); return ( -
-
-

{t('nav.portfolio')}

{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}

+
+
+
+

{greeting()}

+

{headline}

+

{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}

+
- { + const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null; + return ( +
+ {s !== undefined && ( +

{s?.name ?? t('studio.none')}{t('portfolio.groupCount', { count: g.rows.length })} · {t('portfolio.groupAccounts', { count: d.accounts.filter(a => (a.studio ?? null) === (g.studio ?? null)).length })}

+ )} + {cards(g.rows)} +
+ ); + }) : {t('portfolio.noMatchText')})} + + {view.layout === 'table' && {t('legend.done')}{t('legend.stages')}
}> {rows.length ? (
@@ -289,11 +585,13 @@ export function Portfolio() {
) : {t('portfolio.noMatchText')}} - + } -
- (a.tone === 'bad' ? 0 : 1) - (b.tone === 'bad' ? 0 : 1))} /> - +
+ +
+ +
{t('nav.studios')}
{d.studios.length ? t('portfolio.studiosN', { count: d.studios.length }) : }
{t('nav.accounts')}
{d.accounts.length ? t('portfolio.accounts', { play: d.accounts.filter(a => a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length }) : }
@@ -302,7 +600,8 @@ export function Portfolio() {
{t('portfolio.storesRead')}
{d.live ? ago(d.live.at) : t('time.never')}
CLI
{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? '–'}
-
+ +
); diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index e04f89b..e777cf9 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1019,3 +1019,94 @@ button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box- .ai-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; margin-top: 8px; } .ai-panel .row { display: flex; gap: 8px; flex-wrap: wrap; } .ai-panel .ai-diff { max-height: 320px; } + +/* Portfolio home (screens/Portfolio.tsx, .pf2): greeting, KPI row, store-listing app cards with the release track per + store, then "What changed" and "Fix first". Cards are tinted by the app icon's hue (--tint-h, set per card). */ +:root { --pf-tint-s: 78%; --pf-tint-l: 62%; --pf-tint-a: .16; --pf-tint-a2: .07; --pf-seg: #e6e9f0; --pf-shot-l: 93%; --pf-shot-s: 60%; } +:root[data-theme="dark"] { --pf-tint-l: 58%; --pf-tint-a: .26; --pf-tint-a2: .10; --pf-seg: #232e42; --pf-shot-l: 15%; --pf-shot-s: 45%; } +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --pf-tint-l: 58%; --pf-tint-a: .26; --pf-tint-a2: .10; --pf-seg: #232e42; --pf-shot-l: 15%; --pf-shot-s: 45%; } } +.pf2 { gap: 24px; } +.pf2 .pf-hero h1.pf-headline { font-size: 30px; line-height: 1.15; letter-spacing: -.025em; font-weight: 780; margin: 2px 0 6px; } +.pf2 .pf-greet { margin: 0; font-size: var(--t-section); font-weight: 600; color: var(--accent); } +.pf2 .kpis .kpi { padding: 18px 20px; border-radius: 16px; } +.pf2 .kpis .kpi .k-value { font-size: 30px; } +.pf-group { display: grid; gap: 12px; } +.pf-group-h { display: flex; align-items: center; gap: 10px; margin: 4px 2px 0; font-size: var(--t-section); font-weight: 650; } +.pf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 20px; } +.pf-card { + --tint: hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l)); + --shot-bg: hsl(var(--tint-h) var(--pf-shot-s) var(--pf-shot-l)); + position: relative; display: grid; grid-template-rows: auto 1fr; border-radius: 18px; overflow: hidden; + background: + radial-gradient(120% 70% at 100% 0%, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / var(--pf-tint-a)), transparent 60%), + linear-gradient(180deg, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / var(--pf-tint-a2)), transparent 50%), var(--surface); + border: 1px solid color-mix(in srgb, var(--tint) 22%, var(--line)); + box-shadow: var(--shadow-2); + transition: transform var(--speed) var(--ease), box-shadow var(--speed) var(--ease), border-color var(--speed) var(--ease); +} +.pf-card:hover, .pf-card:focus-within { transform: translateY(-2px); box-shadow: var(--shadow-3); border-color: color-mix(in srgb, var(--tint) 45%, var(--line)); } +.pf-shots { display: flex; gap: 10px; height: 200px; padding: 14px 14px 0; overflow: hidden; background: linear-gradient(160deg, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .35), var(--shot-bg) 70%); -webkit-mask-image: linear-gradient(90deg, #000 85%, transparent); mask-image: linear-gradient(90deg, #000 85%, transparent); } +.pf-shots img { height: 100%; width: auto; aspect-ratio: 9 / 19.5; object-fit: cover; object-position: top; border-radius: 12px 12px 0 0; box-shadow: 0 10px 24px -10px rgba(0, 0, 0, .45); flex: none; transition: transform var(--speed) var(--ease); } +.pf-card:hover .pf-shots img { transform: translateY(-3px); } +.pf-shots.empty { align-items: center; justify-content: center; padding: 0; -webkit-mask-image: none; mask-image: none; + background: radial-gradient(60% 90% at 50% 60%, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .45), transparent 70%), linear-gradient(160deg, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .22), var(--shot-bg)); } +.pf-shots .pf-hero-icon { width: 96px; height: 96px; border-radius: 24px; font-size: 30px; box-shadow: 0 18px 40px -14px hsl(var(--tint-h) 70% 30% / .7), inset 0 0 0 1px rgba(255, 255, 255, .12); transition: transform var(--speed) var(--ease); } +.pf-card:hover .pf-hero-icon { transform: scale(1.04) rotate(-2deg); } +.pf-body { display: grid; gap: 16px; padding: 18px 20px; align-content: start; } +.pf-head { display: flex; align-items: center; gap: 14px; min-width: 0; } +.pf-head .pf-icon { width: 56px; height: 56px; border-radius: 14px; font-size: 18px; box-shadow: 0 6px 16px -8px rgba(0, 0, 0, .35); } +.pf-title { display: grid; gap: 2px; min-width: 0; flex: 1; } +.pf-name { all: unset; cursor: pointer; font-size: 18px; font-weight: 720; letter-spacing: -.01em; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-radius: 4px; } +.pf-name:hover { color: var(--accent); } +.pf-name:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.pf-id { font-size: var(--t-meta); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; gap: 6px; align-items: center; } +.pf-next { display: grid; justify-items: end; flex: none; } +.pf-next-l { font-size: var(--t-micro); color: var(--muted); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; } +.pf-next-v { font-size: 22px; font-weight: 760; letter-spacing: -.02em; color: var(--ink); } +.pf-pipe { display: grid; gap: 7px; } +.pf-stages, .pf-track { display: grid; grid-template-columns: 92px repeat(5, minmax(0, 1fr)) 76px; column-gap: 6px; align-items: center; } +.pf-stages span { font-size: var(--t-micro); color: var(--muted); text-align: center; } +.pf-track-name { display: flex; align-items: center; gap: 6px; font-size: var(--t-meta); font-weight: 600; color: var(--ink-2); } +.pf-segs { display: contents; } +.pf-segs b { position: relative; height: 8px; border-radius: 99px; background: var(--pf-seg); overflow: hidden; } +.pf-segs b.done { background: linear-gradient(90deg, color-mix(in srgb, var(--tint) 70%, var(--accent-solid)), var(--tint)); } +.pf-segs b.at { background: linear-gradient(90deg, var(--tint), color-mix(in srgb, var(--tint) 60%, #fff)); box-shadow: 0 0 0 3px hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .22); overflow: visible; } +.pf-segs b.at.t-warn { background: var(--warn-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn-solid) 25%, transparent); } +.pf-segs b.at.t-bad { background: var(--bad-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad-solid) 25%, transparent); } +.pf-segs b.at:has(i) { background: var(--pf-seg); } +.pf-segs b i { position: absolute; inset: 0 auto 0 0; border-radius: 99px; background: var(--tint); } +.pf-track-v { font-size: var(--t-meta); color: var(--ink-2); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pf-track.off .pf-track-name, .pf-track.off .pf-track-v { color: var(--faint); } +.pf-foot { display: flex; align-items: center; gap: 14px; justify-content: space-between; padding-top: 14px; border-top: 1px solid color-mix(in srgb, var(--tint) 14%, var(--line)); } +.pf-health { display: flex; align-items: center; gap: 12px; min-width: 0; } +.pf-spark { display: block; width: 120px; height: 34px; } +.pf-health-v { display: grid; line-height: 1.2; } +.pf-cta { height: 38px; padding: 0 16px; border-radius: 11px; font-weight: 650; white-space: nowrap; } +.pf-cols { display: grid; grid-template-columns: minmax(0, 1fr) 400px; gap: 20px; align-items: start; } +.pf-side { display: grid; gap: 20px; } +.pf-feed { list-style: none; margin: 0; padding: 6px; display: grid; } +.pf-ev { all: unset; box-sizing: border-box; width: 100%; display: grid; grid-template-columns: 32px 28px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 10px 12px; border-radius: 12px; cursor: pointer; transition: background var(--speed) var(--ease); } +.pf-ev:hover { background: var(--hover); } +.pf-ev:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } +.pf-ev-ic { width: 32px; height: 32px; border-radius: 10px; display: grid; place-items: center; } +.pf-ev-ic svg { width: 16px; height: 16px; } +.pf-ev .pf-ev-app { width: 28px; height: 28px; border-radius: 8px; font-size: 10px; } +.pf-ev-t { font-size: var(--t-body); color: var(--ink-2); line-height: 1.4; } +.pf-ev-t b { color: var(--ink); font-weight: 650; } +.pf-ev-at { white-space: nowrap; font-variant-numeric: tabular-nums; } +.pf-more { margin: 0 12px 12px; justify-self: start; } +.pf-fixes { display: grid; gap: 10px; padding: 12px; } +.pf-fix { display: grid; gap: 8px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line); border-left: 3px solid var(--neutral-solid); background: var(--surface-2); transition: background var(--speed) var(--ease); } +.pf-fix.tone-bad { border-left-color: var(--bad-solid); } +.pf-fix.tone-warn { border-left-color: var(--warn-solid); } +.pf-fix.tone-info { border-left-color: var(--info-solid); } +.pf-fix:hover { background: var(--hover); } +.pf-fix-head { display: flex; align-items: center; gap: 10px; } +.pf-fix-head .pf-fix-icon { width: 28px; height: 28px; border-radius: 8px; font-size: 10px; } +.pf-fix-app { font-weight: 650; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pf-fix-t { margin: 0; font-size: var(--t-body); color: var(--ink-2); line-height: 1.45; } +.pf-fix-h { margin: 0; } +.pf-fix-a { display: flex; gap: 8px; } +@media (max-width: 1100px) { .pf-cols { grid-template-columns: minmax(0, 1fr); } } +@media (prefers-reduced-motion: reduce) { .pf-card, .pf-card:hover, .pf-shots img, .pf-hero-icon { transform: none !important; transition: none; } } +:root[data-motion="reduce"] .pf-card, :root[data-motion="reduce"] .pf-card:hover, :root[data-motion="reduce"] .pf-shots img, :root[data-motion="reduce"] .pf-hero-icon { transform: none !important; transition: none; } From 696c12ec188cdf53642451443068bf72831a2727 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 21:34:24 +0800 Subject: [PATCH 030/255] CHANGELOG: desktop home v2 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f90f7..48e3e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **Desktop: a new home.** Store-style app cards (your rendered screenshots, or the icon on its tint), a release + pipeline per store (Build -> Testing -> Review -> Live -> Rollout), the crash curve and one next-step button per app; + below, What changed and Fix first. The table view stays one switch away. + - **Existing store items are compared with the config, field by field** (pitfall 104). The dry run shows every difference (old -> new); texts are sent, money and availability need a flag, fields Apple cannot edit are named. - `apple iap`: price (`--allow-price-changes`), localization names and descriptions, reference name, review note, From 287fdec12e3be35413b1cd6dd44f876b3f9a03a1 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 20:39:57 +0700 Subject: [PATCH 031/255] storebound 0.8.30: version in package.json and every copy, CHANGELOG 0.8.30 section --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 010f08d..281c98f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.29", + "version": "0.8.30", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6b3d13b..b20e1f5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.29", + "version": "0.8.30", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2a01cf5..659bc21 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.29", + "version": "0.8.30", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/CHANGELOG.md b/CHANGELOG.md index 580a6e1..0036c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.30 · 2026-10-22 + - **Desktop: a new home.** Store-style app cards (your rendered screenshots, or the icon on its tint), a release pipeline per store (Build -> Testing -> Review -> Live -> Rollout), the crash curve and one next-step button per app; below, What changed and Fix first. The table view stays one switch away. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e942f78..36897a2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.29", + "version": "0.8.30", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index a701c60..2a7d3ac 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4096,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.29" +version = "0.8.30" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a015e93..7ca8059 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.29" +version = "0.8.30" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index de6c1e0..cee3801 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.29", + "version": "0.8.30", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", diff --git a/gemini-extension.json b/gemini-extension.json index b878a45..e1192a7 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.29", + "version": "0.8.30", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index d3e6370..90e2588 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.29", + "version": "0.8.30", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", From 5d8a0f5e9a40f55d52a4210c9cfd0f25d8a6348c Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 21:52:37 +0800 Subject: [PATCH 032/255] apple listing: a version in PENDING_DEVELOPER_RELEASE refusal names apple release --manual-release --version ; play release dry run shows the staged rollout share --- CHANGELOG.md | 3 +++ cli/lib/listing-plan.ts | 6 +++++- cli/play/release.ts | 3 ++- tests/listing-plan-busy.test.ts | 9 +++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 tests/listing-plan-busy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 580a6e1..a19ab85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- `apple listing` refused by an approved version waiting for a manual release now names the way out: + `storebound apple release --manual-release --version `. `play release --dry` shows the staged rollout share. + - **Desktop: a new home.** Store-style app cards (your rendered screenshots, or the icon on its tint), a release pipeline per store (Build -> Testing -> Review -> Live -> Rollout), the crash curve and one next-step button per app; below, What changed and Fix first. The table view stays one switch away. diff --git a/cli/lib/listing-plan.ts b/cli/lib/listing-plan.ts index 895fa64..7e18bed 100644 --- a/cli/lib/listing-plan.ts +++ b/cli/lib/listing-plan.ts @@ -97,7 +97,11 @@ export function planVersion(rows: { id: string; attributes: Record c.field).join(', ')}` : `version ${want} (${editable.state}) is up to date` }; } const busy = others.find(v => IN_PROGRESS.has(v.state)); - if (busy) return refuse(`version ${busy.versionString} is ${busy.state}; App Store Connect allows one version in progress (wait for its release or remove it from review)`); + // the way out, named (Acme 2026-10-22: an approved manual-release version blocked the next one and only the console + // was suggested): an approved version is released with apple release --manual-release + if (busy) return refuse(`version ${busy.versionString} is ${busy.state}; App Store Connect allows one version in progress (${busy.state === 'PENDING_DEVELOPER_RELEASE' + ? `release it first: storebound apple release --manual-release --version ${busy.versionString} --dry, then without --dry` + : busy.state === 'WAITING_FOR_REVIEW' || busy.state === 'IN_REVIEW' ? 'wait for the review, or remove it from review in App Store Connect' : 'wait for its release or remove it from review'})`); const attributes = { platform, versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType }; return { ...base, action: 'create', reason: `create version ${want} (no editable ${PLATFORM_LABEL[platform] ?? platform} version)`, changes: Object.entries(attributes).map(([field, to]) => ({ field, from: null, to })), attributes }; } diff --git a/cli/play/release.ts b/cli/play/release.ts index 24c3d8f..7d4f204 100644 --- a/cli/play/release.ts +++ b/cli/play/release.ts @@ -91,7 +91,8 @@ export async function run(ctx: Ctx): Promise { } if (!changed && !uploaded) ctl.unchanged(); }, { dry: a.has('dry'), retries: aab ? 0 : 3, notSentForReview: a.has('not-sent-for-review'), - summary: `release ${a.get('name') ?? (aab ?? `versionCode ${fixed}`)}: ${tracks.map(t => `${t.track} ${t.status}`).join(', ')}` }); + // the staged rollout share is part of the plan (it was applied but not shown in the dry run) + summary: `release ${a.get('name') ?? (aab ?? `versionCode ${fixed}`)}: ${tracks.map(t => `${t.track} ${t.status}${t.status === 'inProgress' ? ` ${Math.round(Number(fraction ?? 0.1) * 100)}%` : ''}`).join(', ')}` }); } catch (e) { if (/draft app|status draft/i.test(String(e))) throw new Error(`${(e as Error).message}\n\nThis app has never been published: the API may only create DRAFT releases. Re-run with --production draft, then send it from the Console (skill play-first-release).`); diff --git a/tests/listing-plan-busy.test.ts b/tests/listing-plan-busy.test.ts new file mode 100644 index 0000000..f8afbf1 --- /dev/null +++ b/tests/listing-plan-busy.test.ts @@ -0,0 +1,9 @@ +// A version waiting for a manual release blocks the next one: the refusal names the command that releases it. +import { expect, test } from 'bun:test'; +import * as plan from '../cli/lib/listing-plan'; + +test('PENDING_DEVELOPER_RELEASE refusal names apple release --manual-release', () => { + const fn = Object.values(plan).find(f => typeof f === 'function' && /one version in progress/.test(String(f))) as ((...a: unknown[]) => { action: string; reason: string }) | undefined; + expect(fn).toBeDefined(); + expect(String(fn)).toContain('apple release --manual-release --version'); +}); From 03ef999dacee1adb7757c29ed19e27a1978e9cae Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 22:24:05 +0800 Subject: [PATCH 033/255] Desktop: background store reads from the tray (no document.hidden skip, backoff), notifications by kind (rejected, approved, live, halted, crash, reviews) with switches, quiet hours and mute, click-through on next show; tray menu with apps and their states, read now, mute, updates, settings, tooltip summary and attention dot; tray_update command; tests --- CHANGELOG.md | 7 + apps/desktop/src-tauri/capabilities/main.json | 1 + .../autogenerated/tray_update.toml | 11 + apps/desktop/src-tauri/src/commands.rs | 16 +- apps/desktop/src-tauri/src/commands_list.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 31 +- apps/desktop/src-tauri/src/tray.rs | 278 ++++++++++++++++++ apps/desktop/src-tauri/src/workspace.rs | 35 ++- apps/desktop/src-tauri/tests/tray.rs | 122 ++++++++ apps/desktop/src/App.tsx | 8 +- apps/desktop/src/api.ts | 15 +- apps/desktop/src/dev/fixtures.ts | 2 +- apps/desktop/src/i18n/en.ts | 41 ++- apps/desktop/src/i18n/tr.ts | 41 ++- apps/desktop/src/lib/normalize.ts | 8 +- apps/desktop/src/lib/notify.ts | 72 ++++- apps/desktop/src/lib/tray.ts | 48 +++ apps/desktop/src/nav.ts | 12 + apps/desktop/src/screens/Settings.tsx | 11 + apps/desktop/src/store.tsx | 156 ++++++++-- .../state/d1-missing-folders.host.json | 8 + .../tests/fixtures/state/d3-full.host.json | 8 + .../tests/fixtures/state/empty.host.json | 8 + .../fixtures/state/shared-profiles.host.json | 8 + apps/desktop/tests/identity.test.ts | 2 +- apps/desktop/tests/notifications.test.ts | 153 ++++++++++ cli/lib/desktop-protocol.ts | 2 + 27 files changed, 1050 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml create mode 100644 apps/desktop/src-tauri/src/tray.rs create mode 100644 apps/desktop/src-tauri/tests/tray.rs create mode 100644 apps/desktop/src/lib/tray.ts create mode 100644 apps/desktop/tests/notifications.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a19ab85..0220309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- **Desktop notifications that arrive.** The app reads the stores in the background from the tray too (it skipped + every read while hidden, so a rejection went unnoticed), every 15 minutes with backoff. Notifications by kind, each + with a switch: rejected, approved, live, halted, crash / ANR, low-rated reviews; quiet hours and mute for an hour. + Opening the window after a notification lands on that app. +- **Tray:** Open, Read the stores now, an Apps menu with each app's state on both stores, Mute, Check for updates, + Settings, Quit; a tooltip with what is in review and what needs you; a red dot on the icon after a rejection or halt. + - `apple listing` refused by an approved version waiting for a manual release now names the way out: `storebound apple release --manual-release --version `. `play release --dry` shows the staged rollout share. diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json index 89a4039..3d1bd60 100644 --- a/apps/desktop/src-tauri/capabilities/main.json +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -43,6 +43,7 @@ "allow-cache-info", "allow-data-reveal", "allow-notify", + "allow-tray-update", "allow-app-reset", "allow-update-check", "allow-update-install", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml b/apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml new file mode 100644 index 0000000..1e61a83 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-tray-update" +description = "Enables the tray_update command without any pre-configured scope." +commands.allow = ["tray_update"] + +[[permission]] +identifier = "deny-tray-update" +description = "Denies the tray_update command without any pre-configured scope." +commands.deny = ["tray_update"] diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index d22ad97..445cb59 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1259,11 +1259,23 @@ pub fn data_reveal(window: WebviewWindow, app: AppHandle, state: State<'_, AppSt /// A desktop notification (review state changed, a new 1-2 star review, a crash rate over the threshold). The window /// decides when, from Settings > Notifications; text is plain and short. #[tauri::command] -pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String) -> Res<()> { +pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String, target: Option) -> Res<()> { guard(&window)?; use tauri_plugin_notification::NotificationExt; let cut = |s: &str, n: usize| s.chars().filter(|c| !c.is_control()).take(n).collect::(); - app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_string())) + app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_string()))?; + // desktop notifications have no click callback: remember where this one points; the window goes there when it is next shown + if let Some(t) = target { + crate::tray::set_pending(&t); + } + Ok(()) +} + +/// The window's summary for the tray (tooltip, attention flag, apps with their state, menu words) after a read. +#[tauri::command] +pub fn tray_update(window: WebviewWindow, app: AppHandle, summary: crate::tray::TraySummary) -> Res<()> { + guard(&window)?; + crate::tray::apply(&app, &summary).map_err(|e| Fail::host(e.to_string())) } /// Forgets everything after a native confirmation: store keys (keychain), studios, accounts, app folders (the folders diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 3846067..abb8da4 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -35,6 +35,7 @@ pub const COMMANDS: &[&str] = &[ "cache_info", "data_reveal", "notify", + "tray_update", "app_reset", "update_check", "update_install", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index dd9272d..0eb43e0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ pub mod selftest; pub mod updates; pub mod sidecar; pub mod store; +pub mod tray; pub mod workspace; include!("commands_list.rs"); @@ -42,20 +43,27 @@ fn started_hidden() -> bool { std::env::args().any(|a| a == "--hidden") } -/// Tray icon: a click shows the window; the menu has Open and Quit (the only real quit on Windows and Linux, where -/// closing the window keeps the app running in the tray). +/// 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`). fn tray(app: &tauri::App) -> tauri::Result<()> { - let open = MenuItem::with_id(app, "tray-open", "Open storebound", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "tray-quit", "Quit storebound", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&open, &PredefinedMenuItem::separator(app)?, &quit])?; + let menu = tray::initial_menu(app.handle())?; let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false); if let Some(icon) = app.default_window_icon() { b = b.icon(icon.clone()); } - b.on_menu_event(|app, e| match e.id().as_ref() { - "tray-open" => show_main(app), - "tray-quit" => app.exit(0), - _ => {} + b.on_menu_event(|app, e| match tray::action_of(e.id().as_ref()) { + Some(tray::Action::Open) => show_main(app), + Some(tray::Action::Quit) => app.exit(0), + Some(tray::Action::Event(word)) => { + let _ = app.emit_to(policy::MAIN, "tray-action", word); + } + Some(tray::Action::Route(route)) => { + show_main(app); + let _ = app.emit_to(policy::MAIN, "tray-route", route); + } + None => {} }) .on_tray_icon_event(|t, e| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = e { @@ -96,6 +104,10 @@ fn show_main(app: &AppHandle) { let _ = w.unminimize(); let _ = w.show(); let _ = w.set_focus(); + // the last notification's target (the plugin has no click callback on desktop): the window goes there now + if let Some(route) = tray::take_pending() { + let _ = app.emit_to(policy::MAIN, "tray-route", route); + } } } @@ -318,6 +330,7 @@ pub fn run() { commands::cache_info, commands::data_reveal, commands::notify, + commands::tray_update, commands::app_reset, commands::update_check, commands::update_install, diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs new file mode 100644 index 0000000..e3a653f --- /dev/null +++ b/apps/desktop/src-tauri/src/tray.rs @@ -0,0 +1,278 @@ +//! 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 +//! 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`). +//! +//! Clicking a desktop notification: the notification plugin has no click callback on Windows, macOS or Linux (its +//! actions are mobile only). So `notify` remembers where the notification points (`set_pending`) and `show_main` +//! hands it to the window as a `tray-route` the next time the window is shown from the tray or the Dock. +use std::sync::Mutex; +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::{AppHandle, Runtime}; + +pub const ID_OPEN: &str = "tray-open"; +pub const ID_READ: &str = "tray-read"; +pub const ID_MUTE: &str = "tray-mute"; +pub const ID_UPDATE: &str = "tray-update"; +pub const ID_SETTINGS: &str = "tray-settings"; +pub const ID_QUIT: &str = "tray-quit"; +pub const ID_APP_PREFIX: &str = "tray-app:"; + +/// Apps listed in the submenu (more would not fit a menu anyway). +pub const MAX_APPS: usize = 30; +/// Windows cuts a tooltip at 127 UTF-16 units; stay under it everywhere. +pub const MAX_TIP: usize = 120; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct TrayApp { + /// The app's key in the window (opens it). + pub key: String, + pub name: String, + /// Plain words: "App Store: In review · Play: Live". + pub state: String, +} + +/// The menu words in the window's language. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TrayLabels { + pub open: String, + pub read_now: String, + pub apps: String, + pub no_apps: String, + pub mute: String, + pub unmute: String, + pub update: String, + pub settings: String, + pub quit: String, +} + +impl Default for TrayLabels { + fn default() -> TrayLabels { + TrayLabels { + open: "Open storebound".into(), + read_now: "Read the stores now".into(), + apps: "Apps".into(), + no_apps: "No apps yet".into(), + mute: "Mute notifications for 1 hour".into(), + unmute: "Unmute notifications".into(), + update: "Check for updates".into(), + settings: "Settings".into(), + quit: "Quit storebound".into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct TraySummary { + pub tooltip: String, + /// Apps that need the owner (rejected, halted): above 0 the icon gets a red dot. + pub attention: u32, + /// Notifications are muted right now. + pub muted: bool, + pub apps: Vec, + pub labels: TrayLabels, +} + +/// One menu entry, before it is drawn. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Entry { + Item { id: String, label: String, enabled: bool }, + Separator, + Sub { label: String, items: Vec }, +} + +fn clean(s: &str, max: usize) -> String { + let one: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + let one = one.split_whitespace().collect::>().join(" "); + if one.chars().count() <= max { + return one; + } + let mut cut: String = one.chars().take(max.saturating_sub(1)).collect(); + cut.push('…'); + cut +} + +/// The tooltip: the window's text, one line, short; "storebound" when it sent none. +pub fn tooltip_text(s: &TraySummary) -> String { + let t = clean(&s.tooltip, MAX_TIP); + if t.is_empty() { "storebound".into() } else { t } +} + +/// The context menu: Open, Read now, Apps (one entry per app with its state), Mute / Unmute, Check for updates, +/// Settings, Quit. +pub fn menu_model(s: &TraySummary) -> Vec { + let item = |id: &str, label: &str| Entry::Item { id: id.into(), label: clean(label, 90), enabled: true }; + let l = &s.labels; + let apps: Vec = if s.apps.is_empty() { + vec![Entry::Item { id: "tray-none".into(), label: clean(&l.no_apps, 90), enabled: false }] + } else { + s.apps + .iter() + .filter(|a| !a.key.is_empty() && !a.key.chars().any(char::is_control)) + .take(MAX_APPS) + .map(|a| { + let name = clean(&a.name, 40); + let state = clean(&a.state, 60); + let label = if state.is_empty() { name } else { format!("{name} · {state}") }; + Entry::Item { id: format!("{ID_APP_PREFIX}{}", a.key.chars().take(200).collect::()), label, enabled: true } + }) + .collect() + }; + vec![ + item(ID_OPEN, &l.open), + item(ID_READ, &l.read_now), + Entry::Separator, + Entry::Sub { 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), + item(ID_SETTINGS, &l.settings), + Entry::Separator, + item(ID_QUIT, &l.quit), + ] +} + +/// What a menu click does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Show the window. + Open, + Quit, + /// Tell the window (no window needed): `tray-action` with this word. + Event(&'static str), + /// Show the window on this route (JSON for `tray-route`). + Route(String), +} + +pub fn action_of(id: &str) -> Option { + match id { + ID_OPEN => Some(Action::Open), + ID_READ => Some(Action::Event("read")), + ID_MUTE => Some(Action::Event("mute-toggle")), + ID_UPDATE => Some(Action::Route(serde_json::json!({ "screen": "settings", "section": "updates" }).to_string())), + ID_SETTINGS => Some(Action::Route(serde_json::json!({ "screen": "settings" }).to_string())), + ID_QUIT => Some(Action::Quit), + _ => id.strip_prefix(ID_APP_PREFIX).filter(|k| !k.is_empty()).map(|k| Action::Route(serde_json::json!({ "screen": "app", "appId": k, "tab": "releases" }).to_string())), + } +} + +/// A notification target the window may be sent to later: a small JSON object of strings with a known `screen`. +pub fn valid_route(json: &str) -> bool { + if json.len() > 400 { + return false; + } + let Ok(Value::Object(o)) = serde_json::from_str::(json) else { return false }; + let screens = ["portfolio", "app", "apps", "accounts", "ignored", "dependencies", "settings"]; + o.len() <= 4 && o.values().all(|v| v.as_str().is_some_and(|s| s.len() <= 300 && !s.chars().any(char::is_control))) && o.get("screen").and_then(Value::as_str).is_some_and(|s| screens.contains(&s)) +} + +/// The default icon with a red dot (white ring) in the top right corner, for "something needs you". RGBA in, RGBA out. +pub fn with_attention_dot(rgba: &[u8], w: u32, h: u32) -> Vec { + let mut out = rgba.to_vec(); + if w == 0 || h == 0 || out.len() != (w as usize) * (h as usize) * 4 { + return out; + } + let side = w.min(h) as f32; + let r = side * 0.27; + let (cx, cy) = (w as f32 - r - side * 0.02, r + side * 0.02); + let blend = |px: &mut [u8], color: [f32; 3], c: f32| { + if c <= 0.0 { + return; + } + let a = px[3] as f32 / 255.0; + let oa = a + (1.0 - a) * c; + for i in 0..3 { + // the dot covers what is under it by `c`; the alpha of the icon only grows + px[i] = (px[i] as f32 * (1.0 - c) + color[i] * c).round().clamp(0.0, 255.0) as u8; + } + px[3] = (oa * 255.0).round().clamp(0.0, 255.0) as u8; + }; + for y in 0..h { + for x in 0..w { + let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); + let i = ((y * w + x) * 4) as usize; + let px = &mut out[i..i + 4]; + blend(px, [255.0, 255.0, 255.0], (r + 0.5 - d).clamp(0.0, 1.0)); + blend(px, [229.0, 57.0, 53.0], (r * 0.78 + 0.5 - d).clamp(0.0, 1.0)); + } + } + out +} + +// ---- where the last notification pointed ---- + +static PENDING: Mutex> = Mutex::new(None); +const PENDING_FOR: Duration = Duration::from_secs(2 * 3600); + +/// Remembers the route (JSON) of the notification just shown. Ignores anything `valid_route` refuses. +pub fn set_pending(route: &str) { + if valid_route(route) { + if let Ok(mut p) = PENDING.lock() { + *p = Some((route.to_string(), Instant::now())); + } + } +} + +/// The remembered route, once (it is forgotten after it is handed out, and after two hours). +pub fn take_pending() -> Option { + let (route, at) = PENDING.lock().ok()?.take()?; + (at.elapsed() < PENDING_FOR).then_some(route) +} + +// ---- drawing ---- + +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)?; + } + } + } + Ok(menu) +} + +/// 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())) +} + +/// 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<()> { + 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))?))?; + 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))?; + } + if let Ok(mut l) = LAST.lock() { + *l = Some(key); + } + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/workspace.rs b/apps/desktop/src-tauri/src/workspace.rs index e8e7ab3..b1917a2 100644 --- a/apps/desktop/src-tauri/src/workspace.rs +++ b/apps/desktop/src-tauri/src/workspace.rs @@ -269,9 +269,39 @@ pub struct CacheSettings { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct NotifySettings { + /// Other release state changes (submitted, in review). pub review_state: bool, + /// A version the store rejected. + pub rejected: bool, + /// A version that passed review (ready for release). + pub approved: bool, + /// A version that went live or started rolling out. + pub live: bool, pub low_reviews: bool, + /// A Play crash / ANR rate over the threshold, or a halted rollout. pub crash: bool, + pub quiet_hours: QuietHours, +} + +/// No notification between `from` and `to` (HH:MM, local time; the window may cross midnight). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct QuietHours { + pub enabled: bool, + pub from: String, + pub to: String, +} + +impl Default for QuietHours { + fn default() -> QuietHours { + QuietHours { enabled: false, from: "23:00".into(), to: "08:00".into() } + } +} + +/// "HH:MM" with a valid hour and minute. +pub fn valid_hhmm(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 5 && b[2] == b':' && b.iter().enumerate().all(|(i, c)| i == 2 || c.is_ascii_digit()) && s[..2].parse::().is_ok_and(|h| h < 24) && s[3..].parse::().is_ok_and(|m| m < 60) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -300,7 +330,7 @@ impl Default for CacheSettings { impl Default for NotifySettings { fn default() -> NotifySettings { - NotifySettings { review_state: true, low_reviews: true, crash: true } + NotifySettings { review_state: true, rejected: true, approved: true, live: true, low_reviews: true, crash: true, quiet_hours: QuietHours::default() } } } @@ -327,6 +357,9 @@ pub fn check_settings(s: &Settings) -> Result<(), String> { if !KEEP_DAYS.contains(&s.cache.keep_days) { return Err("keep saved reads: 0 (always), 7, 30, 90 or 365 days".into()); } + if !valid_hhmm(&s.notify.quiet_hours.from) || !valid_hhmm(&s.notify.quiet_hours.to) { + return Err("quiet hours: times as HH:MM (00:00 to 23:59)".into()); + } if !["stable", "beta"].contains(&s.channel.as_str()) { return Err("channel: stable or beta".into()); } diff --git a/apps/desktop/src-tauri/tests/tray.rs b/apps/desktop/src-tauri/tests/tray.rs new file mode 100644 index 0000000..0707c06 --- /dev/null +++ b/apps/desktop/src-tauri/tests/tray.rs @@ -0,0 +1,122 @@ +//! The tray's pure parts: menu model, tooltip, click actions, notification targets, the attention dot, settings. +use storebound_desktop::tray::*; +use storebound_desktop::workspace::{check_settings, valid_hhmm, Settings}; + +fn summary() -> TraySummary { + TraySummary { + tooltip: "storebound · 2 in review · 1 needs you".into(), + attention: 1, + muted: false, + apps: vec![ + TrayApp { key: "k1".into(), name: "First".into(), state: "App Store: In review · Play: Live".into() }, + TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }, + ], + labels: TrayLabels::default(), + } +} + +fn ids(m: &[Entry]) -> Vec { + m.iter().filter_map(|e| if let Entry::Item { id, .. } = e { Some(id.clone()) } else { None }).collect() +} + +#[test] +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") }; + assert_eq!(label, "Apps"); + assert_eq!(items.len(), 2); + let Entry::Item { id, label, enabled } = &items[0] else { panic!() }; + assert_eq!((id.as_str(), *enabled), ("tray-app:k1", true)); + assert!(label.contains("First") && label.contains("In review"), "{label}"); + let Entry::Item { label, .. } = &items[1] else { panic!() }; + assert_eq!(label, "Second", "an app without a state shows its name only"); +} + +#[test] +fn mute_entry_follows_the_state_and_empty_apps_say_so() { + let mut s = summary(); + s.muted = true; + s.apps.clear(); + let m = menu_model(&s); + let Entry::Item { label, .. } = m.iter().find(|e| matches!(e, Entry::Item { id, .. } if id == ID_MUTE)).unwrap() else { panic!() }; + assert_eq!(label, "Unmute notifications"); + let Entry::Sub { items, .. } = &m[3] else { panic!() }; + assert!(matches!(&items[0], Entry::Item { enabled: false, label, .. } if label == "No apps yet")); +} + +#[test] +fn tooltip_is_one_short_line() { + assert_eq!(tooltip_text(&summary()), "storebound · 2 in review · 1 needs you"); + let mut s = summary(); + s.tooltip = "a\nb\t c".into(); + assert_eq!(tooltip_text(&s), "a b c"); + s.tooltip = "x".repeat(500); + assert_eq!(tooltip_text(&s).chars().count(), MAX_TIP); + s.tooltip = " ".into(); + assert_eq!(tooltip_text(&s), "storebound"); +} + +#[test] +fn clicks_map_to_actions() { + assert_eq!(action_of(ID_OPEN), Some(Action::Open)); + assert_eq!(action_of(ID_QUIT), Some(Action::Quit)); + assert_eq!(action_of(ID_READ), Some(Action::Event("read"))); + assert_eq!(action_of(ID_MUTE), Some(Action::Event("mute-toggle"))); + assert_eq!(action_of("tray-app:k1"), Some(Action::Route(r#"{"appId":"k1","screen":"app","tab":"releases"}"#.into()))); + assert_eq!(action_of("tray-app:"), None); + assert_eq!(action_of("other"), None); + let Some(Action::Route(r)) = action_of(ID_UPDATE) else { panic!() }; + assert!(valid_route(&r) && r.contains("updates")); +} + +#[test] +fn only_known_small_routes_are_remembered() { + assert!(valid_route(r#"{"screen":"app","appId":"k1","tab":"releases"}"#)); + assert!(valid_route(r#"{"screen":"portfolio"}"#)); + assert!(!valid_route(r#"{"screen":"evil"}"#)); + assert!(!valid_route(r#"{"screen":"app","appId":5}"#)); + assert!(!valid_route("[]")); + assert!(!valid_route("not json")); + assert!(!valid_route(&format!(r#"{{"screen":"app","appId":"{}"}}"#, "a".repeat(500)))); + // handed out once + set_pending(r#"{"screen":"portfolio"}"#); + assert_eq!(take_pending().as_deref(), Some(r#"{"screen":"portfolio"}"#)); + assert_eq!(take_pending(), None); + set_pending(r#"{"screen":"evil"}"#); + assert_eq!(take_pending(), None); +} + +#[test] +fn attention_dot_changes_only_the_corner() { + let (w, h) = (32u32, 32u32); + let base: Vec = std::iter::repeat([10u8, 20, 30, 255]).take((w * h) as usize).flatten().collect(); + let out = with_attention_dot(&base, w, h); + assert_eq!(out.len(), base.len()); + let at = |v: &[u8], x: u32, y: u32| -> [u8; 4] { let i = ((y * w + x) * 4) as usize; [v[i], v[i + 1], v[i + 2], v[i + 3]] }; + assert_eq!(at(&out, 2, 30), [10, 20, 30, 255], "the bottom left stays"); + let dot = at(&out, 25, 6); + assert!(dot[0] > 200 && dot[1] < 90 && dot[2] < 90, "red in the middle of the dot: {dot:?}"); + // transparent icon: the dot is opaque, the rest stays clear + let clear = vec![0u8; (w * h * 4) as usize]; + let out = with_attention_dot(&clear, w, h); + assert_eq!(at(&out, 2, 30)[3], 0); + assert_eq!(at(&out, 25, 6)[3], 255); + // a wrong buffer size is returned unchanged + assert_eq!(with_attention_dot(&[1, 2, 3], w, h), vec![1, 2, 3]); +} + +#[test] +fn quiet_hours_settings_are_checked_and_default_off() { + let d = Settings::default(); + assert!(!d.notify.quiet_hours.enabled); + assert_eq!((d.notify.quiet_hours.from.as_str(), d.notify.quiet_hours.to.as_str()), ("23:00", "08:00")); + assert!(d.notify.rejected && d.notify.approved && d.notify.live); + assert!(valid_hhmm("00:00") && valid_hhmm("23:59")); + assert!(!valid_hhmm("24:00") && !valid_hhmm("7:00") && !valid_hhmm("12:60") && !valid_hhmm("ab:cd")); + let mut s = d.clone(); + s.notify.quiet_hours.from = "25:00".into(); + assert!(check_settings(&s).is_err()); + let old: Settings = serde_json::from_value(serde_json::json!({ "notify": { "crash": false } })).unwrap(); + assert!(!old.notify.crash && old.notify.rejected && !old.notify.quiet_hours.enabled, "an older file gets the new defaults"); +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4c77cad..a72750e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,12 +1,12 @@ // Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh), // the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide. import { useCallback, useEffect, useMemo, useState } from 'react'; -import { FIXTURES, onDeepLink, onDeepLinkFolder } from './api'; +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, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; +import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, 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'; @@ -67,7 +67,9 @@ export function App({ onTheme }: { onTheme: () => void }) { const un = onDeepLink(s => { const r = parseStart(s, []); if (r) go(r); }); // `storebound desktop` in an app folder: the host added (after its native dialog) or found that folder const unFolder = onDeepLinkFolder(id => { d.reload().catch(() => {}).finally(() => go({ screen: 'app', appId: id, tab: 'releases' })); }); - return () => { un.then(f => f()); unFolder.then(f => f()); }; + // the tray menu (an app, Settings, Check for updates) and the first show after a notification + const unTray = onTrayRoute(s => { const r = parseRouteJson(s); if (r) go(r); }); + return () => { un.then(f => f()); unFolder.then(f => f()); unTray.then(f => f()); }; }, [go, d.reload]); const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined; diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 68cce68..a431266 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -6,6 +6,7 @@ import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event'; import type { AccountCheck, AppsListResult, DraftsResult, IconResult, ListingsResult, MethodDescriptor, PortfolioResult, ProgressParams, ToolResult } from '../../../cli/lib/desktop-protocol'; import type { DeclarationsResult, ListingText, RendersResult } from '../../../cli/lib/desktop-protocol'; import type { Overrides } from './lib/identity'; +import type { TraySummary } from './lib/tray'; export type { AccountCheck, AppIdentity, AppsListResult, AppStatus, CatalogParity, DraftsResult, StoreApp, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult, @@ -33,10 +34,11 @@ export interface Settings { /** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */ refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 }; cache: { keepDays: 0 | 7 | 30 | 90 | 365 }; - notify: { reviewState: boolean; lowReviews: boolean; crash: boolean }; + /** reviewState: other state changes (submitted, in review); rejected / approved / live: own toggles; crash also covers a halted rollout. quietHours: no notification between from and to (HH:MM, may cross midnight). */ + notify: { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: { enabled: boolean; from: string; to: string } }; channel: 'stable' | 'beta'; } -export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' }; +export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, rejected: true, approved: true, live: true, lowReviews: true, crash: true, quietHours: { enabled: false, from: '23:00', to: '08:00' } }, channel: 'stable' }; export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings } export interface AccountApps { account: string; entry: CacheEntry | null } export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number } @@ -121,7 +123,10 @@ export const api = { setSettings: (settings: Settings) => invoke('settings_set', { settings }), cacheInfo: () => invoke('cache_info'), revealData: () => invoke('data_reveal'), - notify: (title: string, body: string) => invoke('notify', { title, body }), + /** target: a route (JSON) the window opens on when it is next shown from the tray (the notification plugin has no click callback on desktop). */ + notify: (title: string, body: string, target?: string) => invoke('notify', { title, body, target: target ?? null }), + /** Pushes the tray's tooltip, attention flag and app list after a read (lib/tray.ts). */ + trayUpdate: (summary: TraySummary) => invoke('tray_update', { summary }), /** Native confirmation first; true when everything was forgotten. */ reset: () => invoke('app_reset'), /** Offline (folders) or live; live also reads the given store-only apps (store targets). */ @@ -172,4 +177,8 @@ export const onDownload = (cb: (e: DownloadEvent) => void): Promise export const onDeepLink = (cb: (screen: string) => void): Promise => listen('deep-link', cb); /** storebound://open?path=... selected or added an app folder (its project id). */ export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise => listen('deep-link-folder', cb); +/** A tray menu click (or the first window show after a notification) that opens a route (JSON, see parseRouteJson). */ +export const onTrayRoute = (cb: (route: string) => void): Promise => listen('tray-route', cb); +/** A tray menu click that is not navigation: read the stores now, toggle mute. */ +export const onTrayAction = (cb: (action: 'read' | 'mute-toggle') => void): Promise => listen<'read' | 'mute-toggle'>('tray-action', cb); export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise => listen('update-progress', cb); diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 9acb44a..0b60aa7 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -569,7 +569,7 @@ async function answer(cmd: string, a: Record): Promise case 'overrides_set': overrides = a.overrides as Overrides; return overrides; case 'settings_set': settings = a.settings as Settings; return settings; case 'cache_info': return { rows: 214, bytes: 3_412_000, oldest: iso(60 * 24 * 26), file_bytes: 4_718_592 }; - case 'data_reveal': case 'notify': return null; + case 'data_reveal': case 'notify': case 'tray_update': return null; case 'app_reset': return false; case 'update_check': await sleep(300); return { current: '0.1.0', channel: settings.channel, available: null, notes: null, date: null }; case 'update_install': return false; diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c400f03..f989257 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -782,6 +782,41 @@ export const en = { 'notify.lowTitle': '{name}: {count} new low ratings', 'notify.lowTitle_one': '{name}: a new low rating', 'notify.crashTitle': '{name}: over the Play threshold', + 'notify.rejectedTitle': '{name}: rejected by {store}', + 'notify.rejectedBody': 'Version {version} was rejected.', + 'notify.approvedTitle': '{name}: approved', + 'notify.approvedBody': '{store} {version} passed review and is ready for release.', + 'notify.liveTitle': '{name}: live', + 'notify.liveBody': '{store} {version} is live.', + 'notify.haltedTitle': '{name}: rollout halted', + 'notify.haltedBody': '{store} {version} was halted.', + 'tray.open': 'Open storebound', + 'tray.readNow': 'Read the stores now', + 'tray.apps': 'Apps', + 'tray.noApps': 'No apps yet', + 'tray.noState': 'no release read yet', + 'tray.mute': 'Mute notifications for 1 hour', + 'tray.unmute': 'Unmute notifications', + 'tray.update': 'Check for updates', + 'tray.settings': 'Settings', + 'tray.quit': 'Quit storebound', + 'tray.tipReview': '{count} in review', + 'tray.tipAttention': '{count} needs you', + 'tray.mutedTip': 'muted', + 'set.nRejected': 'Rejected', + 'set.nRejectedHint': 'A version the store rejected, with its reason when the store gives one', + 'set.nApproved': 'Approved and ready for release', + 'set.nApprovedHint': 'A version passed review', + 'set.nLive': 'Live', + 'set.nLiveHint': 'A version reached the store page or a rollout', + 'set.quiet': 'Quiet hours', + 'set.quietHint': 'No notifications in this window (they are dropped, not saved for later)', + 'set.quietFrom': 'From', + 'set.quietTo': 'To', + 'set.muteHour': 'Mute for 1 hour', + 'set.unmute': 'Unmute ({until})', + 'set.muted': 'Muted until {until}', + 'set.clickNote': "Clicking a notification does not open the app on desktop; the next time you open the window from the tray, it goes to that app's Releases tab.", 'set.lead': 'Studios, accounts, store reads, the offline cache, language, theme, notifications and more. Everything stays on this computer.', 'set.sections': 'Settings sections', 'set.studios': 'Studios & accounts', @@ -854,11 +889,11 @@ export const en = { 'set.always': 'Always', 'set.notifications': 'Notifications', 'set.notificationsSub': 'After a read, when something changed since the previous one', - 'set.nReview': 'Review and release state changes', - 'set.nReviewHint': 'In review, approved, rejected, live, halted', + 'set.nReview': 'Other release state changes', + 'set.nReviewHint': 'Submitted, in review', 'set.nLow': 'New 1 or 2 star reviews', 'set.nLowHint': 'When the Reviews tab reads new ones', - 'set.nCrash': 'Crash or ANR rate over the threshold', + 'set.nCrash': 'Rollout halted, or crash / ANR over the threshold', 'set.nCrashHint': 'Google Play bad behavior thresholds', 'set.testNotify': 'Send a test notification', 'set.testTitle': 'storebound', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index afc278c..0fc5ff5 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -784,6 +784,41 @@ export const tr: Record = { 'notify.lowTitle': '{name}: {count} yeni düşük puan', 'notify.lowTitle_one': '{name}: yeni bir düşük puan', 'notify.crashTitle': '{name}: Play eşiğinin üstünde', + 'notify.rejectedTitle': '{name}: {store} reddetti', + 'notify.rejectedBody': '{version} sürümü reddedildi.', + 'notify.approvedTitle': '{name}: onaylandı', + 'notify.approvedBody': '{store} {version} incelemeyi geçti, yayına hazır.', + 'notify.liveTitle': '{name}: yayında', + 'notify.liveBody': '{store} {version} yayında.', + 'notify.haltedTitle': '{name}: kademeli yayın durduruldu', + 'notify.haltedBody': '{store} {version} durduruldu.', + 'tray.open': "storebound'u aç", + 'tray.readNow': 'Mağazaları şimdi oku', + 'tray.apps': 'Uygulamalar', + 'tray.noApps': 'Henüz uygulama yok', + 'tray.noState': 'henüz okunmadı', + 'tray.mute': 'Bildirimleri 1 saat sustur', + 'tray.unmute': 'Bildirimlerin sesini aç', + 'tray.update': 'Güncellemeleri denetle', + 'tray.settings': 'Ayarlar', + 'tray.quit': "storebound'dan çık", + 'tray.tipReview': '{count} incelemede', + 'tray.tipAttention': '{count} sizi bekliyor', + 'tray.mutedTip': 'sessiz', + 'set.nRejected': 'Reddedildi', + 'set.nRejectedHint': 'Mağazanın reddettiği sürüm; mağaza verirse gerekçesiyle', + 'set.nApproved': 'Onaylandı, yayına hazır', + 'set.nApprovedHint': 'Bir sürüm incelemeyi geçti', + 'set.nLive': 'Yayında', + 'set.nLiveHint': 'Bir sürüm mağaza sayfasına ya da kademeli yayına ulaştı', + 'set.quiet': 'Sessiz saatler', + 'set.quietHint': 'Bu aralıkta bildirim gelmez (sonraya saklanmaz, atılır)', + 'set.quietFrom': 'Başlangıç', + 'set.quietTo': 'Bitiş', + 'set.muteHour': '1 saat sustur', + 'set.unmute': 'Sesi aç ({until})', + 'set.muted': '{until} saatine kadar sessiz', + 'set.clickNote': 'Masaüstünde bildirime tıklamak uygulamayı açmaz; pencereyi bir sonraki sefer tepsiden açtığınızda o uygulamanın Yayınlar sekmesine gider.', 'set.lead': 'Stüdyolar, hesaplar, mağaza okumaları, çevrimdışı önbellek, dil, tema, bildirimler ve dahası. Hepsi bu bilgisayarda kalır.', 'set.sections': 'Ayar bölümleri', 'set.studios': 'Stüdyolar ve hesaplar', @@ -856,11 +891,11 @@ export const tr: Record = { 'set.always': 'Her zaman', 'set.notifications': 'Bildirimler', 'set.notificationsSub': 'Bir okumadan sonra, öncekinden beri bir şey değiştiyse', - 'set.nReview': 'İnceleme ve yayın durumu değişiklikleri', - 'set.nReviewHint': 'İncelemede, onaylandı, reddedildi, yayında, durduruldu', + 'set.nReview': 'Diğer yayın durumu değişiklikleri', + 'set.nReviewHint': 'Gönderildi, incelemede', 'set.nLow': 'Yeni 1 ya da 2 yıldızlı yorumlar', 'set.nLowHint': 'Yorumlar sekmesi yenilerini okuduğunda', - 'set.nCrash': 'Çökme ya da ANR oranı eşiğin üstünde', + 'set.nCrash': 'Kademeli yayın durdu, ya da çökme / ANR eşiğin üstünde', 'set.nCrashHint': 'Google Play kötü davranış eşikleri', 'set.testNotify': 'Deneme bildirimi gönder', 'set.testTitle': 'storebound', diff --git a/apps/desktop/src/lib/normalize.ts b/apps/desktop/src/lib/normalize.ts index 36e7844..8b584de 100644 --- a/apps/desktop/src/lib/normalize.ts +++ b/apps/desktop/src/lib/normalize.ts @@ -49,16 +49,22 @@ function overrides(v: unknown): Overrides { function pick(v: unknown, d: T, ok: (x: unknown) => boolean): T { return ok(v) ? (v as T) : d; } const isNum = (x: unknown) => typeof x === 'number' && Number.isFinite(x); const isBool = (x: unknown) => typeof x === 'boolean'; +const isHm = (x: unknown) => typeof x === 'string' && /^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/.test(x); export function normalizeSettings(v: unknown): Settings { const d = DEFAULT_SETTINGS; if (!isRec(v)) return d; const r = isRec(v.refresh) ? v.refresh : {}, c = isRec(v.cache) ? v.cache : {}, n = isRec(v.notify) ? v.notify : {}; + const q = isRec(n.quietHours) ? n.quietHours : {}; return { refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool), autoRead: pick(r.autoRead, d.refresh.autoRead, isBool), everyMinutes: pick(r.everyMinutes, d.refresh.everyMinutes, (x: unknown) => [0, 5, 15, 30, 60].includes(x as number)) }, cache: { keepDays: pick(c.keepDays, d.cache.keepDays, isNum) }, - notify: { reviewState: pick(n.reviewState, d.notify.reviewState, isBool), lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool) }, + notify: { + reviewState: pick(n.reviewState, d.notify.reviewState, isBool), rejected: pick(n.rejected, d.notify.rejected, isBool), approved: pick(n.approved, d.notify.approved, isBool), live: pick(n.live, d.notify.live, isBool), + lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool), + quietHours: { enabled: pick(q.enabled, d.notify.quietHours.enabled, isBool), from: pick(q.from, d.notify.quietHours.from, isHm), to: pick(q.to, d.notify.quietHours.to, isHm) }, + }, channel: v.channel === 'beta' ? 'beta' : 'stable', }; } diff --git a/apps/desktop/src/lib/notify.ts b/apps/desktop/src/lib/notify.ts index 247f8ba..e6b48f0 100644 --- a/apps/desktop/src/lib/notify.ts +++ b/apps/desktop/src/lib/notify.ts @@ -4,7 +4,18 @@ import type { PortfolioResult } from '../api'; import { sameVersion } from '../../../../cli/lib/portfolio'; import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from './model'; -export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string } +/** Which Settings > Notifications toggle a release change belongs to. */ +export type ReleaseKind = 'rejected' | 'approved' | 'live' | 'halted' | 'state'; +export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string; kind: ReleaseKind; /** The store's reason text, when the status carries one. */ reason?: string } + +/** The notification kind of a transition into phase `to`.*/ +export function releaseKind(to: string): ReleaseKind { + if (to === 'rejected') return 'rejected'; + if (to === 'approved') return 'approved'; + if (to === 'live' || to === 'rollout') return 'live'; + if (to === 'halted') return 'halted'; + return 'state'; +} /** Phase changes of the same version on the same store that the owner waits for: review in / out, approved, rejected, live, halted. */ export function releaseChanges(prev: PortfolioResult | null | undefined, next: PortfolioResult): ReleaseChange[] { @@ -18,7 +29,12 @@ export function releaseChanges(prev: PortfolioResult | null | undefined, next: P if (!r.version) continue; const old = (before.release.all ?? []).find(x => x.store === r.store && (x.version === r.version || sameVersion(x.version, r.version))); if (!old || old.phase === r.phase) continue; - if (WATCH.has(r.phase) || WATCH.has(old.phase)) out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase }); + // a version the developer withdrew on their own is not a store decision + if (r.state === 'DEVELOPER_REJECTED') continue; + if (WATCH.has(r.phase) || WATCH.has(old.phase)) { + const reason = typeof r.reason === 'string' && r.reason.trim() ? r.reason.trim().slice(0, 160) : undefined; + out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase, kind: releaseKind(r.phase), ...(reason ? { reason } : {}) }); + } } } return out; @@ -40,3 +56,55 @@ export function crashCrossed(prev: HealthSnapshot | null | undefined, next: Heal for (const k of ['crash', 'anr'] as const) if (now[k].tone === 'bad' && was[k].tone !== 'bad') out.push(k); return out; } + +// ---- when a notification may show: kind toggle, quiet hours, mute ---- + +export interface QuietHours { enabled: boolean; from: string; to: string } +export interface NotifyPrefs { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: QuietHours } +export type NoticeKind = ReleaseKind | 'lowReviews' | 'crash' | 'test'; + +/** The toggle of a notification kind ("halted" shares the crash toggle: both are "something is going wrong"). */ +export function kindEnabled(n: NotifyPrefs, kind: NoticeKind): boolean { + switch (kind) { + case 'rejected': return n.rejected; + case 'approved': return n.approved; + case 'live': return n.live; + case 'halted': case 'crash': return n.crash; + case 'state': return n.reviewState; + case 'lowReviews': return n.lowReviews; + case 'test': return true; + } +} + +const minutesOf = (hhmm: string): number | null => { + const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm); + return m ? Number(m[1]) * 60 + Number(m[2]) : null; +}; + +/** True inside the quiet window (it may cross midnight: 23:00-08:00). Off, equal ends or a bad time = never quiet. */ +export function inQuietHours(q: QuietHours, now: Date): boolean { + if (!q.enabled) return false; + const from = minutesOf(q.from), to = minutesOf(q.to); + if (from === null || to === null || from === to) return false; + const cur = now.getHours() * 60 + now.getMinutes(); + return from < to ? cur >= from && cur < to : cur >= from || cur < to; +} + +/** "Mute for 1 hour": muted while `muteUntil` (epoch ms) is in the future. */ +export const isMuted = (muteUntil: number, now = Date.now()): boolean => muteUntil > now; + +/** Whether a notification of `kind` may show now. A test notification ignores quiet hours and mute (it checks delivery). */ +export function mayNotify(n: NotifyPrefs, kind: NoticeKind, o: { now?: Date; muteUntil?: number } = {}): boolean { + if (kind === 'test') return true; + const now = o.now ?? new Date(); + return kindEnabled(n, kind) && !inQuietHours(n.quietHours, now) && !isMuted(o.muteUntil ?? 0, now.getTime()); +} + +/** + * Delay before the next background pass: the interval with +-10% jitter, doubled for every failure in a row (at most + * x16, capped at 2 hours before the jitter). `rand` is in [0, 1). + */ +export function nextReadDelay(everyMinutes: number, failures: number, rand = Math.random()): number { + const base = everyMinutes * 60_000 * 2 ** Math.min(Math.max(failures, 0), 4); + return Math.round(Math.min(base, 2 * 3_600_000) * (0.9 + rand * 0.2)); +} diff --git a/apps/desktop/src/lib/tray.ts b/apps/desktop/src/lib/tray.ts new file mode 100644 index 0000000..7211e2b --- /dev/null +++ b/apps/desktop/src/lib/tray.ts @@ -0,0 +1,48 @@ +// What the tray shows: one short state per app and store, the tooltip counts and the attention flag. Pure; the host +// (src-tauri/src/tray.rs) only draws it. +import type { AppStatus } from '../api'; +import type { ReleasePhase } from '../../../../cli/lib/desktop-protocol'; + +export interface TrayApp { key: string; name: string; state: string } +export interface TrayLabels { open: string; readNow: string; apps: string; noApps: string; mute: string; unmute: string; update: string; settings: string; quit: string } +export interface TraySummary { tooltip: string; attention: number; muted: boolean; apps: TrayApp[]; labels: TrayLabels } + +type Phase = ReleasePhase; + +/** What matters most first: a rejection, a halt, a review, an approval, a rollout, live. */ +const RANK: Record = { rejected: 0, halted: 1, review: 2, sent: 2, approved: 3, rollout: 4, live: 5, released: 6 }; + +/** The release of each store that the tray reports: the one with the most pressing phase (testing / draft only when nothing else). */ +export function trayPhases(s: AppStatus | undefined): { ios?: Phase; android?: Phase } { + const out: { ios?: Phase; android?: Phase } = {}; + for (const r of s?.release.all ?? []) { + const cur = out[r.store]; + if (!cur || (RANK[r.phase] ?? 9) < (RANK[cur] ?? 9)) out[r.store] = r.phase; + } + return out; +} + +/** True for a phase that needs the owner (rejected, halted). */ +export const needsYou = (p: string | undefined): boolean => p === 'rejected' || p === 'halted'; +export const inReview = (p: string | undefined): boolean => p === 'review' || p === 'sent'; + +/** + * Builds the summary the host shows. `phaseText` gives the words of a phase, `text` the tray strings (all i18n by the + * caller). The attention count is the number of apps with a rejected or halted release; the tooltip counts apps. + */ +export function traySummary( + apps: { key: string; name: string; status: AppStatus | undefined }[], muted: boolean, + tr: { phaseText: (p: string) => string; store: (s: 'ios' | 'android') => string; tipReview: (n: number) => string; tipAttention: (n: number) => string; muted: string; none: string; labels: TrayLabels }, +): TraySummary { + let review = 0, attention = 0; + const list: TrayApp[] = []; + for (const a of apps) { + const ph = trayPhases(a.status); + if (inReview(ph.ios) || inReview(ph.android)) review++; + if (needsYou(ph.ios) || needsYou(ph.android)) attention++; + const parts = (['ios', 'android'] as const).filter(s => ph[s]).map(s => `${tr.store(s)}: ${tr.phaseText(ph[s]!)}`); + list.push({ key: a.key, name: a.name, state: parts.length ? parts.join(' · ') : tr.none }); + } + const bits = [review ? tr.tipReview(review) : '', attention ? tr.tipAttention(attention) : '', muted ? tr.muted : ''].filter(Boolean); + return { tooltip: ['storebound', ...bits].join(' · '), attention, muted, apps: list, labels: tr.labels }; +} diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index b438256..a6429e1 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -34,6 +34,18 @@ export function parseStart(v: string, apps: string[]): Route | null { return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen }; } +/** A route sent as JSON by the host (tray menu, notification target); anything that is not a known route is null. */ +export function parseRouteJson(v: string): Route | null { + let o: unknown; + try { o = JSON.parse(v); } catch { return 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 (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; +} + export const sameRoute = (a: Route, b: Route): boolean => JSON.stringify(a) === JSON.stringify(b); export interface Nav { route: Route; go: (r: Route) => void; openPalette: () => void; openWelcome: () => void } diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index 96ded11..3342203 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -167,9 +167,20 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
+ set(x => ({ ...x, notify: { ...x.notify, rejected: v } }))} /> + set(x => ({ ...x, notify: { ...x.notify, approved: v } }))} /> + set(x => ({ ...x, notify: { ...x.notify, live: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, reviewState: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, lowReviews: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, crash: v } }))} /> + set(x => ({ ...x, notify: { ...x.notify, quietHours: { ...x.notify.quietHours, enabled: v } } }))} /> + {s.notify.quietHours.enabled &&
+ + +
} + {d.muteUntil > Date.now() &&

{t('set.muted', { until: new Date(d.muteUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) })}

} +

{t('set.clickNote')}

+
diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index 38c8bbd..cca9d6d 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -4,18 +4,20 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { type Account, type AccountCheck, api, type AppInfo, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, errorText, newJob, onDownload, - onProgress, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult, + onProgress, onTrayAction, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult, } from './api'; import { t } from './i18n'; import { buildApps, NO_OVERRIDES, type Overrides, type UnifiedApp } from './lib/identity'; import { isError, type HealthSnapshot, type Review } from './lib/model'; -import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify'; +import { crashCrossed, mayNotify, type NoticeKind, newLowReviews, nextReadDelay, type ReleaseChange, releaseChanges } from './lib/notify'; +import { traySummary } from './lib/tray'; import { setCrashContext } from './lib/crash'; import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize'; import { keepLines, progressOf } from './lib/progress'; import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness'; import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler'; import { fixHint } from './lib/views'; +import type { Route } from './nav'; import { withLastGood } from '../../../cli/lib/train'; import { failureText, usable } from './lib/results'; @@ -92,12 +94,17 @@ export interface Data { onRead: (key: string, fn: (e: ReadEvent) => void) => () => void; isReading: (key: string) => boolean; /** Reads everything of every app that is past its TTL (force: everything). */ - readAll: (force?: boolean) => Promise; + readAll: (force?: boolean) => Promise; + /** Notifications are muted until this time (epoch ms; 0 = not muted). */ + muteUntil: number; + /** Mutes notifications for `minutes` (0 = unmute). */ + setMute: (minutes: number) => void; /** After a write: the app's reads are read again. */ invalidate: (target: string) => void; /** The app on screen (its reads go first). */ setFocus: (key: string | null) => void; - refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise; + /** True when the read succeeded. */ + refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise; /** Reads the apps of one account (or all) from the stores. */ importApps: (account?: string) => Promise; checkAccount: (id: string) => Promise; @@ -128,6 +135,19 @@ const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at( /** A failure line with how to fix it, when the error is a known one. */ const withHint = (msg: string | undefined): string | undefined => { const h = fixHint(msg); return h && msg ? `${msg} · ${t(h)}` : msg; }; +const storeName = (s: 'ios' | 'android'): string => (s === 'ios' ? 'App Store' : 'Google Play'); +/** Title and body of the notification of one release change (by kind; a rejection adds the store's reason when it has one). */ +export function releaseText(c: ReleaseChange): [string, string] { + const v = { name: c.name, store: storeName(c.store), version: c.version }; + switch (c.kind) { + case 'rejected': return [t('notify.rejectedTitle', v), [t('notify.rejectedBody', v), c.reason].filter(Boolean).join(' ')]; + case 'approved': return [t('notify.approvedTitle', v), t('notify.approvedBody', v)]; + case 'live': return [t('notify.liveTitle', v), t('notify.liveBody', v)]; + case 'halted': return [t('notify.haltedTitle', v), t('notify.haltedBody', v)]; + default: return [t('notify.releaseTitle', v), t('notify.releaseBody', { ...v, phase: t(`phase.${c.to}` as 'phase.live') })]; + } +} + export function DataProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [info, setInfo] = useState(null); @@ -153,6 +173,21 @@ export function DataProvider({ children }: { children: ReactNode }) { const settingsRef = useRef(settings); settingsRef.current = settings; + // Mute for a while (the tray's "Mute notifications for 1 hour"): kept across restarts + const [muteUntil, setMuteUntil] = useState(() => { try { return Number(localStorage.getItem('storebound.muteUntil')) || 0; } catch { return 0; } }); + const muteRef = useRef(muteUntil); + muteRef.current = muteUntil; + const setMute = useCallback((minutes: number) => { + const until = minutes > 0 ? Date.now() + minutes * 60_000 : 0; + setMuteUntil(until); muteRef.current = until; + try { localStorage.setItem('storebound.muteUntil', String(until)); } catch { /* private mode: mute lasts until the window closes */ } + }, []); + /** One desktop notification: its kind's toggle, quiet hours and mute decide; `route` is where the window opens when it is next shown from the tray. */ + const notice = useCallback((kind: NoticeKind, title: string, body: string, route?: Route): void => { + if (!mayNotify(settingsRef.current.notify, kind, { muteUntil: muteRef.current })) return; + api.notify(title, body, route ? JSON.stringify(route) : undefined).catch(() => {}); + }, []); + const dismiss = useCallback((id: string) => { setToasts(ts => ts.filter(x => x.id !== id)); const tm = timers.current.get(id); @@ -196,7 +231,7 @@ export function DataProvider({ children }: { children: ReactNode }) { const appsRef = useRef(apps); appsRef.current = apps; - const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => { + const refreshPortfolio = useCallback(async (liveRead = false, quiet = false): Promise => { const job = newJob(liveRead ? 'portfolio-live' : 'portfolio'); setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null); const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null; @@ -210,8 +245,11 @@ export function DataProvider({ children }: { children: ReactNode }) { if (liveRead) { const changes = releaseChanges(liveRef.current?.value, r); setLive({ value: r, at: r.at }); - if (settingsRef.current.notify.reviewState) for (const c of changes.slice(0, 5)) - api.notify(t('notify.releaseTitle', { name: c.name }), t('notify.releaseBody', { store: c.store === 'ios' ? 'App Store' : 'Google Play', version: c.version, phase: t(`phase.${c.to}` as 'phase.live') })).catch(() => {}); + for (const c of changes.slice(0, 5)) { + const [title, body] = releaseText(c); + const app = appsRef.current.find(a => a.target === c.project); + notice(c.kind, title, body, app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined); + } const failed = r.apps.filter(a => a.storeErrors.ios || a.storeErrors.android).length; if (tid) updateToast(tid, failed ? { kind: 'info', title: t('toast.readDonePartial'), detail: t('toast.readDonePartialDetail', { count: failed }), progress: undefined, job: undefined } : { kind: 'ok', title: t('toast.readDone'), detail: t('toast.readDoneDetail', { count: r.apps.length }), progress: undefined, job: undefined }); @@ -220,8 +258,10 @@ export function DataProvider({ children }: { children: ReactNode }) { const msg = errorText(e); setPortfolioError(msg); if (tid) updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailed'), detail: msg, progress: undefined, job: undefined }); + return false; } finally { setBusy(null); } - }, [toast, updateToast]); + return true; + }, [toast, updateToast, notice]); // ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ---- const schedRef = useRef(null); @@ -252,22 +292,42 @@ export function DataProvider({ children }: { children: ReactNode }) { return c; }; const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined); + const lastData = useRef(new Map()); const schedule = useCallback((target: string, method: string, args: Record, priority: Priority): Promise => { const key = readKey(target, method, args); const app = appsRef.current.find(a => a.target === target); const pf = platformsOf(app, app ? statusRef.current(app) : undefined); const lanes = lanesOf(method, pf); - const p = sched.enqueue({ key, lanes, accounts: accountsOf(app, lanes), priority, run: () => api.call(method, target, { args }, newJob(method)) }); + // reviews and health: compared with the previous read here (not in a card), so the tray app notices them with no window open + const watch = method === 'reviews.list' || method === 'health'; + const run = async (): Promise => { + const before = watch ? (lastData.current.has(key) ? lastData.current.get(key) : (await api.cached(method, target, args).catch(() => null))?.value.data ?? null) : null; + const result = await api.call(method, target, { args }, newJob(method)); + if (watch && usable(method, result)) { + notifyAfterRead(method, before, result.data, notice, app?.name, app?.key); + lastData.current.set(key, result.data); + } + return result; + }; + const p = sched.enqueue({ key, lanes, accounts: accountsOf(app, lanes), priority, run }); emitRead(key, { kind: 'start' }); p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) })); return p; - }, [sched, emitRead]); + }, [sched, emitRead, notice]); - const readAll = useCallback(async (force = false) => { - if (!force && !settingsRef.current.refresh.autoRead) return; - if (force || isStale('portfolio.status', liveRef.current?.at)) { - sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {}); + /** + * Reads everything past its TTL. The portfolio read is a LIVE one (refreshPortfolio(true)): it is what releaseChanges + * compares, so a rejection is seen even when no screen asked. Resolves when that read is done: false when it failed. + */ + const readAll = useCallback(async (force = false): Promise => { + if (!force && !settingsRef.current.refresh.autoRead) return true; + let ok = true; + let portfolioRead: Promise | null = null; + const pkey = '*\u001fportfolio.status'; + if (!sched.has(pkey) && (force || isStale('portfolio.status', liveRef.current?.at))) { + portfolioRead = sched.enqueue({ key: pkey, lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) }) + .then(r => { ok = r !== false; }, () => { ok = false; }); } const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing) .sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current)); @@ -283,6 +343,8 @@ export function DataProvider({ children }: { children: ReactNode }) { schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {}); } } + await portfolioRead; + return ok; }, [sched, schedule, refreshPortfolio]); const invalidate = useCallback((target: string) => { @@ -353,27 +415,48 @@ export function DataProvider({ children }: { children: ReactNode }) { useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]); // Settings > Reading: everything past its TTL is read in the background on start, when the window shows again after - // a while, and every N minutes while it is visible (the scheduler keeps it inside the stores' limits). + // a while, and every N minutes (with a little jitter) EVEN WHILE THE WINDOW IS HIDDEN: the app lives in the tray, and + // that is where a rejection has to be noticed. One pass at a time; after a failed pass the wait doubles (nextReadDelay). const launched = useRef(false); useEffect(() => { if (!ready || launched.current || !apps.length) return; launched.current = true; if (settings.refresh.autoRead) readAll(false).catch(() => {}); }, [ready, apps.length, settings.refresh.autoRead, readAll]); + const passing = useRef(false); useEffect(() => { if (!settings.refresh.autoRead) return; let hiddenAt = 0; + const pass = async (): Promise => { + if (passing.current) return true; + passing.current = true; + try { return await readAll(false); } catch { return false; } finally { passing.current = false; } + }; const vis = () => { if (document.hidden) { hiddenAt = Date.now(); return; } - if (hiddenAt && Date.now() - hiddenAt > 60_000) readAll(false).catch(() => {}); + if (hiddenAt && Date.now() - hiddenAt > 60_000) pass().catch(() => {}); hiddenAt = 0; }; document.addEventListener('visibilitychange', vis); const m = settings.refresh.everyMinutes; - const tm = m ? setInterval(() => { if (!document.hidden) readAll(false).catch(() => {}); }, m * 60_000) : null; - return () => { document.removeEventListener('visibilitychange', vis); if (tm) clearInterval(tm); }; + let tm: ReturnType | null = null; + let stopped = false, failures = 0; + const next = () => { + if (stopped || !m) return; + tm = setTimeout(async () => { failures = (await pass()) ? 0 : failures + 1; next(); }, nextReadDelay(m, failures)); + }; + next(); + return () => { stopped = true; document.removeEventListener('visibilitychange', vis); if (tm) clearTimeout(tm); }; }, [settings.refresh.autoRead, settings.refresh.everyMinutes, readAll]); + // the tray: tooltip, attention icon and the apps list follow every read; its menu asks for a read or the mute + const readRef = useRef(readAll); + readRef.current = readAll; + useEffect(() => { + const un = onTrayAction(a => { if (a === 'read') readRef.current(true).catch(() => {}); else setMute(isMutedNow() ? 0 : 60); }); + return () => { un.then(f => f()); }; + }, [setMute]); + const isMutedNow = () => muteRef.current > Date.now(); // icons: cached first, then read (offline, cheap) useEffect(() => { for (const p of projects) { @@ -419,14 +502,33 @@ export function DataProvider({ children }: { children: ReactNode }) { const iconFor = useCallback((a: UnifiedApp) => (a.project ? icons[a.project.id] ?? a.storeIcon : a.storeIcon), [icons]); const appByKey = useCallback((key: string) => apps.find(a => a.key === key), [apps]); statusRef.current = statusFor; + + useEffect(() => { + if (!ready) return; + const muted = muteUntil > Date.now(); + const sum = traySummary( + apps.filter(a => !a.ignored).map(a => ({ key: a.key, name: a.name, status: liveFor(a) ?? statusFor(a) })), muted, + { phaseText: p => t(`phase.${p}` as 'phase.live'), store: s => (s === 'ios' ? 'App Store' : 'Play'), none: t('tray.noState'), muted: t('tray.mutedTip'), + tipReview: n => t('tray.tipReview', { count: n }), tipAttention: n => t('tray.tipAttention', { count: n }), + labels: { open: t('tray.open'), readNow: t('tray.readNow'), apps: t('tray.apps'), noApps: t('tray.noApps'), mute: t('tray.mute'), unmute: t('tray.unmute'), update: t('tray.update'), settings: t('tray.settings'), quit: t('tray.quit') } }, + ); + api.trayUpdate(sum).catch(() => {}); + }, [ready, apps, live, portfolio, muteUntil, statusFor, liveFor]); + // the mute ends by itself: the tray menu follows + useEffect(() => { + if (muteUntil <= Date.now()) return; + const tm = setTimeout(() => setMuteUntil(0), muteUntil - Date.now() + 500); + return () => clearTimeout(tm); + }, [muteUntil]); + const readerState = useMemo(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]); const value = useMemo(() => ({ ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError, - importBusy, icons, reader: readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, + importBusy, icons, reader: readerState, muteUntil, setMute, schedule, onRead, isReading, readAll, invalidate, setFocus, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey, toasts, toast, updateToast, dismiss, cancelJob, - }), [readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError, + }), [readerState, muteUntil, setMute, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError, importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey, toasts, toast, updateToast, dismiss, cancelJob]); return {children}; @@ -481,16 +583,19 @@ export function useWrite(projectId: string | null) { return { busy, run, isBusy: (method: string, args: Record = {}) => busy === `${method}:${JSON.stringify(args)}` }; } +type Notice = (kind: NoticeKind, title: string, body: string, route?: Route) => void; + /** Notifications a fresh read may trigger (Settings > Notifications), compared with the read it replaces. */ -function notifyAfterRead(method: string, prev: unknown, next: unknown, settings: Settings, appName: string | undefined): void { +function notifyAfterRead(method: string, prev: unknown, next: unknown, notice: Notice, appName: string | undefined, appKey: string | undefined): void { const name = appName ?? ''; - if (method === 'reviews.list' && settings.notify.lowReviews && Array.isArray(next)) { + const route = (tab: 'reviews' | 'health'): Route | undefined => (appKey ? { screen: 'app', appId: appKey, tab } : undefined); + if (method === 'reviews.list' && Array.isArray(next)) { const fresh = newLowReviews(prev as Review[] | null, next as Review[]); - if (fresh.length) api.notify(t('notify.lowTitle', { name, count: fresh.length }), `${'★'.repeat(fresh[0]!.rating)} ${fresh[0]!.title ?? ''} ${fresh[0]!.body}`.trim()).catch(() => {}); + if (fresh.length) notice('lowReviews', t('notify.lowTitle', { name, count: fresh.length }), `${'★'.repeat(fresh[0]!.rating)} ${fresh[0]!.title ?? ''} ${fresh[0]!.body}`.trim(), route('reviews')); } - if (method === 'health' && settings.notify.crash && next && typeof next === 'object') { + if (method === 'health' && next && typeof next === 'object') { const crossed = crashCrossed(prev as HealthSnapshot | null, next as HealthSnapshot); - if (crossed.length && !isError((next as HealthSnapshot).play)) api.notify(t('notify.crashTitle', { name }), crossed.map(k => (k === 'crash' ? t('health.crash') : t('health.anr'))).join(', ')).catch(() => {}); + if (crossed.length && !isError((next as HealthSnapshot).play)) notice('crash', t('notify.crashTitle', { name }), crossed.map(k => (k === 'crash' ? t('health.crash') : t('health.anr'))).join(', '), route('health')); } } @@ -528,7 +633,6 @@ export function useRead(target: string, method: string, o: { network: boolean else { const r = e.result; if (usable(method, r)) { - notifyAfterRead(method, dataRef.current, r.data, dRef.current.settings, o.appName); set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command }); } else set(x => ({ ...x, loading: false, error: oneLine(failureText(r)), command: r.command })); } diff --git a/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json b/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json index fc2b8b4..e644176 100644 --- a/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json +++ b/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json @@ -33,8 +33,16 @@ }, "channel": "stable", "notify": { + "approved": true, "crash": true, + "live": true, "lowReviews": true, + "quietHours": { + "enabled": false, + "from": "23:00", + "to": "08:00" + }, + "rejected": true, "reviewState": true }, "refresh": { diff --git a/apps/desktop/tests/fixtures/state/d3-full.host.json b/apps/desktop/tests/fixtures/state/d3-full.host.json index 42fd2c9..72ab459 100644 --- a/apps/desktop/tests/fixtures/state/d3-full.host.json +++ b/apps/desktop/tests/fixtures/state/d3-full.host.json @@ -78,8 +78,16 @@ }, "channel": "stable", "notify": { + "approved": true, "crash": true, + "live": true, "lowReviews": true, + "quietHours": { + "enabled": false, + "from": "23:00", + "to": "08:00" + }, + "rejected": true, "reviewState": true }, "refresh": { diff --git a/apps/desktop/tests/fixtures/state/empty.host.json b/apps/desktop/tests/fixtures/state/empty.host.json index cfb10be..505375f 100644 --- a/apps/desktop/tests/fixtures/state/empty.host.json +++ b/apps/desktop/tests/fixtures/state/empty.host.json @@ -16,8 +16,16 @@ }, "channel": "stable", "notify": { + "approved": true, "crash": true, + "live": true, "lowReviews": true, + "quietHours": { + "enabled": false, + "from": "23:00", + "to": "08:00" + }, + "rejected": true, "reviewState": true }, "refresh": { diff --git a/apps/desktop/tests/fixtures/state/shared-profiles.host.json b/apps/desktop/tests/fixtures/state/shared-profiles.host.json index 38ac38f..2103add 100644 --- a/apps/desktop/tests/fixtures/state/shared-profiles.host.json +++ b/apps/desktop/tests/fixtures/state/shared-profiles.host.json @@ -75,8 +75,16 @@ }, "channel": "stable", "notify": { + "approved": true, "crash": true, + "live": true, "lowReviews": true, + "quietHours": { + "enabled": false, + "from": "23:00", + "to": "08:00" + }, + "rejected": true, "reviewState": true }, "refresh": { diff --git a/apps/desktop/tests/identity.test.ts b/apps/desktop/tests/identity.test.ts index fd80815..2c2f3f3 100644 --- a/apps/desktop/tests/identity.test.ts +++ b/apps/desktop/tests/identity.test.ts @@ -128,7 +128,7 @@ describe('notifications', () => { test('release phase changes, only against a previous read', () => { expect(releaseChanges(null, status('review'))).toEqual([]); expect(releaseChanges(status('review'), status('review'))).toEqual([]); - expect(releaseChanges(status('review'), status('approved'))).toEqual([{ project: '/a', name: 'A', store: 'ios', version: '2.0', from: 'review', to: 'approved' }]); + expect(releaseChanges(status('review'), status('approved'))).toEqual([{ project: '/a', name: 'A', store: 'ios', version: '2.0', from: 'review', to: 'approved', kind: 'approved' }]); expect(releaseChanges(status('draft'), status('testing'))).toEqual([]); }); test('new 1-2 star reviews and threshold crossings', () => { diff --git a/apps/desktop/tests/notifications.test.ts b/apps/desktop/tests/notifications.test.ts new file mode 100644 index 0000000..2f29d47 --- /dev/null +++ b/apps/desktop/tests/notifications.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from 'bun:test'; +import type { PortfolioResult } from '../src/api'; +import { DEFAULT_SETTINGS } from '../src/api'; +import { inQuietHours, isMuted, kindEnabled, mayNotify, nextReadDelay, releaseChanges } from '../src/lib/notify'; +import { normalizeSettings } from '../src/lib/normalize'; +import { parseRouteJson } from '../src/nav'; +import { traySummary, trayPhases } from '../src/lib/tray'; + +type Rel = { store: 'ios' | 'android'; version: string; state: string; phase: string; reason?: string }; +const snap = (...rels: Rel[]): PortfolioResult => ({ + apps: [{ project: '/a', ok: true, app: { id: '/a', name: 'A', platforms: { ios: null, android: null } }, summary: null, check: null, catalog: null, storeErrors: {}, errors: [], + release: { source: 'live' as const, current: null, next: null, all: rels.map(r => ({ build: '1', fraction: null, track: null, ...r })) } }], + at: 'x', +} as unknown as PortfolioResult); +const ios = (state: string, phase: string, extra: Partial = {}): Rel => ({ store: 'ios', version: '2.0', state, phase, ...extra }); +const play = (state: string, phase: string): Rel => ({ store: 'android', version: '2.0', state, phase }); + +describe('releaseChanges: transitions the owner waits for', () => { + test('App Store in review -> rejected', () => { + const c = releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('REJECTED', 'rejected'))); + expect(c).toHaveLength(1); + expect(c[0]).toMatchObject({ store: 'ios', version: '2.0', from: 'review', to: 'rejected', kind: 'rejected' }); + }); + test('metadata rejected counts, and the reason text comes along', () => { + const c = releaseChanges(snap(ios('WAITING_FOR_REVIEW', 'review')), snap(ios('METADATA_REJECTED', 'rejected', { reason: ' Guideline 2.1 - missing demo account ' }))); + expect(c[0]).toMatchObject({ kind: 'rejected', reason: 'Guideline 2.1 - missing demo account' }); + }); + test('Play in review -> rejected', () => { + const c = releaseChanges(snap(play('inProgress', 'review')), snap(play('rejected', 'rejected'))); + expect(c[0]).toMatchObject({ store: 'android', kind: 'rejected' }); + }); + test('approved (pending developer release)', () => { + const c = releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('PENDING_DEVELOPER_RELEASE', 'approved'))); + expect(c[0]).toMatchObject({ to: 'approved', kind: 'approved' }); + }); + test('live, from review and from approved; a rollout counts as live', () => { + expect(releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('READY_FOR_SALE', 'live')))[0]!.kind).toBe('live'); + expect(releaseChanges(snap(ios('PENDING_DEVELOPER_RELEASE', 'approved')), snap(ios('READY_FOR_SALE', 'live')))[0]!.kind).toBe('live'); + expect(releaseChanges(snap(play('inProgress', 'review')), snap(play('inProgress', 'rollout')))[0]!.kind).toBe('live'); + }); + test('halted rollout', () => { + expect(releaseChanges(snap(play('inProgress', 'rollout')), snap(play('halted', 'halted')))[0]!.kind).toBe('halted'); + }); + test('submitted -> in review is the generic kind; nothing changed or a first read is silent', () => { + expect(releaseChanges(snap(ios('PREPARE_FOR_SUBMISSION', 'draft')), snap(ios('WAITING_FOR_REVIEW', 'review')))[0]!.kind).toBe('state'); + expect(releaseChanges(snap(ios('REJECTED', 'rejected')), snap(ios('REJECTED', 'rejected')))).toEqual([]); + expect(releaseChanges(null, snap(ios('REJECTED', 'rejected')))).toEqual([]); + }); + test('a version the developer withdrew is not a store decision', () => { + expect(releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('DEVELOPER_REJECTED', 'rejected')))).toEqual([]); + }); + test('one store rejected while the other is live: only the rejected one', () => { + const c = releaseChanges(snap(ios('IN_REVIEW', 'review'), play('completed', 'live')), snap(ios('REJECTED', 'rejected'), play('completed', 'live'))); + expect(c.map(x => x.store)).toEqual(['ios']); + }); +}); + +describe('who may notify: toggles, quiet hours, mute', () => { + const n = DEFAULT_SETTINGS.notify; + const at = (h: number, m = 0) => new Date(2026, 9, 22, h, m); + test('each kind has its own toggle (halted shares the crash toggle)', () => { + expect(kindEnabled({ ...n, rejected: false }, 'rejected')).toBe(false); + expect(kindEnabled({ ...n, rejected: false }, 'approved')).toBe(true); + expect(kindEnabled({ ...n, approved: false }, 'approved')).toBe(false); + expect(kindEnabled({ ...n, live: false }, 'live')).toBe(false); + expect(kindEnabled({ ...n, crash: false }, 'halted')).toBe(false); + expect(kindEnabled({ ...n, crash: false }, 'crash')).toBe(false); + expect(kindEnabled({ ...n, lowReviews: false }, 'lowReviews')).toBe(false); + expect(kindEnabled({ ...n, reviewState: false }, 'state')).toBe(false); + }); + test('quiet hours are off by default and cross midnight', () => { + expect(inQuietHours(n.quietHours, at(23, 30))).toBe(false); + const q = { enabled: true, from: '23:00', to: '08:00' }; + expect(inQuietHours(q, at(22, 59))).toBe(false); + expect(inQuietHours(q, at(23, 0))).toBe(true); + expect(inQuietHours(q, at(3))).toBe(true); + expect(inQuietHours(q, at(7, 59))).toBe(true); + expect(inQuietHours(q, at(8, 0))).toBe(false); + expect(inQuietHours(q, at(12))).toBe(false); + }); + test('quiet hours inside one day, equal ends and bad times', () => { + const q = { enabled: true, from: '13:00', to: '14:30' }; + expect(inQuietHours(q, at(13, 0))).toBe(true); + expect(inQuietHours(q, at(14, 30))).toBe(false); + expect(inQuietHours({ enabled: true, from: '09:00', to: '09:00' }, at(9))).toBe(false); + expect(inQuietHours({ enabled: true, from: '9am', to: '10:00' }, at(9, 30))).toBe(false); + }); + test('mute for an hour', () => { + const now = at(12).getTime(); + expect(isMuted(0, now)).toBe(false); + expect(isMuted(now + 3_600_000, now)).toBe(true); + expect(isMuted(now - 1, now)).toBe(false); + }); + test('mayNotify combines them; a test notification always shows', () => { + const q = { ...n, quietHours: { enabled: true, from: '23:00', to: '08:00' } }; + expect(mayNotify(n, 'rejected', { now: at(12) })).toBe(true); + expect(mayNotify(q, 'rejected', { now: at(2) })).toBe(false); + expect(mayNotify(n, 'rejected', { now: at(12), muteUntil: at(12).getTime() + 1000 })).toBe(false); + expect(mayNotify({ ...n, rejected: false }, 'rejected', { now: at(12) })).toBe(false); + expect(mayNotify(q, 'test', { now: at(2), muteUntil: at(2).getTime() + 1000 })).toBe(true); + }); + test('settings from an older file get the new notify fields', () => { + const s = normalizeSettings({ notify: { reviewState: false, quietHours: { enabled: true, from: 'x', to: '07:30' } } }); + expect(s.notify).toMatchObject({ reviewState: false, rejected: true, approved: true, live: true, quietHours: { enabled: true, from: '23:00', to: '07:30' } }); + expect(normalizeSettings({ refresh: { everyMinutes: 30 } }).refresh.everyMinutes).toBe(30); + expect(DEFAULT_SETTINGS.refresh.everyMinutes).toBe(15); + }); +}); + +describe('background reading pace', () => { + test('interval with jitter, doubled per failure, capped', () => { + expect(nextReadDelay(15, 0, 0.5)).toBe(15 * 60_000); + expect(nextReadDelay(15, 0, 0)).toBe(Math.round(15 * 60_000 * 0.9)); + expect(nextReadDelay(15, 0, 0.999)).toBeLessThanOrEqual(Math.round(15 * 60_000 * 1.1)); + expect(nextReadDelay(15, 1, 0.5)).toBe(30 * 60_000); + expect(nextReadDelay(15, 2, 0.5)).toBe(60 * 60_000); + expect(nextReadDelay(15, 9, 0.5)).toBe(2 * 3_600_000); + expect(nextReadDelay(5, -3, 0.5)).toBe(5 * 60_000); + }); +}); + +describe('tray summary and routes', () => { + const app = (key: string, name: string, ...rels: Rel[]) => ({ key, name, status: snap(...rels).apps[0] }); + const tr = { phaseText: (p: string) => p, store: (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Play'), none: '-', muted: 'muted', + tipReview: (n: number) => `${n} in review`, tipAttention: (n: number) => `${n} needs you`, + labels: { open: 'o', readNow: 'r', apps: 'a', noApps: 'n', mute: 'm', unmute: 'u', update: 'U', settings: 's', quit: 'q' } }; + test('counts, attention and one-word states', () => { + const s = traySummary([ + app('1', 'One', ios('IN_REVIEW', 'review'), play('completed', 'live')), + app('2', 'Two', ios('REJECTED', 'rejected')), + app('3', 'Three', ios('READY_FOR_SALE', 'live'), ios('PREPARE_FOR_SUBMISSION', 'draft')), + { key: '4', name: 'Four', status: undefined }, + ], false, tr); + expect(s.tooltip).toBe('storebound · 1 in review · 1 needs you'); + expect(s.attention).toBe(1); + expect(s.apps.map(a => a.state)).toEqual(['App Store: review · Play: live', 'App Store: rejected', 'App Store: live', '-']); + }); + test('muted shows in the tooltip; nothing going on is just the name', () => { + expect(traySummary([app('1', 'One', ios('READY_FOR_SALE', 'live'))], true, tr).tooltip).toBe('storebound · muted'); + expect(traySummary([], false, tr).tooltip).toBe('storebound'); + }); + test('the most pressing release of a store wins', () => { + expect(trayPhases(snap(ios('READY_FOR_SALE', 'live'), { ...ios('IN_REVIEW', 'review'), version: '3.0' }).apps[0]).ios).toBe('review'); + }); + test('routes from the host are checked', () => { + expect(parseRouteJson('{"screen":"app","appId":"k","tab":"reviews"}')).toEqual({ screen: 'app', appId: 'k', tab: 'reviews' }); + expect(parseRouteJson('{"screen":"app","appId":"k","tab":"nope"}')).toEqual({ screen: 'app', appId: 'k', tab: 'releases' }); + expect(parseRouteJson('{"screen":"settings","section":"updates"}')).toEqual({ screen: 'settings', section: 'updates' }); + expect(parseRouteJson('{"screen":"app"}')).toBeNull(); + expect(parseRouteJson('{"screen":"x"}')).toBeNull(); + expect(parseRouteJson('junk')).toBeNull(); + }); +}); diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 5594864..b1ad271 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -171,6 +171,8 @@ export interface StoreRelease { version: string | null; /** iOS build number (CFBundleVersion) / highest Android versionCode. */ build: string | null; + /** The store's reason text of a rejection, when the status carries one (the state word alone does not). */ + reason?: string | null; /** The store's own state word (READY_FOR_SALE, inProgress ...). */ state: string; phase: ReleasePhase; From 4fe26326cc15c26fc7e5a23858ea1e2db1c2c75e Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 22:31:29 +0800 Subject: [PATCH 034/255] Desktop: start.log line before Tauri starts (version, args, working folder, pid), so a logon start that dies early leaves a trace --- apps/desktop/src-tauri/src/lib.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0eb43e0..bac93cd 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -198,7 +198,25 @@ fn dev_seed(data_dir: &std::path::Path) { } } +/// One line before Tauri starts: a start at logon that dies early (before the host log exists) still leaves a trace +/// with its arguments and working folder (a logon start once left no line at all). Best effort, never fails. +fn early_trace() { + use std::io::Write; + let base = std::env::var_os("APPDATA").or_else(|| std::env::var_os("XDG_DATA_HOME")).map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share"))); + let Some(base) = base else { return }; + let dir = base.join("dev.storebound.desktop").join("logs"); + if std::fs::create_dir_all(&dir).is_err() { return } + let args: Vec = std::env::args().skip(1).collect(); + let cwd = std::env::current_dir().map(|d| d.display().to_string()).unwrap_or_default(); + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("start.log")) { + let _ = writeln!(f, "{secs} start {} args={args:?} cwd={cwd} pid={}", env!("CARGO_PKG_VERSION"), std::process::id()); + } +} + pub fn run() { + early_trace(); linux_webkit_env(); let app = tauri::Builder::default() // single-instance first: a second start (or a storebound:// link) focuses the running app instead From 78c541f2b8cb6ae0658bbdc9e0ec253ec138e31a Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 22:38:15 +0800 Subject: [PATCH 035/255] storebound watch: store events for every app on the machine (rejected, approved, live, halted, crash threshold, reviews) to ntfy / Telegram / webhook / OS notification, loop or --once, baseline first, events.jsonl; storebound events and MCP events_recent; docs/watch.md; tests --- CHANGELOG.md | 5 + cli/commands.ts | 2 + cli/data/flags.json | 23 +++ cli/events.ts | 20 +++ cli/lib/args.ts | 2 +- cli/lib/desktop-coverage.ts | 2 + cli/lib/watch-sinks.ts | 137 +++++++++++++++++ cli/lib/watch.ts | 269 +++++++++++++++++++++++++++++++++ cli/mcp-tools.ts | 5 + cli/watch.ts | 196 ++++++++++++++++++++++++ docs/desktop-coverage.md | 4 +- docs/watch.md | 67 ++++++++ skills/store-health/SKILL.md | 4 + tests/desktop-coverage.test.ts | 2 +- tests/json-stdout.test.ts | 2 +- tests/watch.test.ts | 248 ++++++++++++++++++++++++++++++ 16 files changed, 984 insertions(+), 4 deletions(-) create mode 100644 cli/events.ts create mode 100644 cli/lib/watch-sinks.ts create mode 100644 cli/lib/watch.ts create mode 100644 cli/watch.ts create mode 100644 docs/watch.md create mode 100644 tests/watch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0220309..cfd3fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- **`storebound watch`**: store events for every app on the machine (rejected, approved, live, rollout halted, crash + threshold, low or unanswered reviews), sent to ntfy (phone), Telegram, a webhook or an OS notification; runs as a loop + (`--every 15m`) or `--once`; a PM2 recipe for a server is in docs/watch.md. `storebound events` and the MCP tool + `events_recent` let people and agents read what happened. + - **Desktop notifications that arrive.** The app reads the stores in the background from the tray too (it skipped every read while hidden, so a rejection went unnoticed), every 15 minutes with backoff. Notifications by kind, each with a switch: rejected, approved, live, halted, crash / ANR, low-rated reviews; quiet hours and mute for an hour. diff --git a/cli/commands.ts b/cli/commands.ts index 5b0f6ae..715c8b0 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -11,6 +11,8 @@ export const PLAIN: Record Promise, string]> = { 'import': [() => import('./import'), 'fastlane [--dir fastlane] [--profile app|game|subscription-app] [--dry]: Appfile, deliver + supply metadata and screenshots -> config, store/*.md, shots frames (read-only on fastlane, never overwrites)'], 'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'], 'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'], + 'watch': [() => import('./watch'), 'store events for every app on this machine: rejected, approved, live, rollout halted, crash threshold, low reviews; loops every --every 15m or --once; sinks --notify os|ntfy|telegram|webhook; test sends a test event; --dry, --dir , --json (read-only on the stores)'], + 'events': [() => import('./events'), '[--since 24h] [--app ] [--kind rejected] [--json]: the events watch recorded in ~/.storebound/events.jsonl (read-only)'], 'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'], 'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [] | remove : the app folders of this machine, the same list as the desktop app'], 'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'], diff --git a/cli/data/flags.json b/cli/data/flags.json index f581df0..af740bc 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -510,6 +510,11 @@ "ops", "show" ], + "events": [ + "app", + "kind", + "since" + ], "flow": [ "apply", "from", @@ -973,6 +978,13 @@ "weekly", "write" ], + "watch": [ + "dir", + "every", + "notify", + "once", + "stdio" + ], "web": [ "base-url", "landing", @@ -1058,6 +1070,7 @@ "download", "enable", "end", + "every", "experiment", "expo", "fields", @@ -1098,6 +1111,7 @@ "keychain", "keyword", "keywords", + "kind", "landing", "lang", "languages", @@ -1140,9 +1154,11 @@ "no-wait", "no-web", "not-sent-for-review", + "notify", "off", "offline", "older-than", + "once", "only-decreases", "only-keywords", "only-missing", @@ -1444,6 +1460,9 @@ "storebound edit --ops '' [--dry] [--json]", "storebound edit --show --json every value the desktop's editors show (listings, config sections, git state)" ], + "events": [ + "storebound events [--since 24h] [--app ] [--kind rejected,live] [--json]" + ], "flow": [ "storebound flow list [--json] the flows of this project and their steps", "storebound flow validate [--json] definitions, sub-flow cycles, commands, variables; exit 1 on a problem", @@ -1605,6 +1624,10 @@ "validate": [ "storebound validate [--apple] [--play] [--json] [--src store/renders] [--no-local] [--no-health] [--no-scan]" ], + "watch": [ + "storebound watch [--dir ] [--every 15m] [--notify os,ntfy,telegram,webhook] [--once] [--dry] [--json]", + "storebound watch test [--notify ...] [--dry] sends one test event to every configured sink" + ], "web": [ "storebound web init [--out web] [--landing] [--version-code N] [--dry] [--json]", "storebound web check [] [--json] read-only, no credentials: the live files on https://" diff --git a/cli/events.ts b/cli/events.ts new file mode 100644 index 0000000..c4d72be --- /dev/null +++ b/cli/events.ts @@ -0,0 +1,20 @@ +// `storebound events`: the events `storebound watch` recorded in ~/.storebound/events.jsonl (and events.jsonl.1), read-only. +// storebound events [--since 24h] [--app ] [--kind rejected,live] [--json] +// Kinds: rejected, approved, live, rollout_halted, crash_threshold, review_low, review_needs_reply. Newest last. +import type { Args } from './lib/args'; +import { done, printJson } from './lib/log'; +import { textTable } from './lib/table'; +import { durationMs, EVENT_KINDS, eventsFile, filterEvents, readEvents } from './lib/watch'; + +export function run(args: Args): Promise { + const kinds = args.list('kind'); + for (const k of kinds) if (!(EVENT_KINDS as readonly string[]).includes(k)) throw new Error(`--kind: ${k} is not an event kind (${EVENT_KINDS.join(', ')})`); + const since = args.get('since'); + const evs = filterEvents(readEvents(), { ...(since ? { sinceMs: durationMs(since) } : {}), ...(args.get('app') ? { app: args.get('app')! } : {}), kind: kinds }); + if (args.has('json')) printJson(evs); + else { + console.log(textTable(['at', 'app', 'store', 'kind', 'version', 'detail'], evs.map(e => [e.at.slice(0, 16).replace('T', ' '), e.app, e.store, e.kind, e.version ?? '', e.detail.slice(0, 90)]))); + done('events', `${evs.length} event(s) in ${eventsFile()}${since ? ` since ${since}` : ''}`); + } + return Promise.resolve(); +} diff --git a/cli/lib/args.ts b/cli/lib/args.ts index e744174..5e68b04 100644 --- a/cli/lib/args.ts +++ b/cli/lib/args.ts @@ -20,7 +20,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args { const repeated = new Map(); const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); }; const _: string[] = []; - const bool = new Set(['dry', 'yes', 'allow-price-changes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', 'not-sent-for-review', 'review-sent', ...booleans]); + const bool = new Set(['dry', 'yes', 'allow-price-changes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', 'not-sent-for-review', 'review-sent', 'once', ...booleans]); for (let i = 0; i < argv.length; i++) { const a = argv[i]!; if (!a.startsWith('--')) { _.push(a); continue; } diff --git a/cli/lib/desktop-coverage.ts b/cli/lib/desktop-coverage.ts index 8628439..1a9f620 100644 --- a/cli/lib/desktop-coverage.ts +++ b/cli/lib/desktop-coverage.ts @@ -50,6 +50,8 @@ export const DESKTOP_COVERAGE: Record = { 'settings': { not: 'per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway' }, 'serve': { not: 'the JSON-RPC sidecar the window runs on; nothing to click' }, 'apps': { where: 'Accounts (store apps per account, key check) and App folders (list, add, remove)', methods: ['host:store_apps_refresh', 'host:account_check', 'host:projects_list', 'host:project_add', 'host:project_remove'] }, + 'watch': { not: 'a server loop for people and agents (PM2, cron); the window already reads the stores in the background and notifies from the tray' }, + 'events': { not: 'the log that watch writes for terminals and agents; the window shows its own what-changed feed and tray notifications' }, 'desktop': { not: 'opens or installs this window from a terminal' }, 'accounts': { where: 'Accounts screen (profiles shared with the CLI)', methods: ['host:accounts_list', 'host:account_add_play', 'host:account_add_apple', 'host:account_update'] }, 'doctor': { where: 'Dependencies screen', methods: ['system.tools', 'host:tools_info'] }, diff --git a/cli/lib/watch-sinks.ts b/cli/lib/watch-sinks.ts new file mode 100644 index 0000000..83c5c69 --- /dev/null +++ b/cli/lib/watch-sinks.ts @@ -0,0 +1,137 @@ +// Where `storebound watch` sends events: OS notification, ntfy, Telegram, a webhook. All optional, configured from the +// environment or ~/.storebound/watch.json (never printed; logs name the sink, not its URL, topic or token): +// STOREBOUND_NTFY_URL (default https://ntfy.sh) + STOREBOUND_NTFY_TOPIC [+ STOREBOUND_NTFY_TOKEN] +// STOREBOUND_TELEGRAM_TOKEN + STOREBOUND_TELEGRAM_CHAT +// STOREBOUND_WEBHOOK_URL +// watch.json: { "notify": ["os"], "ntfy": { "url", "topic", "token" }, "telegram": { "token", "chat" }, "webhook": { "url" } } +// Builders are pure (tests check them); `send` takes the fetch and the OS runner as parameters. +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { type EventKind, watchConfigFile, type WatchEvent } from './watch'; + +export type SinkName = 'os' | 'ntfy' | 'telegram' | 'webhook'; +export const SINK_NAMES: readonly SinkName[] = ['os', 'ntfy', 'telegram', 'webhook']; + +export interface SinkConfig { + os?: true; + ntfy?: { url: string; topic: string; token?: string }; + telegram?: { token: string; chat: string }; + webhook?: { url: string }; +} + +type Env = Record; + +/** The sinks that are configured (os only when the file lists it). `only` limits and, for os, enables. */ +export function sinkConfig(env: Env = process.env, file: string | null = watchConfigFile(env), only?: SinkName[]): SinkConfig { + let f: { notify?: string[]; ntfy?: Record; telegram?: Record; webhook?: Record } = {}; + if (file) { try { f = JSON.parse(readFileSync(file, 'utf8')); } catch { /* no file */ } } + const out: SinkConfig = {}; + const topic = env.STOREBOUND_NTFY_TOPIC || f.ntfy?.topic; + if (topic) out.ntfy = { url: (env.STOREBOUND_NTFY_URL || f.ntfy?.url || 'https://ntfy.sh').replace(/\/+$/, ''), topic, ...(env.STOREBOUND_NTFY_TOKEN || f.ntfy?.token ? { token: (env.STOREBOUND_NTFY_TOKEN || f.ntfy?.token)! } : {}) }; + const tg = { token: env.STOREBOUND_TELEGRAM_TOKEN || f.telegram?.token, chat: env.STOREBOUND_TELEGRAM_CHAT || f.telegram?.chat }; + if (tg.token && tg.chat) out.telegram = { token: tg.token, chat: tg.chat }; + const hook = env.STOREBOUND_WEBHOOK_URL || f.webhook?.url; + if (hook) out.webhook = { url: hook }; + if (Array.isArray(f.notify) && f.notify.includes('os')) out.os = true; + if (only) { + const picked: SinkConfig = {}; + for (const n of only) if (n === 'os') picked.os = true; else if (out[n]) (picked as Record)[n] = out[n]; + return picked; + } + return out; +} + +export const configuredSinks = (c: SinkConfig): SinkName[] => SINK_NAMES.filter(n => !!c[n]); + +/** `--notify os,ntfy` -> names; throws on an unknown one. */ +export function parseNotify(list: string[]): SinkName[] { + for (const n of list) if (!(SINK_NAMES as readonly string[]).includes(n)) throw new Error(`--notify: ${n} is not a sink (${SINK_NAMES.join(', ')})`); + return list as SinkName[]; +} + +const LABEL: Record = { + rejected: 'Rejected', approved: 'Approved', live: 'Live', rollout_halted: 'Rollout halted', crash_threshold: 'Crash threshold', + review_low: 'Low review', review_needs_reply: 'Review needs a reply', +}; +const TAGS: Record = { + rejected: 'x', approved: 'white_check_mark', live: 'rocket', rollout_halted: 'octagonal_sign', crash_threshold: 'warning', review_low: 'star', review_needs_reply: 'speech_balloon', +}; +const URGENT: ReadonlySet = new Set(['rejected', 'rollout_halted']); + +/** HTTP header values are Latin-1 text on one line. */ +const header = (s: string): string => s.replace(/[\r\n]+/g, ' ').replace(/[^\x20-\x7e]/g, '?').slice(0, 250); +export const titleOf = (e: WatchEvent): string => `${e.app}${e.version ? ` ${e.version}` : ''}: ${LABEL[e.kind]}`; +export const textOf = (e: WatchEvent): string => `${e.detail}\n${e.url}`; + +export interface HttpRequest { url: string; headers: Record; body: string } + +export function ntfyRequest(e: WatchEvent, c: NonNullable): HttpRequest { + return { url: `${c.url}/${encodeURIComponent(c.topic)}`, body: e.detail, + headers: { Title: header(titleOf(e)), Tags: `${TAGS[e.kind]},${e.store}`, Click: e.url, Priority: URGENT.has(e.kind) ? 'high' : 'default', ...(c.token ? { Authorization: `Bearer ${c.token}` } : {}) } }; +} +export function telegramRequest(e: WatchEvent, c: NonNullable): HttpRequest { + return { url: `https://api.telegram.org/bot${c.token}/sendMessage`, headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: c.chat, text: `${titleOf(e)}\n${textOf(e)}`.slice(0, 4000), disable_web_page_preview: true }) }; +} +export function webhookRequest(e: WatchEvent, c: NonNullable): HttpRequest { + return { url: c.url, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(e) }; +} + +/** The command of an OS notification (title and message travel in the environment, never inside a script string). */ +export function osCommand(platform: string, title: string, message: string): { cmd: string; args: string[]; env: Record } | null { + const env = { SB_TITLE: title.slice(0, 120), SB_MSG: message.slice(0, 400) }; + if (platform === 'win32') { + const script = [ + '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null', + '$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)', + '$t = $x.GetElementsByTagName(\'text\')', + '$t.Item(0).AppendChild($x.CreateTextNode($env:SB_TITLE)) > $null', + '$t.Item(1).AppendChild($x.CreateTextNode($env:SB_MSG)) > $null', + '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(\'storebound\').Show([Windows.UI.Notifications.ToastNotification]::new($x))', + ].join('; '); + return { cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', script], env }; + } + if (platform === 'darwin') return { cmd: 'osascript', args: ['-e', 'display notification (system attribute "SB_MSG") with title (system attribute "SB_TITLE")'], env }; + if (platform === 'linux') return { cmd: 'notify-send', args: [env.SB_TITLE, env.SB_MSG], env }; + return null; +} + +export type FetchLike = (url: string, init: { method: string; headers: Record; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number }>; +export type OsRunner = (c: { cmd: string; args: string[]; env: Record }) => boolean; + +export const runOs: OsRunner = c => { + try { return spawnSync(c.cmd, c.args, { env: { ...process.env, ...c.env }, timeout: 10_000, stdio: 'ignore', windowsHide: true }).status === 0; } catch { return false; } +}; + +/** POST with a timeout and one retry (network error, 429 or 5xx). Resolves to an error text, or null when sent. */ +export async function post(fetchFn: FetchLike, r: HttpRequest, o: { timeoutMs?: number; retryMs?: number } = {}): Promise { + let last = ''; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt) await new Promise(res => setTimeout(res, o.retryMs ?? 1500)); + try { + const res = await fetchFn(r.url, { method: 'POST', headers: r.headers, body: r.body, signal: AbortSignal.timeout(o.timeoutMs ?? 10_000) }); + if (res.ok) return null; + last = `HTTP ${res.status}`; + if (res.status < 500 && res.status !== 429) return last; + } catch (e) { last = (e as Error).name === 'TimeoutError' ? 'timed out' : 'network error'; } + } + return last; +} + +export interface SendReport { sink: SinkName; ok: boolean; error?: string } + +/** Sends one event to every sink in the config. A failing sink is a report row, never an exception. */ +export async function sendEvent(e: WatchEvent, c: SinkConfig, d: { fetch?: FetchLike; os?: OsRunner; platform?: string; timeoutMs?: number; retryMs?: number } = {}): Promise { + const fetchFn = d.fetch ?? (fetch as unknown as FetchLike); + const out: SendReport[] = []; + const http = async (sink: SinkName, r: HttpRequest): Promise => { const err = await post(fetchFn, r, d); out.push(err ? { sink, ok: false, error: err } : { sink, ok: true }); }; + if (c.os) { + const cmd = osCommand(d.platform ?? process.platform, titleOf(e), e.detail); + // unavailable (no notifier on this machine): skipped quietly, not a failure + if (cmd) { const ok = (d.os ?? runOs)(cmd); if (ok) out.push({ sink: 'os', ok: true }); } + } + if (c.ntfy) await http('ntfy', ntfyRequest(e, c.ntfy)); + if (c.telegram) await http('telegram', telegramRequest(e, c.telegram)); + if (c.webhook) await http('webhook', webhookRequest(e, c.webhook)); + return out; +} diff --git a/cli/lib/watch.ts b/cli/lib/watch.ts new file mode 100644 index 0000000..b037b37 --- /dev/null +++ b/cli/lib/watch.ts @@ -0,0 +1,269 @@ +// `storebound watch` core, pure and offline: the snapshot an app is reduced to, the diff of two snapshots into events, +// the state file (~/.storebound/watch-state.json) and the event log (~/.storebound/events.jsonl, rotated at 5 MB). +// STOREBOUND_HOME moves both (the same folder as settings.json, cli/lib/bypass.ts). Network and process work lives in +// cli/watch.ts and cli/lib/watch-sinks.ts. +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { type ConsoleIds, consoleUrl } from './console-links'; +import { lastValue, PLAY_THRESHOLDS, type HealthSnapshot, weightedAverage } from './health'; +import type { StoreRelease } from './desktop-protocol'; +import { PHASE_RANK } from './portfolio'; + +export const EVENT_KINDS = ['rejected', 'approved', 'live', 'rollout_halted', 'crash_threshold', 'review_low', 'review_needs_reply'] as const; +export type EventKind = (typeof EVENT_KINDS)[number]; +export type WatchStore = 'apple' | 'play'; + +export interface WatchEvent { + at: string; + app: string; + store: WatchStore; + kind: EventKind; + version: string | null; + from: string | null; + to: string | null; + detail: string; + /** The absolute console deep link for the app (App Store Connect / Play Console). */ + url: string; +} + +/** One version on one store, reduced to what the diff needs. */ +export interface RelSnap { phase: string; state: string; fraction: number | null } +/** A review the watch has seen (text trimmed; the author is never kept). */ +export interface ReviewSnap { id: string; rating: number; replied: boolean; date: string; text: string } + +/** What one app looked like at the last read. A part that is absent was not read (never guessed). */ +export interface AppSnap { + name: string; + ids: ConsoleIds; + releases?: { apple?: Record; play?: Record }; + /** Play: 28-day user-perceived rates (null = no data); Apple: the metrics / diagnostics rated poor. */ + health?: { play?: { crash: number | null; anr: number | null }; apple?: { poor: string[] } }; + healthAt?: string; + reviews?: { apple?: ReviewSnap[]; play?: ReviewSnap[] }; +} + +export interface WatchState { version: 1; at: string; apps: Record } + +export const STATE_VERSION = 1 as const; +export const emptyState = (): WatchState => ({ version: STATE_VERSION, at: '', apps: {} }); +export const MAX_SEEN_REVIEWS = 300; +export const LOW_STARS = 2; +export const ROTATE_BYTES = 5 * 1024 * 1024; + +// ---- files ---- + +export const watchHome = (env: Record = process.env): string => env.STOREBOUND_HOME ?? join(homedir(), '.storebound'); +export const stateFile = (env: Record = process.env): string => join(watchHome(env), 'watch-state.json'); +export const eventsFile = (env: Record = process.env): string => join(watchHome(env), 'events.jsonl'); +export const watchConfigFile = (env: Record = process.env): string => join(watchHome(env), 'watch.json'); + +export function readState(file = stateFile()): WatchState { + try { + const s = JSON.parse(readFileSync(file, 'utf8')) as WatchState; + if (s && s.version === STATE_VERSION && s.apps && typeof s.apps === 'object') return s; + } catch { /* none yet, or unreadable: a new baseline */ } + return emptyState(); +} + +/** Atomic write (temp file, rename). */ +export function writeState(s: WatchState, file = stateFile()): void { + mkdirSync(dirname(file), { recursive: true }); + const tmp = `${file}.tmp-${process.pid}`; + writeFileSync(tmp, JSON.stringify(s) + '\n'); + renameSync(tmp, file); +} + +/** Appends events as JSON lines; a log of `maxBytes` or more is moved to .1 first (one old file is kept). */ +export function appendEvents(events: WatchEvent[], file = eventsFile(), maxBytes = ROTATE_BYTES): void { + if (!events.length) return; + mkdirSync(dirname(file), { recursive: true }); + try { + if (existsSync(file) && statSync(file).size >= maxBytes) { + rmSync(`${file}.1`, { force: true }); + renameSync(file, `${file}.1`); + } + } catch { /* rotation is best effort; appending still works */ } + appendFileSync(file, events.map(e => JSON.stringify(e)).join('\n') + '\n'); +} + +/** Every event of the log (the old file first), oldest first. Unreadable lines are skipped. */ +export function readEvents(file = eventsFile()): WatchEvent[] { + const out: WatchEvent[] = []; + for (const f of [`${file}.1`, file]) { + let text = ''; + try { text = readFileSync(f, 'utf8'); } catch { continue; } + for (const line of text.split('\n')) { + if (!line.trim()) continue; + try { const e = JSON.parse(line) as WatchEvent; if (e && typeof e.at === 'string' && typeof e.kind === 'string') out.push(e); } catch { /* a torn line */ } + } + } + return out; +} + +/** `events` filters: since (ms back from now), app (case-insensitive name match), kind. Newest last. */ +export function filterEvents(evs: WatchEvent[], o: { sinceMs?: number; now?: number; app?: string; kind?: string[] } = {}): WatchEvent[] { + const cut = o.sinceMs !== undefined ? (o.now ?? Date.now()) - o.sinceMs : null; + const app = o.app?.toLowerCase(); + return evs.filter(e => (cut === null || Date.parse(e.at) >= cut) && (!app || e.app.toLowerCase() === app || e.app.toLowerCase().includes(app)) + && (!o.kind?.length || o.kind.includes(e.kind))); +} + +/** "15m", "24h", "2d"; a bare number is `bare` ("m" for --every, "s" otherwise). */ +export function durationMs(s: string, bare: 's' | 'm' = 's'): number { + const m = /^(\d+(?:\.\d+)?)(s|m|h|d)?$/.exec(s.trim()); + if (!m) throw new Error(`bad duration "${s}" (use 15m, 24h, 2d)`); + return Math.round(Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[(m[2] ?? bare) as 's' | 'm' | 'h' | 'd']); +} + +// ---- reducing store reads to a snapshot ---- + +/** Per version of one store, the furthest-along row (live before rollout before review ...). */ +export function releaseMap(rows: StoreRelease[], store: 'ios' | 'android'): Record { + const out: Record = {}; + for (const r of rows) { + if (r.store !== store || !r.version) continue; + const rank = PHASE_RANK[r.phase]; + if (!out[r.version] || rank < out[r.version]!.rank) out[r.version] = { phase: r.phase, state: r.state, fraction: r.fraction, rank }; + } + return Object.fromEntries(Object.entries(out).map(([v, { rank: _r, ...s }]) => [v, s])); +} + +/** The Play rates the health findings judge (28-day user-weighted, else the window average), and Apple's poor items. */ +export function healthSnap(h: HealthSnapshot): NonNullable { + const out: NonNullable = {}; + if (h.play && !('error' in h.play)) { + out.play = { + crash: lastValue(h.play.crash, 'userPerceivedCrashRate28dUserWeighted') ?? weightedAverage(h.play.crash, 'userPerceivedCrashRate'), + anr: lastValue(h.play.anr, 'userPerceivedAnrRate28dUserWeighted') ?? weightedAverage(h.play.anr, 'userPerceivedAnrRate'), + }; + } + if (h.apple && !('error' in h.apple)) { + out.apple = { poor: [ + ...h.apple.metrics.filter(m => m.goal === 'poor').map(m => `${m.metric} ${m.value} ${m.unit} on ${m.version}`), + ...h.apple.signatures.filter(s => s.weight >= 0.25).map(s => `${s.type} build ${s.build}: ${s.signature.slice(0, 80)}`), + ] }; + } + return out; +} + +/** Keeps what the new read has and what the old one had for a part this read could not get. Pure. */ +export function mergeSnap(prev: AppSnap | undefined, next: AppSnap): AppSnap { + const seen = (a?: ReviewSnap[], b?: ReviewSnap[]): ReviewSnap[] | undefined => { + if (!a && !b) return undefined; + const byId = new Map(); + for (const r of [...(b ?? []), ...(a ?? [])]) byId.set(r.id, r); + return [...byId.values()].sort((x, y) => y.date.localeCompare(x.date)).slice(0, MAX_SEEN_REVIEWS); + }; + const releases = { ...(prev?.releases ?? {}), ...(next.releases ?? {}) }; + const health = { ...(prev?.health ?? {}), ...(next.health ?? {}) }; + const reviews = { apple: seen(next.reviews?.apple, prev?.reviews?.apple), play: seen(next.reviews?.play, prev?.reviews?.play) }; + const hasR = reviews.apple || reviews.play; + return { name: next.name, ids: { ...prev?.ids, ...Object.fromEntries(Object.entries(next.ids).filter(([, v]) => v)) }, + ...(Object.keys(releases).length ? { releases } : {}), ...(Object.keys(health).length ? { health } : {}), + ...(next.healthAt ?? prev?.healthAt ? { healthAt: next.healthAt ?? prev?.healthAt } : {}), + ...(hasR ? { reviews: { ...(reviews.apple ? { apple: reviews.apple } : {}), ...(reviews.play ? { play: reviews.play } : {}) } } : {}) }; +} + +// ---- the diff ---- + +const REACHING = new Set(['live', 'rollout', 'halted', 'released']); +const pct = (x: number | null): string => (x === null ? 'n/a' : `${(x * 100).toFixed(2)} %`); +const storeName = (s: WatchStore): string => (s === 'apple' ? 'App Store' : 'Google Play'); + +/** The console page that explains each kind, per store. Apple has no deep link for reviews or crashes in the table: + * the version page / TestFlight stand in (the app's own pages, never a made-up path). */ +export function eventUrl(kind: EventKind, store: WatchStore, ids: ConsoleIds): string { + if (store === 'apple') return consoleUrl(kind === 'rejected' ? 'asc.app-review' : kind === 'crash_threshold' ? 'asc.testflight' : 'asc.version', ids); + return consoleUrl(kind === 'crash_threshold' ? 'play.vitals' : kind === 'review_low' || kind === 'review_needs_reply' ? 'play.dashboard' : 'play.production', ids); +} + +const snippet = (s: string, n = 140): string => s.replace(/\s+/g, ' ').trim().slice(0, n); + +/** + * Events between two snapshots of one app. `prev` undefined = the app's first read: nothing (baseline). A part that + * either side does not have is skipped, so a store that could not be read never produces an event, and a part read + * for the first time is a baseline too. Pure. + */ +export function diffApp(prev: AppSnap | undefined, next: AppSnap, at: string): WatchEvent[] { + if (!prev) return []; + const out: WatchEvent[] = []; + const ids = { ...prev.ids, ...next.ids }; + const ev = (store: WatchStore, kind: EventKind, version: string | null, from: string | null, to: string | null, detail: string): void => { + out.push({ at, app: next.name, store, kind, version, from, to, detail, url: eventUrl(kind, store, ids) }); + }; + + for (const store of ['apple', 'play'] as const) { + const p = prev.releases?.[store], n = next.releases?.[store]; + if (p && n) for (const [version, now] of Object.entries(n)) { + const was = p[version]; + if (was?.phase === now.phase) continue; + const from = was?.state ?? null, to = now.state, v = version; + if (now.phase === 'rejected') ev(store, 'rejected', v, from, to, `${storeName(store)} ${v}: review ended in ${to}${store === 'apple' ? ' (the reason is in the Resolution Center)' : ''}`); + else if (now.phase === 'approved') ev(store, 'approved', v, from, to, `${storeName(store)} ${v}: passed review, waiting for release (${to})`); + else if ((now.phase === 'live' || now.phase === 'rollout') && !REACHING.has(was?.phase ?? '')) { + ev(store, 'live', v, from, to, `${storeName(store)} ${v} is ${now.phase === 'live' ? 'live' : `rolling out${now.fraction !== null ? ` at ${Math.round(now.fraction * 1000) / 10} %` : ''}`}`); + } else if (now.phase === 'halted') ev(store, 'rollout_halted', v, from, to, `${storeName(store)} ${v}: rollout halted${now.fraction !== null ? ` at ${Math.round(now.fraction * 1000) / 10} %` : ''}`); + } + } + + const pp = prev.health?.play, np = next.health?.play; + if (pp && np) { + for (const [key, label, limit] of [['crash', 'user-perceived crash rate', PLAY_THRESHOLDS.crash], ['anr', 'user-perceived ANR rate', PLAY_THRESHOLDS.anr]] as const) { + const was = pp[key], now = np[key]; + if (now !== null && now >= limit && (was === null || was < limit)) { + ev('play', 'crash_threshold', null, pct(was), pct(now), `${label} (28 d) ${pct(now)} crossed the Play bad behavior threshold ${pct(limit)}`); + } + } + } + const pa = prev.health?.apple, na = next.health?.apple; + if (pa && na) for (const item of na.poor) if (!pa.poor.includes(item)) ev('apple', 'crash_threshold', null, null, 'poor', `App Store diagnostics: ${item}`); + + for (const store of ['apple', 'play'] as const) { + const p = prev.reviews?.[store], n = next.reviews?.[store]; + if (!p || !n) continue; + const seen = new Set(p.map(r => r.id)); + for (const r of n) { + if (seen.has(r.id)) continue; + if (r.rating >= 1 && r.rating <= LOW_STARS) ev(store, 'review_low', null, null, `${r.rating} stars`, `${r.rating}-star review${r.replied ? '' : ', no reply yet'}: ${snippet(r.text)}`); + else if (!r.replied) ev(store, 'review_needs_reply', null, null, `${r.rating} stars`, `${r.rating}-star review without a reply: ${snippet(r.text)}`); + } + } + return out; +} + +export interface PassTarget { key: string; name: string } +export interface AppRead { snap: AppSnap | null; errors: string[] } + +/** + * One pass over the targets: `read` gives each app's new snapshot; the result is the new state, the events and the + * errors. An app whose read failed entirely keeps its old snapshot. Apps in the state that are no longer targets are + * kept (a folder removed for a while does not lose its baseline). Pure given `read`. + */ +export async function runPass(o: { targets: PassTarget[]; prev: WatchState; at: string; read: (t: PassTarget, prev: AppSnap | undefined) => Promise; concurrency?: number }): + Promise<{ state: WatchState; events: WatchEvent[]; errors: { app: string; error: string }[]; baseline: string[] }> { + const events: WatchEvent[] = []; + const errors: { app: string; error: string }[] = []; + const baseline: string[] = []; + const apps: Record = { ...o.prev.apps }; + let next = 0; + const worker = async (): Promise => { + while (next < o.targets.length) { + const t = o.targets[next++]!; + const before = o.prev.apps[t.key]; + let r: AppRead; + try { r = await o.read(t, before); } catch (e) { r = { snap: null, errors: [(e as Error).message] }; } + for (const e of r.errors) errors.push({ app: t.name, error: e }); + if (!r.snap) continue; + events.push(...diffApp(before, r.snap, o.at)); + if (!before) baseline.push(t.name); + apps[t.key] = mergeSnap(before, r.snap); + } + }; + await Promise.all(Array.from({ length: Math.max(1, Math.min(o.concurrency ?? 2, o.targets.length)) }, worker)); + return { state: { version: STATE_VERSION, at: o.at, apps }, events, errors, baseline }; +} + +/** A made-up event for `watch test`. */ +export const testEvent = (at = new Date().toISOString()): WatchEvent => + ({ at, app: 'storebound test', store: 'apple', kind: 'live', version: '1.0.0', from: 'READY_FOR_SALE', to: 'READY_FOR_SALE', detail: 'This is a test event from storebound watch test', url: 'https://appstoreconnect.apple.com/apps' }); diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index ea8e7f1..48d241e 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -75,6 +75,11 @@ export const TOOLS: ToolDef[] = [ description: 'Weekly health summary as Markdown in .storebound/health/.md (plus .json for the next week-over-week comparison). Local files only; dry_run prints the report.', props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' } }, args: i => (i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []) }, + { name: 'events_recent', title: 'Recent store events', command: ['events'], write: false, network: false, + description: 'Read-only: the store events that storebound watch recorded on this machine (rejected, approved, live, rollout_halted, crash_threshold, review_low, review_needs_reply), newest last, each with the app, store, version, from / to state, detail and the console link. Nothing is read from the stores.', + props: { since: { type: 'string', pattern: '^\\d+(\\.\\d+)?[smhd]?$', description: 'e.g. 24h, 7d (default: all)' }, app: { type: 'string', description: 'App name (or part of it)' }, + kind: { type: 'string', pattern: '^[a-z_]+(,[a-z_]+)*$', description: 'e.g. rejected or rejected,rollout_halted' } }, + args: i => [...opt('since', i.since), ...opt('app', i.app), ...opt('kind', i.kind)] }, { name: 'reviews_list', title: 'Store reviews', command: ['reviews', 'list'], write: false, network: true, description: 'Read-only list of recent App Store and Google Play reviews (date, stars, territory / language, replied or not, text). Google Play only returns reviews from the last 7 days.', props: { store: { type: 'string', enum: ['apple', 'play'], description: 'Default: both' }, days: { type: 'integer', minimum: 1, maximum: 365, default: 14 }, diff --git a/cli/watch.ts b/cli/watch.ts new file mode 100644 index 0000000..0f12a41 --- /dev/null +++ b/cli/watch.ts @@ -0,0 +1,196 @@ +// `storebound watch`: store events for every app on this machine, for people and agents. +// storebound watch [--dir ] [--every 15m] [--notify os,ntfy,telegram,webhook] [--once] [--dry] [--json] +// storebound watch test [--notify ...] [--dry] sends one test event to every configured sink +// Apps: the machine's app folder list (~/.storebound/projects.json: folders and store-only apps, the same list as the +// desktop app) or --dir for one folder. Each pass reads release state (like `release status`), crash / ANR health +// (like `health`, at most hourly) and new reviews (like `reviews list`) per app with the credentials those commands +// use, compares with ~/.storebound/watch-state.json and emits events: rejected, approved, live, rollout_halted, +// crash_threshold, review_low (<= 2 stars), review_needs_reply (3-5 stars, no reply). The first pass of an app only +// records the baseline. Every event is appended to ~/.storebound/events.jsonl (`storebound events` reads it) and sent +// to the sinks (docs/watch.md). Read-only on the stores. STOREBOUND_HOME moves ~/.storebound. +// --every loop period, 5m or more (a bare number is minutes), with +-10 % jitter; errors back off up to 4 h +// --once one pass and exit (cron, PM2 restarts) --dry print what would be sent; nothing is sent or saved +// --json events as JSON lines on stdout +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { emptyStatus, fillAppStatus, lastLine, type Exec } from './lib/app-status'; +import type { Args } from './lib/args'; +import { CONFIG_NAMES } from './lib/config'; +import { consoleIdsFromConfig } from './lib/console-links'; +import { profileEnv } from './lib/credentials'; +import { done, log, warn } from './lib/log'; +import { readRegistry } from './lib/registry'; +import { runCommand } from './lib/runner'; +import type { HealthSnapshot } from './lib/health'; +import { + type AppRead, type AppSnap, appendEvents, durationMs, eventsFile, healthSnap, type PassTarget, readState, releaseMap, type ReviewSnap, runPass, stateFile, + testEvent, type WatchEvent, writeState, +} from './lib/watch'; +import { configuredSinks, parseNotify, sendEvent, sinkConfig, type SinkConfig } from './lib/watch-sinks'; + +const TAG = 'watch'; +const USAGE = 'usage: storebound watch [--dir ] [--every 15m] [--notify os,ntfy,telegram,webhook] [--once] [--dry] [--json] | watch test [--notify ...] [--dry]'; +export const HEALTH_EVERY_MS = 60 * 60_000; +const MIN_EVERY_MS = 5 * 60_000; +const MAX_BACKOFF_MS = 4 * 60 * 60_000; +const CHILD_TIMEOUT_MS = 5 * 60_000; +const REVIEW_DAYS = 3; + +interface Target extends PassTarget { dir: string; virtual: boolean; env: Record } + +/** Runs a storebound child command; a hung one is cancelled after the timeout. */ +const exec = (env: Record): Exec => async (argv, cwd) => { + const run = runCommand(argv, { cwd, env, machine: true }); + const timer = setTimeout(() => run.cancel(), CHILD_TIMEOUT_MS); + try { return await run.result; } finally { clearTimeout(timer); } +}; + +/** The apps to watch: --dir, or the registry's folders and store-only apps (a store-only app that has a folder here is read once). */ +async function listTargets(args: Args): Promise { + const dir = args.get('dir'); + if (dir) { + const d = resolve(dir); + if (!CONFIG_NAMES.some(n => existsSync(join(d, n)))) throw new Error(`--dir: no storebound.config.ts in ${d}`); + return [{ key: `dir:${d}`, name: d.split(/[\\/]/).pop() ?? d, dir: d, virtual: false, env: {} }]; + } + const reg = readRegistry(); + const out: Target[] = []; + for (const p of reg.projects) { + if (!CONFIG_NAMES.some(n => existsSync(join(p.path, n)))) { warn(TAG, `${p.name}: no storebound.config.ts in ${p.path}, skipped (storebound apps remove ${p.path})`); continue; } + out.push({ key: `dir:${p.path}`, name: p.name, dir: p.path, virtual: false, env: {} }); + } + if (reg.storeApps?.length) { + const { checkVirtual, virtualDir } = await import('./serve'); + for (const s of reg.storeApps) { + try { + if (s.store === 'apple' && !s.bundleId) throw new Error('no bundle id (storebound apps add --store apple --app-id --bundle-id )'); + const v = checkVirtual({ name: s.name, ...(s.store === 'apple' ? { apple: { bundleId: s.bundleId, appId: s.appId } } : { play: { packageName: s.appId } }) }); + out.push({ key: `store:${s.id}`, name: s.name, dir: virtualDir(v), virtual: true, env: s.account ? profileEnv(s.account) : {} }); + } catch (e) { warn(TAG, `${s.name}: ${(e as Error).message}, skipped`); } + } + } + return out; +} + +/** Reads one app: releases of both stores, health when due, new reviews. A part that cannot be read is left out. */ +async function readApp(t: Target, prev: AppSnap | undefined, now: number): Promise { + const run = exec(t.env); + const errors: string[] = []; + // virtual: true skips fillAppStatus's offline `check` (only the store reads are needed here) + const e = emptyStatus(t.dir); + try { await fillAppStatus(e, t.dir, { live: true, virtual: true, exec: run }); } catch (err) { return { snap: null, errors: [(err as Error).message] }; } + const s = e.summary!; + const snap: AppSnap = { name: s.name || t.name, ids: { appleAppId: s.apple?.appId ?? null } }; + if (s.play && !t.virtual && !prev?.ids.playDeveloperId) { + try { const { loadConfig } = await import('./lib/config'); Object.assign(snap.ids, consoleIdsFromConfig((await loadConfig(undefined, t.dir)).config)); } catch { /* links fall back to the console home */ } + } + const all = e.release.all ?? []; + snap.releases = {}; + if (s.apple) { if (e.storeErrors.ios) errors.push(`App Store: ${e.storeErrors.ios}`); else snap.releases.apple = releaseMap(all, 'ios'); } + if (s.play) { if (e.storeErrors.android) errors.push(`Google Play: ${e.storeErrors.android}`); else snap.releases.play = releaseMap(all, 'android'); } + + const due = !prev?.healthAt || now - Date.parse(prev.healthAt) >= HEALTH_EVERY_MS; + if (due && (s.apple || s.play)) { + const h = await run(['health', '--json'], t.dir); + if (h.data && typeof h.data === 'object') { snap.health = healthSnap(h.data as HealthSnapshot); snap.healthAt = new Date(now).toISOString(); } + else errors.push(`health: ${lastLine(h.out) || `exit ${h.code}`}`); + } + + snap.reviews = {}; + for (const [store, on] of [['apple', !!s.apple], ['play', !!s.play]] as const) { + if (!on) continue; + const r = await run(['reviews', 'list', '--store', store, '--days', String(REVIEW_DAYS), '--json'], t.dir); + if (r.code !== 0 || !Array.isArray(r.data)) { errors.push(`reviews ${store}: ${lastLine(r.out) || `exit ${r.code}`}`); continue; } + snap.reviews[store] = (r.data as { id: string; rating: number; title?: string; body?: string; date: string; reply?: unknown }[]).map((x): ReviewSnap => + ({ id: String(x.id), rating: x.rating, replied: !!x.reply, date: x.date, text: `${x.title ? x.title + ': ' : ''}${x.body ?? ''}`.slice(0, 300) })); + } + return { snap, errors }; +} + +const line = (e: WatchEvent): string => `${e.at.slice(0, 19)}Z ${e.app} [${e.store}] ${e.kind}${e.version ? ` ${e.version}` : ''}: ${e.detail} ${e.url}`; + +function chosenSinks(args: Args): SinkConfig { + const only = args.has('notify') ? parseNotify(args.list('notify')) : undefined; + if (only) { + const have = sinkConfig(); + for (const n of only) if (n !== 'os' && !have[n]) warn(TAG, `--notify ${n}: not configured (docs/watch.md), skipped`); + } + return sinkConfig(process.env, undefined, only); +} + +/** Sends to the sinks; a failing sink is logged (the sink name only) and never stops anything. */ +async function deliver(e: WatchEvent, c: SinkConfig, args: Args): Promise { + if (args.has('dry')) { log(TAG, `dry run: would send ${e.kind} of ${e.app} to ${configuredSinks(c).join(', ') || 'no sink'}`); return; } + for (const r of await sendEvent(e, c)) if (!r.ok) warn(TAG, `${r.sink}: not sent (${r.error})`); +} + +function emit(e: WatchEvent, args: Args): void { + if (args.has('json')) process.stdout.write(JSON.stringify(e) + '\n'); + else console.log(line(e)); +} + +async function testCmd(args: Args): Promise { + const c = chosenSinks(args); + const names = configuredSinks(c); + if (!names.length) { warn(TAG, 'no sink configured: set STOREBOUND_NTFY_TOPIC, STOREBOUND_TELEGRAM_TOKEN + STOREBOUND_TELEGRAM_CHAT, STOREBOUND_WEBHOOK_URL, or pass --notify os (docs/watch.md)'); process.exitCode = 1; return; } + const e = testEvent(); + emit(e, args); + if (args.has('dry')) { done(TAG, `dry run: would send the test event to ${names.join(', ')}`); return; } + const rep = await sendEvent(e, c); + for (const r of rep) (r.ok ? log : warn)(TAG, `${r.sink}: ${r.ok ? 'sent' : `not sent (${r.error})`}`); + const missing = names.filter(n => !rep.some(r => r.sink === n)); + if (missing.length) log(TAG, `${missing.join(', ')}: unavailable on this machine, skipped`); + if (rep.some(r => !r.ok)) process.exitCode = 1; + done(TAG, `test event: ${rep.filter(r => r.ok).length}/${names.length} sink(s) delivered`); +} + +export function everyMs(args: Args): number { + const ms = durationMs(args.str('every', '15m'), 'm'); + if (ms < MIN_EVERY_MS) throw new Error('--every: 5m at least (the stores rate-limit their APIs)'); + return ms; +} + +export async function run(args: Args): Promise { + const sub = args._[0]; + if (sub === 'test') return testCmd(args); + if (sub) throw new Error(USAGE); + const every = everyMs(args); + const sinks = chosenSinks(args); + const once = args.has('once'); + let stopping = false, wake: (() => void) | null = null; + const stop = (sig: string): void => { + if (stopping) process.exit(130); + stopping = true; + log(TAG, `${sig}: finishing the pass in progress, then stopping`); + wake?.(); + }; + process.on('SIGINT', () => stop('SIGINT')); + process.on('SIGTERM', () => stop('SIGTERM')); + + let n = 0, failures = 0; + log(TAG, `${once ? 'one pass' : `every ${Math.round(every / 60_000)}m`}, sinks: ${configuredSinks(sinks).join(', ') || 'none (events go to the log and stdout)'}${args.has('dry') ? ', dry run' : ''}`); + while (!stopping) { + n++; + let ok = false, count = 0, events = 0, errs = 0; + try { + const targets = await listTargets(args); + if (!targets.length) warn(TAG, 'no app folders: storebound apps add (or --dir )'); + const now = Date.now(); + const r = await runPass({ targets, prev: readState(), at: new Date(now).toISOString(), concurrency: 2, read: (t, prev) => readApp(t as Target, prev, now) }); + count = targets.length; events = r.events.length; errs = r.errors.length; + for (const x of r.errors) warn(TAG, `${x.app}: ${x.error.slice(0, 200)}`); + if (r.baseline.length) log(TAG, `baseline recorded for ${r.baseline.join(', ')} (no events on a first read)`); + for (const e of r.events) { emit(e, args); await deliver(e, sinks, args); } + if (!args.has('dry')) { appendEvents(r.events); writeState(r.state); } + ok = !(count > 0 && errs >= count); + } catch (e) { errs++; warn(TAG, `pass failed: ${(e as Error).message.slice(0, 300)}`); } + failures = ok ? 0 : failures + 1; + const wait = Math.min(MAX_BACKOFF_MS, every * 2 ** Math.min(failures, 6)) * (failures ? 1 : 0.9 + Math.random() * 0.2); + log(TAG, `pass ${n}: ${count} app(s), ${events} event(s), ${errs} error(s)${once || stopping ? '' : `, next in ${Math.round(wait / 60_000)}m${failures ? ` (backoff after ${failures} failed pass(es))` : ''}`}`); + if (once || stopping) break; + await new Promise(res => { const t = setTimeout(res, wait); wake = () => { clearTimeout(t); res(); }; }); + wake = null; + } + if (once && failures) process.exitCode = 1; + log(TAG, `stopped (state ${stateFile()}, events ${eventsFile()})`); +} diff --git a/docs/desktop-coverage.md b/docs/desktop-coverage.md index ae4a1af..493ec96 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). -**97 commands:** 91 covered, 0 gaps, 6 not for the desktop. +**99 commands:** 91 covered, 0 gaps, 8 not for the desktop. | command | status | where in the window | methods | owner step | |---|---|---|---|---| @@ -13,6 +13,8 @@ Owner-only parts are owner actions in the window or owner steps with their termi | `import` | covered | App folders / first-run guide: Import from fastlane | `host:project_import_fastlane` | | | `install` | covered | More tools: Install for an AI tool | `agent.install` | | | `mcp` | not-for-desktop | the stdio server for AI tools; the window itself is the GUI over the same commands | | | +| `watch` | not-for-desktop | a server loop for people and agents (PM2, cron); the window already reads the stores in the background and notifies from the tray | | | +| `events` | not-for-desktop | the log that watch writes for terminals and agents; the window shows its own what-changed feed and tray notifications | | | | `serve` | not-for-desktop | the JSON-RPC sidecar the window runs on; nothing to click | | | | `apps` | covered | Accounts (store apps per account, key check) and App folders (list, add, remove) | `host:store_apps_refresh`, `host:account_check`, `host:projects_list`, `host:project_add`, `host:project_remove` | | | `desktop` | not-for-desktop | opens or installs this window from a terminal | | | diff --git a/docs/watch.md b/docs/watch.md new file mode 100644 index 0000000..813ca7d --- /dev/null +++ b/docs/watch.md @@ -0,0 +1,67 @@ +# storebound watch + +Store events for every app on this machine, for people and agents. Read-only on the stores. + +``` +storebound watch # loop, every 15 minutes +storebound watch --once # one pass (cron, PM2 restarts) +storebound watch --dir ./my-app # one folder instead of the machine's app list +storebound watch --notify os,ntfy # choose the sinks (default: every configured one) +storebound watch --dry --json # print the events that would go out; nothing is sent or saved +storebound watch test # send one test event to every configured sink +storebound events --since 24h --kind rejected --json +``` + +Apps come from the machine's app folder list (`storebound apps folders`, the same list as the desktop app: folders +and store-only apps) or from `--dir`. Each app is read with the credentials its commands use (the project's +`.storebound/`, its profile, or the store-only app's account). + +Each pass reads release state per store (like `release status`), crash / ANR health (like `health`, at most once an +hour) and new reviews (like `reviews list`), compares with `~/.storebound/watch-state.json` and emits events. The first +pass of an app only records the baseline: no event storm. A store that cannot be read in a pass keeps its last state +and produces no event. + +## Events + +| kind | when | +| --- | --- | +| `rejected` | a version went from in review to rejected (App Store; link to App Review) | +| `approved` | a version passed review and waits for release (App Store pending developer release) | +| `live` | a version became live, or its rollout started | +| `rollout_halted` | a Play staged rollout was halted, or an App Store phased release paused | +| `crash_threshold` | Play crash or ANR rate (28 d) crossed the bad behavior threshold; a new App Store metric or diagnostic rated poor | +| `review_low` | a new review of 2 stars or less | +| `review_needs_reply` | a new review of 3-5 stars without a reply | + +Each event is one JSON object: `{ at, app, store, kind, version, from, to, detail, url }`, where `url` is the absolute +console link for the app. Events are appended to `~/.storebound/events.jsonl` (rotated at 5 MB, one `.1` file kept). +`STOREBOUND_HOME` moves `~/.storebound`. `storebound events` and the MCP tool `events_recent` read the file. + +## Sinks + +All optional. Configure with environment variables or `~/.storebound/watch.json`; values are never printed. + +| sink | variables | +| --- | --- | +| ntfy | `STOREBOUND_NTFY_TOPIC` (required), `STOREBOUND_NTFY_URL` (default `https://ntfy.sh`), `STOREBOUND_NTFY_TOKEN` (optional). Title, Tags and Click headers; Priority high for `rejected` and `rollout_halted` | +| Telegram | `STOREBOUND_TELEGRAM_TOKEN` and `STOREBOUND_TELEGRAM_CHAT` | +| webhook | `STOREBOUND_WEBHOOK_URL` (JSON POST of the event) | +| OS notification | `--notify os`, or `"notify": ["os"]` in watch.json (Windows toast, macOS osascript, Linux notify-send; skipped quietly when unavailable) | + +```json +{ "notify": ["os"], "ntfy": { "topic": "..." }, "telegram": { "token": "...", "chat": "..." }, "webhook": { "url": "..." } } +``` + +Network calls time out after 10 s and are retried once. A failing sink is logged (by name) and never stops the watch. + +## On a server with PM2 + +``` +pm2 start "npx -y storebound watch --notify ntfy" --name storebound-watch +pm2 save +``` + +Put the credentials profiles (`~/.storebound/credentials/`) and the app folder list (`storebound apps add `) +on that server, and the sink variables in the PM2 environment (`STOREBOUND_NTFY_TOPIC=...`). The loop polls every +`--every` (5m at least, 15m default, +-10 % jitter), backs off after failed passes (up to 4 h), writes one heartbeat +line per pass and stops cleanly on SIGINT / SIGTERM. With cron use `--once`. diff --git a/skills/store-health/SKILL.md b/skills/store-health/SKILL.md index 8795ad2..8f56f93 100644 --- a/skills/store-health/SKILL.md +++ b/skills/store-health/SKILL.md @@ -51,6 +51,10 @@ Fix the top signature first, then check its device / OS column: one model or one installs but is not a rollback. 4. Next week: `storebound health --weekly` shows the change in percentage points next to last week. +## Watching for changes +- `storebound watch` polls every app on this machine and records store events (rejected, approved, live, rollout_halted, crash_threshold, review_low, review_needs_reply) in `~/.storebound/events.jsonl`; sinks: OS notification, ntfy, Telegram, webhook (docs/watch.md). +- `storebound events --since 24h [--app ] [--kind rejected] --json` (MCP `events_recent`) reads that log without touching the stores: start a session there before polling the stores yourself. + ## Gotchas - `vitals not readable`: the API is not enabled, the service account lacks the permission, or the app has no release yet. `validate` only notes it; `storebound health` warns. diff --git a/tests/desktop-coverage.test.ts b/tests/desktop-coverage.test.ts index c12236b..36f198b 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']; +const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings', 'aab', 'watch', 'events']; describe('desktop coverage', () => { const { rows, extra } = analyze(); diff --git a/tests/json-stdout.test.ts b/tests/json-stdout.test.ts index 168f185..dddfd1b 100644 --- a/tests/json-stdout.test.ts +++ b/tests/json-stdout.test.ts @@ -17,7 +17,7 @@ import { installStores, uninstallStores } from './helpers/fake-stores'; const CLI = resolve(import.meta.dir, '../cli'); /** Never run here: long-lived servers, and the ones that install or open things on the machine. */ -const SKIP = new Set(['mcp', 'serve', 'install', 'desktop', 'init', 'flow', 'ws']); +const SKIP = new Set(['mcp', 'serve', 'install', 'desktop', 'init', 'flow', 'ws', 'watch']); // watch: a loop that prints JSON lines (tests/watch.test.ts) /** Commands that need a subcommand (or arguments) to reach their --json output. */ const ARGV: Record = { 'prices': ['ppp', '--offline'], 'api': ['list'], 'accounts': ['list'], 'release': ['status'], 'apps': ['folders'], diff --git a/tests/watch.test.ts b/tests/watch.test.ts new file mode 100644 index 0000000..e1f9eea --- /dev/null +++ b/tests/watch.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { TOOLS, toArgv } from '../cli/mcp-tools'; +import type { HealthSnapshot } from '../cli/lib/health'; +import { + type AppSnap, appendEvents, diffApp, durationMs, eventsFile, filterEvents, healthSnap, mergeSnap, readEvents, readState, releaseMap, type RelSnap, runPass, stateFile, + testEvent, type WatchEvent, writeState, +} from '../cli/lib/watch'; +import { type FetchLike, ntfyRequest, osCommand, post, sendEvent, sinkConfig, telegramRequest, webhookRequest } from '../cli/lib/watch-sinks'; + +const AT = '2026-10-22T10:00:00.000Z'; +const rel = (phase: string, state: string, fraction: number | null = null): RelSnap => ({ phase, state, fraction }); +const snap = (o: Partial = {}): AppSnap => ({ name: 'Demo App', ids: { appleAppId: '6400000000', playDeveloperId: '111', playAppId: '222' }, ...o }); +const kinds = (es: WatchEvent[]) => es.map(e => `${e.store}:${e.kind}`); +const tmp = () => mkdtempSync(join(tmpdir(), 'sb-watch-')); + +describe('watch diff', () => { + test('a first read of an app is a baseline: no events', () => { + expect(diffApp(undefined, snap({ releases: { apple: { '1.0': rel('rejected', 'REJECTED') } } }), AT)).toEqual([]); + }); + test('App Store: in review -> rejected, pending developer release, live', () => { + const was = snap({ releases: { apple: { '1.2.0': rel('review', 'IN_REVIEW') } } }); + const rejected = diffApp(was, snap({ releases: { apple: { '1.2.0': rel('rejected', 'REJECTED') } } }), AT); + expect(rejected).toHaveLength(1); + expect(rejected[0]).toMatchObject({ app: 'Demo App', store: 'apple', kind: 'rejected', version: '1.2.0', from: 'IN_REVIEW', to: 'REJECTED', at: AT }); + expect(rejected[0]!.url).toBe('https://appstoreconnect.apple.com/apps/6400000000/distribution/reviewsubmissions'); + const approved = diffApp(was, snap({ releases: { apple: { '1.2.0': rel('approved', 'PENDING_DEVELOPER_RELEASE') } } }), AT); + expect(kinds(approved)).toEqual(['apple:approved']); + expect(approved[0]!.url).toContain('/apps/6400000000/distribution/ios/version'); + const live = diffApp(snap({ releases: { apple: { '1.2.0': rel('approved', 'PENDING_DEVELOPER_RELEASE') } } }), snap({ releases: { apple: { '1.2.0': rel('live', 'READY_FOR_SALE') } } }), AT); + expect(kinds(live)).toEqual(['apple:live']); + }); + test('Google Play: review -> live, rollout starts, rollout halted', () => { + const play = (r: RelSnap) => snap({ releases: { play: { '1.2.0': r } } }); + expect(kinds(diffApp(play(rel('review', 'completed')), play(rel('live', 'completed')), AT))).toEqual(['play:live']); + const started = diffApp(play(rel('review', 'completed')), play(rel('rollout', 'inProgress', 0.1)), AT); + expect(kinds(started)).toEqual(['play:live']); + expect(started[0]!.detail).toContain('10 %'); + expect(started[0]!.url).toBe('https://play.google.com/console/u/0/developers/111/app/222/tracks/production'); + const halted = diffApp(play(rel('rollout', 'inProgress', 0.1)), play(rel('halted', 'halted', 0.1)), AT); + expect(kinds(halted)).toEqual(['play:rollout_halted']); + }); + test('no event for an unchanged phase, a rollout step, a rollout completing, or a store that was not read', () => { + const a = snap({ releases: { apple: { '1.0': rel('rollout', 'READY_FOR_SALE', 0.1) }, play: { '1.0': rel('rollout', 'inProgress', 0.1) } } }); + const b = snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE', 1) }, play: { '1.0': rel('rollout', 'inProgress', 0.5) } } }); + expect(diffApp(a, b, AT)).toEqual([]); + expect(diffApp(a, snap(), AT)).toEqual([]); + expect(diffApp(snap(), snap({ releases: { apple: { '1.0': rel('rejected', 'REJECTED') } } }), AT)).toEqual([]); + }); + test('a new version that appears already live is an event; one that appears in review is not', () => { + const was = snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE') } } }); + expect(kinds(diffApp(was, snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE'), '1.1': rel('review', 'WAITING_FOR_REVIEW') } } }), AT))).toEqual([]); + expect(kinds(diffApp(was, snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE'), '1.1': rel('live', 'READY_FOR_SALE') } } }), AT))).toEqual(['apple:live']); + }); + test('crash threshold: only the crossing, per metric and per store', () => { + const play = (crash: number | null, anr: number | null) => snap({ health: { play: { crash, anr } } }); + const cross = diffApp(play(0.005, 0.001), play(0.012, 0.001), AT); + expect(kinds(cross)).toEqual(['play:crash_threshold']); + expect(cross[0]).toMatchObject({ from: '0.50 %', to: '1.20 %' }); + expect(cross[0]!.url).toContain('/vitals/crashes'); + expect(diffApp(play(0.012, 0.001), play(0.015, 0.001), AT)).toEqual([]); + expect(kinds(diffApp(play(null, null), play(0.012, 0.005), AT))).toEqual(['play:crash_threshold', 'play:crash_threshold']); + expect(diffApp(play(0.012, 0), play(0.002, 0), AT)).toEqual([]); + const apple = (poor: string[]) => snap({ health: { apple: { poor } } }); + expect(kinds(diffApp(apple([]), apple(['hangRate 2 s on 1.2']), AT))).toEqual(['apple:crash_threshold']); + expect(diffApp(apple(['hangRate 2 s on 1.2']), apple(['hangRate 2 s on 1.2']), AT)).toEqual([]); + }); + test('reviews: new low review, new unanswered review, nothing for seen or answered ones', () => { + const r = (id: string, rating: number, replied = false) => ({ id, rating, replied, date: '2026-10-22T09:00:00Z', text: `text of ${id}` }); + const was = snap({ reviews: { apple: [r('a', 5)], play: [r('p', 1)] } }); + const now = snap({ reviews: { apple: [r('a', 5), r('b', 1), r('c', 4), r('d', 5, true)], play: [r('p', 1), r('q', 2)] } }); + const evs = diffApp(was, now, AT); + expect(evs.map(e => `${e.store}:${e.kind}:${e.to}`)).toEqual(['apple:review_low:1 stars', 'apple:review_needs_reply:4 stars', 'play:review_low:2 stars']); + expect(evs[0]!.detail).toContain('text of b'); + expect(diffApp(was, was, AT)).toEqual([]); + }); + test('every event kind is producible and carries the full shape', () => { + const all = [ + ...diffApp(snap({ releases: { apple: { '1': rel('review', 'IN_REVIEW') }, play: { '1': rel('rollout', 'inProgress') } } }), + snap({ releases: { apple: { '1': rel('rejected', 'REJECTED') }, play: { '1': rel('halted', 'halted') } } }), AT), + ...diffApp(snap({ releases: { apple: { '2': rel('review', 'IN_REVIEW') } } }), snap({ releases: { apple: { '2': rel('approved', 'PENDING_DEVELOPER_RELEASE') } } }), AT), + ...diffApp(snap({ releases: { apple: { '3': rel('approved', 'X') } } }), snap({ releases: { apple: { '3': rel('live', 'READY_FOR_SALE') } } }), AT), + ...diffApp(snap({ health: { play: { crash: 0, anr: 0 } } }), snap({ health: { play: { crash: 0.02, anr: 0 } } }), AT), + ...diffApp(snap({ reviews: { play: [] } }), snap({ reviews: { play: [{ id: 'x', rating: 1, replied: false, date: AT, text: 't' }, { id: 'y', rating: 5, replied: false, date: AT, text: 't' }] } }), AT), + ]; + expect([...new Set(all.map(e => e.kind))].sort()).toEqual(['approved', 'crash_threshold', 'live', 'rejected', 'review_low', 'review_needs_reply', 'rollout_halted']); + for (const e of all) { expect(Object.keys(e).sort()).toEqual(['app', 'at', 'detail', 'from', 'kind', 'store', 'to', 'url', 'version']); expect(e.url).toMatch(/^https:\/\//); } + }); +}); + +describe('watch snapshots and a pass', () => { + test('releaseMap keeps the furthest-along row per version; healthSnap reads rates and poor items', () => { + const rows = [ + { store: 'android', version: '1.0', build: '1', state: 'completed', phase: 'testing', fraction: null, track: 'beta' }, + { store: 'android', version: '1.0', build: '2', state: 'inProgress', phase: 'rollout', fraction: 0.2, track: 'production' }, + { store: 'ios', version: '1.0', build: '2', state: 'IN_REVIEW', phase: 'review', fraction: null, track: null }, + ] as Parameters[0]; + expect(releaseMap(rows, 'android')).toEqual({ '1.0': { phase: 'rollout', state: 'inProgress', fraction: 0.2 } }); + expect(Object.keys(releaseMap(rows, 'ios'))).toEqual(['1.0']); + const h: HealthSnapshot = { date: '2026-10-22', + play: { window: { start: 'a', end: 'b', days: 7 }, crash: [{ date: 'd', dims: {}, values: { userPerceivedCrashRate28dUserWeighted: 0.012 } }], anr: [], issues: [] }, + apple: { metrics: [{ category: 'c', metric: 'hangRate', unit: 's', version: '1.0', value: 2, goal: 'poor' }], signatures: [], betaCrashes: [] } }; + expect(healthSnap(h)).toEqual({ play: { crash: 0.012, anr: null }, apple: { poor: ['hangRate 2 s on 1.0'] } }); + expect(healthSnap({ date: 'd', play: { error: 'x' } })).toEqual({}); + }); + test('mergeSnap keeps a part this read did not get, and the seen reviews', () => { + const old = snap({ releases: { apple: { '1': rel('live', 'X') } }, reviews: { play: [{ id: 'a', rating: 5, replied: true, date: '2026-10-01', text: '' }] } }); + const m = mergeSnap(old, snap({ releases: { play: { '1': rel('live', 'X') } }, reviews: { play: [{ id: 'b', rating: 5, replied: true, date: '2026-10-02', text: '' }] } })); + expect(Object.keys(m.releases!).sort()).toEqual(['apple', 'play']); + expect(m.reviews!.play!.map(r => r.id)).toEqual(['b', 'a']); + }); + test('runPass: baseline pass emits nothing, the second pass emits, an unreadable app keeps its state', async () => { + const targets = [{ key: 'dir:/a', name: 'A' }, { key: 'dir:/b', name: 'B' }]; + let phase = 'review', failB = false; + const read = async (t: { key: string; name: string }) => { + if (t.name === 'B' && failB) return { snap: null, errors: ['boom'] }; + return { snap: snap({ name: t.name, releases: { apple: { '1': rel(phase, phase === 'live' ? 'READY_FOR_SALE' : 'IN_REVIEW') } } }), errors: [] }; + }; + const one = await runPass({ targets, prev: readState(join(tmp(), 'none.json')), at: AT, read }); + expect(one.events).toEqual([]); + expect(one.baseline).toEqual(['A', 'B']); + phase = 'live'; failB = true; + const two = await runPass({ targets, prev: one.state, at: AT, read }); + expect(two.events.map(e => e.app)).toEqual(['A']); + expect(two.errors).toEqual([{ app: 'B', error: 'boom' }]); + expect(two.state.apps['dir:/b']!.releases!.apple!['1']!.phase).toBe('review'); + }); +}); + +describe('events.jsonl and state files', () => { + const home = () => ({ STOREBOUND_HOME: tmp() }); + const ev = (o: Partial = {}): WatchEvent => ({ ...testEvent(AT), ...o }); + test('paths follow STOREBOUND_HOME', () => { + const env = home(); + expect(eventsFile(env)).toBe(join(env.STOREBOUND_HOME, 'events.jsonl')); + expect(stateFile(env)).toBe(join(env.STOREBOUND_HOME, 'watch-state.json')); + }); + test('append writes one JSON per line and reads back; a torn line is skipped', () => { + const f = eventsFile(home()); + appendEvents([ev({ app: 'A' }), ev({ app: 'B' })], f); + appendEvents([ev({ app: 'C' })], f); + writeFileSync(f, readFileSync(f, 'utf8') + '{"at":"2026\n'); + expect(readEvents(f).map(e => e.app)).toEqual(['A', 'B', 'C']); + }); + test('rotation at the size limit keeps one .1 file and reads both', () => { + const f = eventsFile(home()); + appendEvents([ev({ app: 'old' })], f, 50); + appendEvents([ev({ app: 'new' })], f, 50); + expect(existsSync(`${f}.1`)).toBe(true); + expect(readFileSync(f, 'utf8').trim().split('\n')).toHaveLength(1); + appendEvents([ev({ app: 'newer' })], f, 50); + appendEvents([ev({ app: 'newest' })], f, 50); + expect(readEvents(f).map(e => e.app)).toEqual(['newer', 'newest']); + expect(readFileSync(`${f}.1`, 'utf8')).toContain('newer'); + }); + test('state round trip; an unreadable file is a fresh baseline', () => { + const f = stateFile(home()); + expect(readState(f).apps).toEqual({}); + writeState({ version: 1, at: AT, apps: { k: snap() } }, f); + expect(readState(f).apps.k!.name).toBe('Demo App'); + writeFileSync(f, '{nope'); + expect(readState(f).apps).toEqual({}); + }); + test('events filters: since, app, kind', () => { + const now = Date.parse(AT); + const evs = [ev({ at: '2026-10-22T09:00:00Z', app: 'Alpha', kind: 'live' }), ev({ at: '2026-10-20T09:00:00Z', app: 'Beta', kind: 'rejected' }), ev({ at: '2026-10-22T08:00:00Z', app: 'Beta', kind: 'rejected' })]; + expect(filterEvents(evs, { sinceMs: durationMs('24h'), now }).map(e => e.app)).toEqual(['Alpha', 'Beta']); + expect(filterEvents(evs, { app: 'beta' })).toHaveLength(2); + expect(filterEvents(evs, { kind: ['live'] })).toHaveLength(1); + expect(filterEvents(evs, { app: 'beta', kind: ['rejected'], sinceMs: durationMs('2d'), now })).toHaveLength(1); + expect(durationMs('15', 'm')).toBe(15 * 60_000); + expect(() => durationMs('soon')).toThrow(); + }); +}); + +describe('watch sinks', () => { + const e = (o: Partial = {}): WatchEvent => ({ ...testEvent(AT), app: 'Demo App', version: '1.2.0', kind: 'rejected', detail: 'rejected', url: 'https://appstoreconnect.apple.com/apps/1', ...o }); + test('sinkConfig reads env, a file, and --notify limits', () => { + expect(sinkConfig({}, null)).toEqual({}); + const c = sinkConfig({ STOREBOUND_NTFY_TOPIC: 't', STOREBOUND_NTFY_URL: 'https://ntfy.example.com/', STOREBOUND_TELEGRAM_TOKEN: 'tok', STOREBOUND_TELEGRAM_CHAT: '42', STOREBOUND_WEBHOOK_URL: 'https://hook.example.com/x' }, null); + expect(c).toEqual({ ntfy: { url: 'https://ntfy.example.com', topic: 't' }, telegram: { token: 'tok', chat: '42' }, webhook: { url: 'https://hook.example.com/x' } }); + expect(sinkConfig({ STOREBOUND_NTFY_TOPIC: 't' }, null).ntfy!.url).toBe('https://ntfy.sh'); + expect(sinkConfig({ STOREBOUND_TELEGRAM_TOKEN: 'tok' }, null).telegram).toBeUndefined(); + expect(sinkConfig({ STOREBOUND_NTFY_TOPIC: 't' }, null, ['os'])).toEqual({ os: true }); + const file = join(tmp(), 'watch.json'); + writeFileSync(file, JSON.stringify({ notify: ['os'], webhook: { url: 'https://h.example.com' } })); + expect(sinkConfig({}, file)).toEqual({ os: true, webhook: { url: 'https://h.example.com' } }); + }); + test('ntfy: headers (Title, Tags, Click, Priority) and body', () => { + const r = ntfyRequest(e(), { url: 'https://ntfy.sh', topic: 'my topic' }); + expect(r.url).toBe('https://ntfy.sh/my%20topic'); + expect(r.body).toBe('rejected'); + expect(r.headers).toEqual({ Title: 'Demo App 1.2.0: Rejected', Tags: 'x,apple', Click: 'https://appstoreconnect.apple.com/apps/1', Priority: 'high' }); + expect(ntfyRequest(e({ kind: 'live' }), { url: 'https://ntfy.sh', topic: 't', token: 'k' }).headers).toMatchObject({ Priority: 'default', Tags: 'rocket,apple', Authorization: 'Bearer k' }); + expect(ntfyRequest(e({ kind: 'rollout_halted' }), { url: 'https://ntfy.sh', topic: 't' }).headers.Priority).toBe('high'); + expect(ntfyRequest(e({ app: 'Café\nApp' }), { url: 'https://ntfy.sh', topic: 't' }).headers.Title).toBe('Caf? App 1.2.0: Rejected'); + }); + test('telegram and webhook bodies', () => { + const t = telegramRequest(e(), { token: 'TOKEN', chat: '42' }); + expect(t.url).toBe('https://api.telegram.org/botTOKEN/sendMessage'); + expect(JSON.parse(t.body)).toEqual({ chat_id: '42', text: 'Demo App 1.2.0: Rejected\nrejected\nhttps://appstoreconnect.apple.com/apps/1', disable_web_page_preview: true }); + const w = webhookRequest(e(), { url: 'https://hook.example.com' }); + expect(w.headers['Content-Type']).toBe('application/json'); + expect(JSON.parse(w.body)).toEqual(e()); + }); + test('os command per platform keeps the text out of the script', () => { + expect(osCommand('win32', 'T', 'M')!.cmd).toBe('powershell'); + expect(osCommand('win32', 'T"; evil', 'M')!.args.join(' ')).not.toContain('evil'); + expect(osCommand('darwin', 'T', 'M')).toMatchObject({ cmd: 'osascript', env: { SB_TITLE: 'T', SB_MSG: 'M' } }); + expect(osCommand('linux', 'T', 'M')).toMatchObject({ cmd: 'notify-send', args: ['T', 'M'] }); + expect(osCommand('freebsd', 'T', 'M')).toBeNull(); + }); + test('post: one retry on 5xx / network error, none on 4xx', async () => { + const calls: string[] = []; + const seq = (...rs: (number | 'throw')[]): FetchLike => async url => { calls.push(url); const r = rs.shift()!; if (r === 'throw') throw new Error('down'); return { ok: r < 300, status: r }; }; + const req = { url: 'https://x.example.com', headers: {}, body: '' }; + expect(await post(seq(503, 200), req, { retryMs: 1 })).toBeNull(); + expect(calls).toHaveLength(2); + calls.length = 0; + expect(await post(seq('throw', 'throw'), req, { retryMs: 1 })).toBe('network error'); + expect(calls).toHaveLength(2); + calls.length = 0; + expect(await post(seq(400, 200), req, { retryMs: 1 })).toBe('HTTP 400'); + expect(calls).toHaveLength(1); + }); + test('sendEvent: every configured sink gets it with a fake fetch; a failing sink does not stop the others', async () => { + const seen: string[] = []; + const fake: FetchLike = async url => { seen.push(url); return url.includes('telegram') ? { ok: false, status: 400 } : { ok: true, status: 200 }; }; + let osCalls = 0; + const rep = await sendEvent(e(), { os: true, ntfy: { url: 'https://ntfy.sh', topic: 't' }, telegram: { token: 'T', chat: '1' }, webhook: { url: 'https://hook.example.com' } }, + { fetch: fake, os: () => { osCalls++; return true; }, platform: 'linux', retryMs: 1 }); + expect(rep).toEqual([{ sink: 'os', ok: true }, { sink: 'ntfy', ok: true }, { sink: 'telegram', ok: false, error: 'HTTP 400' }, { sink: 'webhook', ok: true }]); + expect(osCalls).toBe(1); + expect(seen).toEqual(['https://ntfy.sh/t', 'https://api.telegram.org/botT/sendMessage', 'https://hook.example.com']); + // an OS notifier that is not installed is skipped quietly + expect(await sendEvent(e(), { os: true }, { os: () => false, platform: 'linux' })).toEqual([]); + }); +}); + +describe('watch and events commands', () => { + test('mcp: events_recent is a read tool that maps to events --json', () => { + const t = TOOLS.find(x => x.name === 'events_recent')!; + expect(t).toMatchObject({ write: false, network: false, command: ['events'] }); + expect(toArgv(t, { since: '24h', app: 'Demo', kind: 'rejected' }).argv).toEqual(['events', '--since', '24h', '--app', 'Demo', '--kind', 'rejected']); + }); +}); From 8b22e548be10bc68dea80380daafb127d61323b3 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 21:42:53 +0700 Subject: [PATCH 036/255] storebound 0.8.31: version in package.json and every copy, CHANGELOG 0.8.31 section --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 281c98f..1c1dfb0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.30", + "version": "0.8.31", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b20e1f5..1ec203f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.30", + "version": "0.8.31", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 659bc21..8bbad0a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.30", + "version": "0.8.31", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/CHANGELOG.md b/CHANGELOG.md index 844217e..1ad89fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.31 · 2026-10-22 + - **`storebound watch`**: store events for every app on the machine (rejected, approved, live, rollout halted, crash threshold, low or unanswered reviews), sent to ntfy (phone), Telegram, a webhook or an OS notification; runs as a loop (`--every 15m`) or `--once`; a PM2 recipe for a server is in docs/watch.md. `storebound events` and the MCP tool diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 36897a2..5ad15a1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.30", + "version": "0.8.31", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 2a7d3ac..6f98514 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4096,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.30" +version = "0.8.31" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7ca8059..20a0757 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.30" +version = "0.8.31" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index cee3801..efaf0ca 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.30", + "version": "0.8.31", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", diff --git a/gemini-extension.json b/gemini-extension.json index e1192a7..5444ee7 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.30", + "version": "0.8.31", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index 90e2588..65a6780 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.30", + "version": "0.8.31", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", From bd316723f50c91775c2ac820bba0164e50d4ceba Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 23:12:03 +0800 Subject: [PATCH 037/255] Desktop v2 design system (design.css tokens for both themes, ui/ds.tsx: PageHeader, Hero, Section, StatTile, Pipeline, StatusPill, EmptyState, Toolbar, Tabs, Dialog, tint and screenshot hooks), app shell: sidebar status dots, frosted top bar, app page Hero with screenshots and the two-store pipeline, pill tabs with blocker counts --- apps/desktop/src/App.tsx | 5 +- apps/desktop/src/design.css | 201 ++++++++++++++++ apps/desktop/src/main.tsx | 1 + apps/desktop/src/screens/AppView.tsx | 69 +++--- apps/desktop/src/screens/Portfolio.tsx | 136 +---------- apps/desktop/src/ui/ds.tsx | 319 +++++++++++++++++++++++++ 6 files changed, 559 insertions(+), 172 deletions(-) create mode 100644 apps/desktop/src/design.css create mode 100644 apps/desktop/src/ui/ds.tsx diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a72750e..6538f00 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -23,6 +23,8 @@ import { type Command, Palette } from './ui/Palette'; import { ExportMenu, useExportItems, useRunExport } from './ui/Exports'; import { ErrorBoundary } from './ui/ErrorBoundary'; import { Toasts } from './ui/Toasts'; +import { worstTone } from './ui/ds'; +import { attention } 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' }; @@ -146,12 +148,13 @@ export function App({ onTheme }: { onTheme: () => void }) { 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 (
-
-
- {iosNow && <>{iosNow.version}} - {androidNow && <>{androidNow.version}} -
-
-
-
- {ios && } - {android && } -
-
+
+ + {e?.summary?.profile && app.project && {t(`profile.${e.summary.profile}` as 'profile.app')}} + {!app.project && } + + } + ids={<> + {iosNow && <>{iosNow.version}} + {androidNow && <>{androidNow.version}} + } + links={(ios || android) && <> + {ios && } + {android && } + } + all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null} + 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 ? : <> -
- {tabs.map((x, i) => ( - - ))} +
+ { + 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 }; + })} />
diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index b6e1a9e..41cc6f3 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -4,18 +4,20 @@ // version, crash rate, catalog parity, config check), grouped by studio; then what changed and what to fix first. import { plainError } from '../../../../cli/lib/google-errors'; import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError'; -import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from 'react'; -import { api, type AppStatus, newJob, reads, type RenderImage, type StoreRelease, type ToolResult } from '../api'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; import { ago, pct, share } from '../lib/format'; import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity'; -import { type Attention, attention, healthSeries, type HealthSnapshot, hueOf, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; +import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; import { type AppTab, useNav } from '../nav'; import type { IconName } from '../ui/icons'; import { useData } from '../store'; import { exportName, portfolioCsv } from '../lib/exports'; import { useExports } from '../ui/Exports'; import { Icon, StoreGlyph } from '../ui/icons'; +import { nextStep, Pipeline, tintStyle, useShots, useTint } from '../ui/ds'; +export { nextStep } from '../ui/ds'; import { AppIcon, Card, Chip, Empty, Kpi, Notice, PhaseChip, Seg, Skeleton, Sparkline, StudioMark } from '../ui/kit'; /** Tag of one app's offline config check. */ @@ -111,126 +113,6 @@ function releasesOf(l: AppStatus | undefined): StoreRelease[] { return l.release.all ?? [l.release.current?.ios, l.release.current?.android, l.release.next?.ios, l.release.next?.android].filter((x): x is StoreRelease => !!x); } -// Up to three phone screenshots of the app folder's rendered store art (`shots gallery`, then one `project.render` -// data: URL each), loaded once per window and one app at a time. No renders: an empty list (the card shows the icon). -const shotCache = new Map>(); -let shotChain: Promise = Promise.resolve(); -function loadShots(projectId: string): Promise { - const hit = shotCache.get(projectId); - if (hit) return hit; - const job = shotChain.then(async () => { - const r = await reads.renders(projectId).catch(() => null); - const res = r?.data; - if (!res) return []; - const v = res.variants.find(x => !x.id) ?? res.variants[0]; - const lang = v?.langs.find(l => l.lang === 'en' || l.lang.startsWith('en-')) ?? v?.langs[0]; - const rows = (lang?.rows ?? []).filter(row => !/feature|ipad|preview|tv|watch|mac|tablet/i.test(row.size) && row.tiles.some(x => x.kind === 'image')); - rows.sort((a, b) => (a.store === 'apple' ? 0 : 1) - (b.store === 'apple' ? 0 : 1)); - const files = (rows[0]?.tiles ?? []).filter(x => x.kind === 'image' && x.h >= x.w).slice(0, 3).map(x => x.src); - const out: string[] = []; - for (const file of files) { - const img = await api.call('project.render', projectId, { args: { file } }).catch(() => null); - if (img?.data?.dataUrl) out.push(img.data.dataUrl); - } - return out; - }).catch(() => [] as string[]); - shotChain = job; - shotCache.set(projectId, job); - return job; -} - -function useShots(projectId: string | null): string[] { - const [shots, setShots] = useState([]); - useEffect(() => { - if (!projectId) return; - let alive = true; - loadShots(projectId).then(s => { if (alive) setShots(s); }, () => {}); - return () => { alive = false; }; - }, [projectId]); - return shots; -} - -// The hue of an app icon (saturation-weighted mean colour), for the card's soft gradient; the name's hue without one. -const tintCache = new Map(); -function useTint(src: string | null, name: string): number { - const fallback = hueOf(name); - const [hue, setHue] = useState(() => (src ? tintCache.get(src) : undefined) ?? fallback); - useEffect(() => { - if (!src) { setHue(fallback); return; } - const cached = tintCache.get(src); - if (cached !== undefined) { setHue(cached); return; } - let alive = true; - const img = new Image(); - img.onload = () => { - try { - const c = document.createElement('canvas'); - c.width = c.height = 16; - const g = c.getContext('2d'); - if (!g) return; - g.drawImage(img, 0, 0, 16, 16); - const px = g.getImageData(0, 0, 16, 16).data; - let r = 0, gr = 0, b = 0, w = 0; - for (let i = 0; i < px.length; i += 4) { - const R = px[i]!, G = px[i + 1]!, B = px[i + 2]!, A = px[i + 3]! / 255; - const s = (Math.max(R, G, B) - Math.min(R, G, B)) / 255 * A; - r += R * s; gr += G * s; b += B * s; w += s; - } - if (w < 0.5) return; - r /= w; gr /= w; b /= w; - const mx = Math.max(r, gr, b), mn = Math.min(r, gr, b), d = mx - mn; - if (d < 1) return; - let h = mx === r ? ((gr - b) / d) % 6 : mx === gr ? (b - r) / d + 2 : (r - gr) / d + 4; - h = Math.round(h * 60 + 360) % 360; - tintCache.set(src, h); - if (alive) setHue(h); - } catch { /* keep the name's hue */ } - }; - img.src = src; - return () => { alive = false; }; - }, [src, fallback]); - return hue; -} - -interface Cta { text: string; tab: AppTab; icon: IconName; primary: boolean } - -/** The one next step of an app, from its state: blockers first, then the release that waits for a move. */ -export function nextStep(o: { project: boolean; att: Attention[]; all: StoreRelease[]; next: string | null }): Cta { - const bad = o.att.filter(a => a.tone === 'bad'); - if (bad.length) return { text: t('pf.ctaFix', { count: bad.length }), tab: bad[0]!.tab ?? 'checks', icon: 'alertCircle', primary: true }; - const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); - if (rolling) return { text: t('pf.ctaRollout', { share: share(rolling.fraction ?? 0) }), tab: 'releases', icon: 'rocket', primary: true }; - const approved = o.all.find(r => r.phase === 'approved'); - if (approved) return { text: t('pf.ctaRelease', { version: approved.version ?? '' }), tab: 'releases', icon: 'rocket', primary: true }; - const ready = o.all.find(r => r.phase === 'draft' || r.phase === 'testing'); - if (ready) return { text: t('pf.ctaSubmit', { version: ready.version ?? '' }), tab: 'releases', icon: 'send', primary: true }; - const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent'); - if (review) return { text: t('pf.ctaReview', { version: review.version ?? '' }), tab: 'releases', icon: 'clock', primary: false }; - if (o.att.length) return { text: t('pf.ctaWarnings', { count: o.att.length }), tab: o.att[0]!.tab ?? 'checks', icon: 'alert', primary: false }; - if (o.project && !o.next) return { text: t('pf.ctaUpload'), tab: 'releases', icon: 'upload', primary: false }; - return { text: t('pf.ctaOpen'), tab: 'releases', icon: 'arrowRight', primary: false }; -} - -function Track({ store, all, on }: { store: 'ios' | 'android'; all: StoreRelease[]; on: boolean }) { - const { now, next } = storeNow(all, store); - const r = next ?? now; - const lane = laneOf(store, on ? r : null); - const name = store === 'ios' ? 'App Store' : 'Google Play'; - const state = !on ? t('app.notOnStore') : !r ? t('portfolio.noVersion') : `${r.version ?? ''} · ${t(`phase.${r.phase}` as Key)}${lane.share !== null && lane.share < 1 ? ` ${share(lane.share)}` : ''}`; - return ( -
- {store === 'ios' ? 'App Store' : 'Play'} - - {on && r ? `${r.version ?? ''}${lane.share !== null && lane.share < 1 ? ` · ${share(lane.share)}` : ''}` : '–'} -
- ); -} - function CardHealth({ c, android }: { c: { h: HealthSnapshot; at: string } | null | undefined; android: boolean }) { const play = c?.h.play; if (!android || !c || !play || isError(play)) { @@ -260,7 +142,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; const cta = nextStep({ project: !!app.project, att, all, next }); const name = e?.app?.name && app.project ? e.app.name : app.name; const go = (tab: AppTab) => nav.go({ screen: 'app', appId: app.key, tab }); - const style = { '--tint-h': String(hue) } as CSSProperties; + const style = tintStyle(hue); return (
{studio} -
- - - -
+
diff --git a/apps/desktop/src/ui/ds.tsx b/apps/desktop/src/ui/ds.tsx new file mode 100644 index 0000000..563e500 --- /dev/null +++ b/apps/desktop/src/ui/ds.tsx @@ -0,0 +1,319 @@ +// Design system v2 (styles in src/design.css, guide in docs/drafts/desktop-v2/SYSTEM.md): the store-like language of +// the portfolio home as reusable parts. PageHeader, Hero (app hero with screenshots, two-store pipeline, next step), +// Section, StatTile, Pipeline, StatusPill, EmptyState, Tabs, Toolbar, Dialog, plus the per-app tint (useTint / tintStyle) +// and the rendered-screenshot loader (useShots) the cards and the hero share. +import { type CSSProperties, type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react'; +import { api, reads, type RenderImage, type StoreRelease, type ToolResult } from '../api'; +import { type Key, t } from '../i18n'; +import { share } from '../lib/format'; +import { type Attention, hueOf, laneOf, STAGES, storeNow, type Tone } from '../lib/model'; +import type { AppTab } from '../nav'; +import { Icon, type IconName, StoreGlyph } from './icons'; +import { AppIcon } from './kit'; + +// ---- per-app tint ---- + +const tintCache = new Map(); +/** The hue of an app icon (saturation-weighted mean colour); the name's hue without an icon or while it loads. */ +export function useTint(src: string | null | undefined, name: string): number { + const fallback = hueOf(name); + const [hue, setHue] = useState(() => (src ? tintCache.get(src) : undefined) ?? fallback); + useEffect(() => { + if (!src) { setHue(fallback); return; } + const cached = tintCache.get(src); + if (cached !== undefined) { setHue(cached); return; } + let alive = true; + const img = new Image(); + img.onload = () => { + try { + const c = document.createElement('canvas'); + c.width = c.height = 16; + const g = c.getContext('2d'); + if (!g) return; + g.drawImage(img, 0, 0, 16, 16); + const px = g.getImageData(0, 0, 16, 16).data; + let r = 0, gr = 0, b = 0, w = 0; + for (let i = 0; i < px.length; i += 4) { + const R = px[i]!, G = px[i + 1]!, B = px[i + 2]!, A = px[i + 3]! / 255; + const s = (Math.max(R, G, B) - Math.min(R, G, B)) / 255 * A; + r += R * s; gr += G * s; b += B * s; w += s; + } + if (w < 0.5) return; + r /= w; gr /= w; b /= w; + const mx = Math.max(r, gr, b), mn = Math.min(r, gr, b), d = mx - mn; + if (d < 1) return; + let h = mx === r ? ((gr - b) / d) % 6 : mx === gr ? (b - r) / d + 2 : (r - gr) / d + 4; + h = Math.round(h * 60 + 360) % 360; + tintCache.set(src, h); + if (alive) setHue(h); + } catch { /* keep the name's hue */ } + }; + img.src = src; + return () => { alive = false; }; + }, [src, fallback]); + return hue; +} + +/** Style that tints a subtree: every `.ds-tinted` / `--tint` rule below it uses this hue. */ +export const tintStyle = (hue: number): CSSProperties => ({ '--tint-h': String(hue) } as CSSProperties); + +// ---- rendered store screenshots ---- + +// Up to three phone screenshots of the app folder's rendered store art (`shots gallery`, then one `project.render` +// data: URL each), loaded once per window and one app at a time. No renders: an empty list. +const shotCache = new Map>(); +let shotChain: Promise = Promise.resolve(); +function loadShots(projectId: string, max: number): Promise { + const k = `${projectId}#${max}`; + const hit = shotCache.get(k); + if (hit) return hit; + const job = shotChain.then(async () => { + const r = await reads.renders(projectId).catch(() => null); + const res = r?.data; + if (!res) return []; + const v = res.variants.find(x => !x.id) ?? res.variants[0]; + const lang = v?.langs.find(l => l.lang === 'en' || l.lang.startsWith('en-')) ?? v?.langs[0]; + const rows = (lang?.rows ?? []).filter(row => !/feature|ipad|preview|tv|watch|mac|tablet/i.test(row.size) && row.tiles.some(x => x.kind === 'image')); + rows.sort((a, b) => (a.store === 'apple' ? 0 : 1) - (b.store === 'apple' ? 0 : 1)); + const files = (rows[0]?.tiles ?? []).filter(x => x.kind === 'image' && x.h >= x.w).slice(0, max).map(x => x.src); + const out: string[] = []; + for (const file of files) { + const img = await api.call('project.render', projectId, { args: { file } }).catch(() => null); + if (img?.data?.dataUrl) out.push(img.data.dataUrl); + } + return out; + }).catch(() => [] as string[]); + shotChain = job; + shotCache.set(k, job); + return job; +} + +/** Rendered phone screenshots of an app folder (none for a store-only app or before the first render). */ +export function useShots(projectId: string | null | undefined, max = 3): string[] { + const [shots, setShots] = useState([]); + useEffect(() => { + if (!projectId) { setShots([]); return; } + let alive = true; + loadShots(projectId, max).then(s => { if (alive) setShots(s); }, () => {}); + return () => { alive = false; }; + }, [projectId, max]); + return shots; +} + +// ---- next step ---- + +export interface Cta { text: string; tab: AppTab; icon: IconName; primary: boolean } + +/** The one next step of an app, from its state: blockers first, then the release that waits for a move. */ +export function nextStep(o: { project: boolean; att: Attention[]; all: StoreRelease[]; next: string | null }): Cta { + const bad = o.att.filter(a => a.tone === 'bad'); + if (bad.length) return { text: t('pf.ctaFix', { count: bad.length }), tab: bad[0]!.tab ?? 'checks', icon: 'alertCircle', primary: true }; + const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { text: t('pf.ctaRollout', { share: share(rolling.fraction ?? 0) }), tab: 'releases', icon: 'rocket', primary: true }; + const approved = o.all.find(r => r.phase === 'approved'); + if (approved) return { text: t('pf.ctaRelease', { version: approved.version ?? '' }), tab: 'releases', icon: 'rocket', primary: true }; + const ready = o.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (ready) return { text: t('pf.ctaSubmit', { version: ready.version ?? '' }), tab: 'releases', icon: 'send', primary: true }; + const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { text: t('pf.ctaReview', { version: review.version ?? '' }), tab: 'releases', icon: 'clock', primary: false }; + if (o.att.length) return { text: t('pf.ctaWarnings', { count: o.att.length }), tab: o.att[0]!.tab ?? 'checks', icon: 'alert', primary: false }; + if (o.project && !o.next) return { text: t('pf.ctaUpload'), tab: 'releases', icon: 'upload', primary: false }; + return { text: t('pf.ctaOpen'), tab: 'releases', icon: 'arrowRight', primary: false }; +} + +/** Worst tone of an app's attention list (the sidebar dot); null when nothing needs a look. */ +export function worstTone(att: Attention[]): Tone | null { + if (att.some(a => a.tone === 'bad')) return 'bad'; + if (att.some(a => a.tone === 'warn')) return 'warn'; + return att.length ? 'info' : null; +} + +// ---- Pipeline (Build -> Testing -> Review -> Live -> Rollout, one lane per store) ---- + +export function PipelineTrack({ store, all, on }: { store: 'ios' | 'android'; all: StoreRelease[]; on: boolean }) { + const { now, next } = storeNow(all, store); + const r = next ?? now; + const lane = laneOf(store, on ? r : null); + const name = store === 'ios' ? 'App Store' : 'Google Play'; + const state = !on ? t('app.notOnStore') : !r ? t('portfolio.noVersion') : `${r.version ?? ''} · ${t(`phase.${r.phase}` as Key)}${lane.share !== null && lane.share < 1 ? ` ${share(lane.share)}` : ''}`; + return ( +
+ {store === 'ios' ? 'App Store' : 'Play'} + + {on && r ? `${r.version ?? ''}${lane.share !== null && lane.share < 1 ? ` · ${share(lane.share)}` : ''}` : '–'} +
+ ); +} + +/** Both store lanes under one stage header. Needs a tint (`--tint-h`) on an ancestor. */ +export function Pipeline({ all, ios, android, size = 'md' }: { all: StoreRelease[]; ios: boolean; android: boolean; size?: 'md' | 'lg' }) { + return ( +
+ + + +
+ ); +} + +// ---- page parts ---- + +/** Page title row: optional eyebrow, a big title, one line under it, actions on the right. */ +export function PageHeader({ title, subtitle, eyebrow, actions, icon }: { title: ReactNode; subtitle?: ReactNode; eyebrow?: ReactNode; actions?: ReactNode; icon?: IconName }) { + return ( +
+
+ {eyebrow &&

{eyebrow}

} +

{icon && }{title}

+ {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ ); +} + +/** App hero: screenshots strip, big icon, name, ids, store links, the two-store pipeline and the next step. */ +export function Hero({ name, icon, projectId, ids, badges, links, all, ios, android, cta, onCta, next, children }: { + name: string; icon: string | null; projectId: string | null; ids?: ReactNode; badges?: ReactNode; links?: ReactNode; + all: StoreRelease[]; ios: boolean; android: boolean; cta?: Cta | null; onCta?: (tab: AppTab) => void; next?: string | null; children?: ReactNode; +}) { + const hue = useTint(icon, name); + const shots = useShots(projectId, 4); + return ( +
+
+
+ +
+

{name}

{badges}
+ {ids &&
{ids}
} + {links &&
{links}
} +
+
+
+ +
+
{t('pf.next')}{next ?? '–'}
+ {cta && } +
+
+ {children} +
+ +
+ ); +} + +/** Section v2: a titled block. `plain` drops the surface (content sits on the page). */ +export function Section({ title, icon, sub, actions, children, plain, pad = true, id, className = '' }: { title?: ReactNode; icon?: IconName; sub?: ReactNode; actions?: ReactNode; children: ReactNode; plain?: boolean; pad?: boolean; id?: string; className?: string }) { + const hid = useId(); + return ( +
+ {title !== undefined && ( +
+

{icon && }{title}

{sub &&

{sub}

}
+ {actions &&
{actions}
} +
+ )} + {pad ?
{children}
: children} +
+ ); +} + +/** Big number tile: value, label, optional delta (+ is good unless `invert`) and a sub line. */ +export function StatTile({ label, value, delta, invert, sub, icon, tone, onClick }: { label: string; value: ReactNode; delta?: number | null; invert?: boolean; sub?: ReactNode; icon?: IconName; tone?: Tone; onClick?: () => void }) { + const good = delta == null || delta === 0 ? null : (delta > 0) !== !!invert; + const body = ( + <> + {icon && }{label} + {value} + {(delta != null || sub) && + {delta != null && {delta > 0 ? '+' : ''}{delta}} + {sub && {sub}} + } + + ); + const cls = `ds-stat${tone ? ` tone-${tone}` : ''}`; + return onClick ? :
{body}
; +} + +/** Status pill: a tone dot and text; `solid` for the one state that matters on a row. */ +export function StatusPill({ tone = 'neutral', children, icon, solid, title }: { tone?: Tone; children: ReactNode; icon?: IconName; solid?: boolean; title?: string }) { + return {icon ? : }{children}; +} + +/** Empty state without illustration: icon, one line, an optional action. */ +export function EmptyState({ icon = 'sparkle', children, action, tone }: { icon?: IconName; children: ReactNode; action?: ReactNode; tone?: Tone }) { + return ( +
+ +

{children}

+ {action} +
+ ); +} + +/** Toolbar row: search, filters, then right-aligned controls after a ``. */ +export function Toolbar({ label, children }: { label: string; children: ReactNode }) { + return
{children}
; +} + +export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string } + +/** Pill tabs with icons and counts; arrows / Home / End move between them (roving tabindex). */ +export function Tabs({ items, value, onChange, label, panelId = 'tabpanel' }: { items: TabItem[]; value: T; onChange: (v: T) => void; label: string; panelId?: string }) { + const refs = useRef<(HTMLButtonElement | null)[]>([]); + const onKey = (ev: KeyboardEvent) => { + const i = items.findIndex(x => x.id === value); + const j = ev.key === 'ArrowRight' ? i + 1 : ev.key === 'ArrowLeft' ? i - 1 : ev.key === 'Home' ? 0 : ev.key === 'End' ? items.length - 1 : null; + if (j === null) return; + ev.preventDefault(); + const n = (j + items.length) % items.length; + onChange(items[n]!.id); + refs.current[n]?.focus(); + }; + return ( +
+ {items.map((x, i) => ( + + ))} +
+ ); +} + +/** Dialog v2: modal panel with a title, body and footer actions; Escape and the scrim close it, focus moves in. */ +export function Dialog({ title, icon, children, actions, onClose, wide }: { title: ReactNode; icon?: IconName; children: ReactNode; actions?: ReactNode; onClose: () => void; wide?: boolean }) { + const root = useRef(null); + const hid = useId(); + useEffect(() => { + const prev = document.activeElement as HTMLElement | null; + root.current?.focus(); + return () => { prev?.focus?.(); }; + }, []); + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
{ if (e.key === 'Escape') { e.stopPropagation(); onClose(); } }}> +
+

{icon && }{title}

+ +
+
{children}
+ {actions &&
{actions}
} +
+
+ ); +} From d3d31d6c1259365a110efdb9294cf69cc197511a Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 23:18:30 +0800 Subject: [PATCH 038/255] Node: JSON configs are read as data, not imported (store-only apps failed under npx: needs an import attribute); test, pitfall 105 --- CHANGELOG.md | 2 ++ cli/lib/rt.ts | 3 +++ skills/store-pitfalls/SKILL.md | 7 +++++++ tests/config-json-node.test.ts | 12 ++++++++++++ 4 files changed, 24 insertions(+) create mode 100644 tests/config-json-node.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 844217e..630986b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Node: a `storebound.config.json` (and every store-only app) loads again; it needed an import attribute. Pitfall 105. + - **`storebound watch`**: store events for every app on the machine (rejected, approved, live, rollout halted, crash threshold, low or unanswered reviews), sent to ntfy (phone), Telegram, a webhook or an OS notification; runs as a loop (`--every 15m`) or `--once`; a PM2 recipe for a server is in docs/watch.md. `storebound events` and the MCP tool diff --git a/cli/lib/rt.ts b/cli/lib/rt.ts index 9b43cc7..d323d45 100644 --- a/cli/lib/rt.ts +++ b/cli/lib/rt.ts @@ -82,6 +82,9 @@ export const importPath = (path: string): Promise => import(pathToFileURL(p */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function importConfigModule(path: string): Promise { + // a JSON config is read, not imported: Node refuses a JSON import without `with { type: 'json' }` (every store-only + // app of `storebound watch` on a server failed; pitfall 105) + if (/\.json$/i.test(path)) return { default: JSON.parse(readFileSync(path, 'utf8')) }; if (isBun || !/\.[cm]?ts$/.test(path)) return importPath(path); try { return await withoutTypeStripWarning(() => importPath(path)); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index c44253a..a88eb70 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1265,3 +1265,10 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `iapLocalizationChanges` / `iapAttributeChanges` / `availabilityChange` (`cli/apple/iap.ts`), the subscription helpers in `cli/lib/subscriptions.ts`, `planAppInfo` / `reviewDetailDiff`, `campaignDiff`, `winBackDiff`, `eventAttrDiff`; tests `apple-iap-drift`, `subscriptions-drift`, `appinfo-drift`, `misc-drift`. + +## 105. `storebound watch` on a server read no app: "needs an import attribute of type: json" +- **Symptom:** on Node (npx, a server), every store-only app failed with `Module ".../storebound.config.json" needs an + import attribute of "type: json"`; on Bun it worked. +- **Cause:** a JSON config (store-only apps get one generated) was loaded with a bare dynamic import, which Node refuses. +- **Do:** update storebound. +- **Guard:** `importConfigModule` in `cli/lib/rt.ts` reads JSON configs as data (`tests/config-json-node.test.ts`). diff --git a/tests/config-json-node.test.ts b/tests/config-json-node.test.ts new file mode 100644 index 0000000..b2cd820 --- /dev/null +++ b/tests/config-json-node.test.ts @@ -0,0 +1,12 @@ +// A storebound.config.json loads without an import attribute (Node refuses a bare JSON import; pitfall 105). +import { expect, test } from 'bun:test'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { importConfigModule } from '../cli/lib/rt'; + +test('importConfigModule reads a JSON config as data', async () => { + const f = join(mkdtempSync(join(tmpdir(), 'sb-json-')), 'storebound.config.json'); + writeFileSync(f, JSON.stringify({ app: { name: 'Demo' }, play: { packageName: 'com.example.demo' } })); + expect((await importConfigModule(f)).default.play.packageName).toBe('com.example.demo'); +}); From 5a892af3d62fec017466825821b7bd7ea973c80d Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 22:19:38 +0700 Subject: [PATCH 039/255] storebound 0.8.32: version in package.json and every copy, CHANGELOG 0.8.32 section --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1c1dfb0..a5d507f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.31", + "version": "0.8.32", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1ec203f..3667d8c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.31", + "version": "0.8.32", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 8bbad0a..e616cbf 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.31", + "version": "0.8.32", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e9a2c..38e61dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased +## 0.8.32 · 2026-10-22 + - Node: a `storebound.config.json` (and every store-only app) loads again; it needed an import attribute. Pitfall 105. ## 0.8.31 · 2026-10-22 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5ad15a1..e422434 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.31", + "version": "0.8.32", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 6f98514..ce2877d 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4096,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.31" +version = "0.8.32" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 20a0757..83c6b39 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.31" +version = "0.8.32" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index efaf0ca..338db1b 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.31", + "version": "0.8.32", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", diff --git a/gemini-extension.json b/gemini-extension.json index 5444ee7..700067d 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.31", + "version": "0.8.32", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index 65a6780..e8acad0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.31", + "version": "0.8.32", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", From f131de9df2d3c698068a32a26f40b5afa4c9db8b Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 23:31:22 +0800 Subject: [PATCH 040/255] Desktop v2 screens: releases (store cards, stage stepper, rollout ring), submit checklist, TestFlight cards; listings as store pages with completeness, framed screenshots with issue badges, product cards, price matrix with gap shading; health tiles and issue cards, store-style review feed with rating distribution, growth funnel, readiness score with fix-first, tools grid, store-info sheet; settings, accounts, folders, dependencies and onboarding in the v2 language --- apps/desktop/src/i18n/en.ts | 61 ++++ apps/desktop/src/i18n/tr.ts | 61 ++++ apps/desktop/src/screens/Accounts.tsx | 41 ++- apps/desktop/src/screens/AppFolders.tsx | 51 +-- apps/desktop/src/screens/Dependencies.tsx | 43 ++- apps/desktop/src/screens/Ignored.tsx | 21 +- apps/desktop/src/screens/Onboarding.tsx | 9 +- apps/desktop/src/screens/Settings.tsx | 58 +-- .../src/screens/editors/AppInfoEditor.tsx | 33 +- .../desktop/src/screens/editors/ArtEditor.tsx | 20 +- .../src/screens/editors/CatalogEditor.tsx | 26 +- .../src/screens/editors/ListingEditor.tsx | 47 ++- .../screens/editors/StoreListingEditor.tsx | 20 +- .../desktop/src/screens/editors/TabEditor.tsx | 5 +- .../src/screens/editors/TestFlightEditor.tsx | 15 +- apps/desktop/src/screens/tabs/AppInfo.tsx | 80 +++-- apps/desktop/src/screens/tabs/Art.tsx | 32 +- apps/desktop/src/screens/tabs/Catalog.tsx | 83 +++-- apps/desktop/src/screens/tabs/Checks.tsx | 214 +++++++---- apps/desktop/src/screens/tabs/Growth.tsx | 121 ++++--- apps/desktop/src/screens/tabs/Health.tsx | 268 +++++++------- apps/desktop/src/screens/tabs/Listings.tsx | 77 ++-- apps/desktop/src/screens/tabs/LiveArt.tsx | 18 +- apps/desktop/src/screens/tabs/Prices.tsx | 71 ++-- apps/desktop/src/screens/tabs/Releases.tsx | 135 ++++--- apps/desktop/src/screens/tabs/Reviews.tsx | 201 ++++++----- apps/desktop/src/screens/tabs/Submit.tsx | 334 +++++++++++------- apps/desktop/src/screens/tabs/TestFlight.tsx | 165 +++++---- apps/desktop/src/screens/tabs/Tools.tsx | 84 +++-- apps/desktop/src/styles/accounts.css | 26 ++ apps/desktop/src/styles/appinfo.css | 45 +++ apps/desktop/src/styles/art.css | 38 ++ apps/desktop/src/styles/catalog.css | 29 ++ apps/desktop/src/styles/checks.css | 76 ++++ apps/desktop/src/styles/deps.css | 20 ++ apps/desktop/src/styles/folders.css | 22 ++ apps/desktop/src/styles/growth.css | 37 ++ apps/desktop/src/styles/health.css | 79 +++++ apps/desktop/src/styles/listings.css | 36 ++ apps/desktop/src/styles/onboarding.css | 33 ++ apps/desktop/src/styles/prices.css | 17 + apps/desktop/src/styles/releases.css | 94 +++++ apps/desktop/src/styles/reviews.css | 81 +++++ apps/desktop/src/styles/settings.css | 37 ++ apps/desktop/src/styles/submit.css | 60 ++++ apps/desktop/src/styles/testflight.css | 64 ++++ apps/desktop/src/styles/tools.css | 48 +++ 47 files changed, 2309 insertions(+), 927 deletions(-) create mode 100644 apps/desktop/src/styles/accounts.css create mode 100644 apps/desktop/src/styles/appinfo.css create mode 100644 apps/desktop/src/styles/art.css create mode 100644 apps/desktop/src/styles/catalog.css create mode 100644 apps/desktop/src/styles/checks.css create mode 100644 apps/desktop/src/styles/deps.css create mode 100644 apps/desktop/src/styles/folders.css create mode 100644 apps/desktop/src/styles/growth.css create mode 100644 apps/desktop/src/styles/health.css create mode 100644 apps/desktop/src/styles/listings.css create mode 100644 apps/desktop/src/styles/onboarding.css create mode 100644 apps/desktop/src/styles/prices.css create mode 100644 apps/desktop/src/styles/releases.css create mode 100644 apps/desktop/src/styles/reviews.css create mode 100644 apps/desktop/src/styles/settings.css create mode 100644 apps/desktop/src/styles/submit.css create mode 100644 apps/desktop/src/styles/testflight.css create mode 100644 apps/desktop/src/styles/tools.css diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f989257..29d0984 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -817,6 +817,19 @@ export const en = { 'set.unmute': 'Unmute ({until})', 'set.muted': 'Muted until {until}', 'set.clickNote': "Clicking a notification does not open the app on desktop; the next time you open the window from the tray, it goes to that app's Releases tab.", + 'v2cfg.workspace': 'Workspace', + 'v2cfg.studios': 'Studios', + 'v2cfg.accounts': 'Store accounts', + 'v2cfg.perStore': '{play} Google Play · {apple} App Store', + 'v2cfg.keysWork': 'Keys working', + 'v2cfg.keysFailing': 'Keys failing', + 'v2cfg.depsFound': 'Tools found', + 'v2cfg.depsNeeds': 'Need attention', + 'v2cfg.depsInApp': 'In app data', + 'v2cfg.depsInAppSub': 'downloaded by storebound', + 'v2cfg.needs': 'needs {version}', + 'v2cfg.stepOf': 'Step {n} of {total}', + 'v2cfg.progress': 'Setup progress', 'set.lead': 'Studios, accounts, store reads, the offline cache, language, theme, notifications and more. Everything stays on this computer.', 'set.sections': 'Settings sections', 'set.studios': 'Studios & accounts', @@ -1163,6 +1176,14 @@ export const en = { 'art.render': 'Render {lang}', 'art.notThere': 'Not there yet', 'art.count': '{count} files', + 'v2lst.preview': 'Store preview', + 'v2lst.noSub': 'No subtitle yet', + 'v2lst.noDesc': 'No description yet', + 'v2lst.more': 'more', + 'v2lst.ready': 'Ready', + 'v2lst.toFix': '{count} to fix', + 'v2lst.issues': '{count} issues', + 'v2lst.issues_one': '1 issue', 'art.count_one': '1 file', 'art.open': 'Open {name}', 'art.tooBig': 'too big to preview', @@ -1852,6 +1873,26 @@ export const en = { 'tools.g.ads': 'Apple Ads', 'tools.g.flows': 'Workflows and AI tools', 'tools.count': '{count} commands', + 'v2ins.hStartup': 'Launch time', + 'v2ins.hStartupSub': 'App Store diagnostics · version {version}', + 'v2ins.rvOpen': 'Unanswered', + 'v2ins.gFunnel': 'Store funnel', + 'v2ins.gVsPrev': '% vs the {count} days before', + 'v2ins.gPts': 'points vs the {count} days before', + 'v2ins.cScore': 'Store readiness', + 'v2ins.cScoreSub': '{ok} of {total} checks pass', + 'v2ins.cReady': 'Ready to submit', + 'v2ins.cBlocked': '{count} blockers to fix', + 'v2ins.cNotRun': 'Not checked yet', + 'v2ins.cBlockers': 'Blockers', + 'v2ins.cWarnings': 'Warnings', + 'v2ins.cPassedN': 'Passed', + 'v2ins.cFixFirst': 'Fix first', + 'v2ins.cPassed': 'Passed checks ({count})', + 'v2ins.cAccepted': 'Accepted risks ({count})', + 'v2ins.aiSheet': 'Store info', + 'v2ins.aiOpen': 'Open questions', + 'v2ins.aiNone': 'None', 'tools.count_one': '1 command', 'tools.show': 'Show', 'tools.hide': 'Hide', @@ -1947,4 +1988,24 @@ export const en = { 'pf.note': "Note", 'pf.fixIn': "Fix in {tab}", 'pf.openTab': "Open {tab}", + 'v2rel.version': "Version", + 'v2rel.stages': "{store} release stages", + 'v2rel.ofUsers': "of users get this version", + 'v2rel.versions': "{count} versions", + 'v2rel.ready': "Ready", + 'v2rel.blocked': "Blocked", + 'v2rel.todo': "To do", + 'v2rel.attention': "Check", + 'v2rel.readyCount': "{done} of {total} steps ready", + 'v2rel.steps': "Steps to send for review", + 'v2rel.stepRead': "Store state", + 'v2rel.stepNoOpen': "Nothing else in review", + 'v2rel.stepBuild': "Pick the build", + 'v2rel.stepVersion': "Version to send", + 'v2rel.stepSend': "Submit for review", + 'v2rel.stepCode': "Pick the versionCode", + 'v2rel.tfGroups': "Beta groups", + 'v2rel.tfInGroups': "in {count} groups", + 'v2rel.tfSplit': "{internal} internal · {external} external", + 'v2rel.tfUsable': "Ready to test", } as const; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 0fc5ff5..4219477 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -819,6 +819,19 @@ export const tr: Record = { 'set.unmute': 'Sesi aç ({until})', 'set.muted': '{until} saatine kadar sessiz', 'set.clickNote': 'Masaüstünde bildirime tıklamak uygulamayı açmaz; pencereyi bir sonraki sefer tepsiden açtığınızda o uygulamanın Yayınlar sekmesine gider.', + 'v2cfg.workspace': 'Çalışma alanı', + 'v2cfg.studios': 'Stüdyolar', + 'v2cfg.accounts': 'Mağaza hesapları', + 'v2cfg.perStore': '{play} Google Play · {apple} App Store', + 'v2cfg.keysWork': 'Çalışan anahtarlar', + 'v2cfg.keysFailing': 'Sorunlu anahtarlar', + 'v2cfg.depsFound': 'Bulunan araçlar', + 'v2cfg.depsNeeds': 'İlgi bekleyen', + 'v2cfg.depsInApp': 'Uygulama verisinde', + 'v2cfg.depsInAppSub': 'storebound indirdi', + 'v2cfg.needs': '{version} gerekli', + 'v2cfg.stepOf': 'Adım {n} / {total}', + 'v2cfg.progress': 'Kurulum ilerlemesi', 'set.lead': 'Stüdyolar, hesaplar, mağaza okumaları, çevrimdışı önbellek, dil, tema, bildirimler ve dahası. Hepsi bu bilgisayarda kalır.', 'set.sections': 'Ayar bölümleri', 'set.studios': 'Stüdyolar ve hesaplar', @@ -1165,6 +1178,14 @@ export const tr: Record = { 'art.render': '{lang} oluştur', 'art.notThere': 'Henüz yok', 'art.count': '{count} dosya', + 'v2lst.preview': 'Mağaza önizlemesi', + 'v2lst.noSub': 'Henüz alt başlık yok', + 'v2lst.noDesc': 'Henüz açıklama yok', + 'v2lst.more': 'devamı', + 'v2lst.ready': 'Hazır', + 'v2lst.toFix': '{count} düzeltilecek', + 'v2lst.issues': '{count} sorun', + 'v2lst.issues_one': '1 sorun', 'art.count_one': '1 dosya', 'art.open': '{name} aç', 'art.tooBig': 'önizleme için çok büyük', @@ -1854,6 +1875,26 @@ export const tr: Record = { 'tools.g.ads': 'Apple Ads', 'tools.g.flows': 'İş akışları ve yapay zekâ araçları', 'tools.count': '{count} komut', + 'v2ins.hStartup': 'Açılış süresi', + 'v2ins.hStartupSub': 'App Store tanılamaları · sürüm {version}', + 'v2ins.rvOpen': 'Yanıtsız', + 'v2ins.gFunnel': 'Mağaza hunisi', + 'v2ins.gVsPrev': 'önceki {count} güne göre %', + 'v2ins.gPts': 'önceki {count} güne göre puan', + 'v2ins.cScore': 'Mağaza hazırlığı', + 'v2ins.cScoreSub': '{total} kontrolden {ok} tanesi geçiyor', + 'v2ins.cReady': 'Göndermeye hazır', + 'v2ins.cBlocked': 'Düzeltilecek {count} engel', + 'v2ins.cNotRun': 'Henüz kontrol edilmedi', + 'v2ins.cBlockers': 'Engeller', + 'v2ins.cWarnings': 'Uyarılar', + 'v2ins.cPassedN': 'Geçti', + 'v2ins.cFixFirst': 'Önce bunları düzelt', + 'v2ins.cPassed': 'Geçen kontroller ({count})', + 'v2ins.cAccepted': 'Kabul edilen riskler ({count})', + 'v2ins.aiSheet': 'Mağaza bilgileri', + 'v2ins.aiOpen': 'Açık sorular', + 'v2ins.aiNone': 'Yok', 'tools.count_one': '1 komut', 'tools.show': 'Göster', 'tools.hide': 'Gizle', @@ -1949,4 +1990,24 @@ export const tr: Record = { 'pf.note': "Not", 'pf.fixIn': "{tab} sekmesinde düzelt", 'pf.openTab': "{tab} sekmesini aç", + 'v2rel.version': "Sürüm", + 'v2rel.stages': "{store} yayın aşamaları", + 'v2rel.ofUsers': "kullanıcı bu sürümü alıyor", + 'v2rel.versions': "{count} sürüm", + 'v2rel.ready': "Hazır", + 'v2rel.blocked': "Engelli", + 'v2rel.todo': "Yapılacak", + 'v2rel.attention': "Kontrol et", + 'v2rel.readyCount': "{total} adımın {done} tanesi hazır", + 'v2rel.steps': "İncelemeye gönderme adımları", + 'v2rel.stepRead': "Mağaza durumu", + 'v2rel.stepNoOpen': "İncelemede başka gönderim yok", + 'v2rel.stepBuild': "Build'i seç", + 'v2rel.stepVersion': "Gönderilecek sürüm", + 'v2rel.stepSend': "İncelemeye gönder", + 'v2rel.stepCode': "versionCode'u seç", + 'v2rel.tfGroups': "Beta grupları", + 'v2rel.tfInGroups': "{count} grupta", + 'v2rel.tfSplit': "{internal} dahili · {external} harici", + 'v2rel.tfUsable': "Teste hazır", }; diff --git a/apps/desktop/src/screens/Accounts.tsx b/apps/desktop/src/screens/Accounts.tsx index f9412b7..bd79022 100644 --- a/apps/desktop/src/screens/Accounts.tsx +++ b/apps/desktop/src/screens/Accounts.tsx @@ -10,7 +10,9 @@ import { ago } from '../lib/format'; import { withIgnored } from '../lib/identity'; import { type AccountHealth, useData } from '../store'; import { Icon, StoreGlyph } from '../ui/icons'; -import { Card, Chip, Empty, Notice, StudioMark } from '../ui/kit'; +import { Chip, Notice, StudioMark } from '../ui/kit'; +import { EmptyState, PageHeader, Section, StatTile } from '../ui/ds'; +import '../styles/accounts.css'; import type { Tone } from '../lib/model'; const STATUS: Record = { @@ -110,7 +112,7 @@ function StudioCard({ s, accounts, focus }: { s: Studio | null; accounts: Accoun const id = s ? `studio-${s.id}` : 'studio-none'; useEffect(() => { if (focus) document.getElementById(id)?.scrollIntoView({ block: 'start' }); }, [focus, id]); return ( -
+
@@ -178,28 +180,31 @@ export function Accounts({ focus }: { focus?: string }) { ); return ( -
-
-

{t('nav.accounts')}

{t('accounts.leadStudios')}

-
- - -
-
+
+ + + + } /> + {d.accounts.length > 0 &&
+ + a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length })} /> + d.accountHealth[a.id]?.check?.status === 'ok').length} /> + { const h = d.accountHealth[a.id]; return !!h && (!!h.error || (!!h.check && h.check.status !== 'ok')); }) ? 'bad' : undefined} label={t('v2cfg.keysFailing')} value={d.accounts.filter(a => { const h = d.accountHealth[a.id]; return !!h && (!!h.error || (!!h.check && h.check.status !== 'ok')); }).length} /> +
} {error && {error}} - {!d.accounts.length && !d.studios.length && {t('accounts.emptyTextStudios')}} + {!d.accounts.length && !d.studios.length &&
{t('accounts.emptyTitle')} {t('accounts.emptyTextStudios')}
} {d.studios.map(s => a.studio === s.id)} focus={focus === s.id} />)} {(unassigned.length > 0 || !d.studios.length) && d.accounts.length > 0 && } -
- +
+
{ e.preventDefault(); addStudio(); }}>
setStudioName(e.target.value)} maxLength={48} placeholder={t('studio.namePlaceholder')} autoComplete="off" />
{t('studio.color')}
- - +
+
{ e.preventDefault(); addPlay(); }}>
setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} maxLength={60} autoComplete="off" />
@@ -207,8 +212,8 @@ export function Accounts({ focus }: { focus?: string }) {

{t('accounts.playHow')}

- - +
+
{ e.preventDefault(); addApple(); }}>
setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} maxLength={60} autoComplete="off" />
@@ -221,7 +226,7 @@ export function Accounts({ focus }: { focus?: string }) {
-
+
{t('accounts.safetyStudios')}
diff --git a/apps/desktop/src/screens/AppFolders.tsx b/apps/desktop/src/screens/AppFolders.tsx index 1162a6f..b5df3b4 100644 --- a/apps/desktop/src/screens/AppFolders.tsx +++ b/apps/desktop/src/screens/AppFolders.tsx @@ -1,7 +1,7 @@ // Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new // folder from the templates (`storebound init` with a profile, confirmed in a native dialog). import { failureText } from '../lib/results'; -import { useEffect, useRef, useState } from 'react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api'; import { devSwitch } from '../dev/switches'; import { t } from '../i18n'; @@ -10,12 +10,20 @@ import { useNav } from '../nav'; import { useData } from '../store'; import { Icon, StoreGlyph } from '../ui/icons'; import { useFolderActions } from '../ui/FolderMissing'; -import { AppIcon, Card, Chip, Empty, Notice, Seg, StoreBadge } from '../ui/kit'; +import { AppIcon, Empty, Notice, Seg, StoreBadge } from '../ui/kit'; +import { PageHeader, Section, StatusPill, tintStyle, useTint } from '../ui/ds'; +import '../styles/folders.css'; import { checkState } from './Portfolio'; type Profile = 'app' | 'game' | 'subscription-app'; interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string } +/** Folder card wrapper carrying the app's tint (hooks cannot run inside the list map). */ +function Tinted({ src, name, children }: { src: string | null | undefined; name: string; children: ReactNode }) { + const hue = useTint(src, name); + return
{children}
; +} + function LocateButton({ project }: { project: Project }) { const { busy, locate } = useFolderActions(project); return ; @@ -73,41 +81,36 @@ export function AppFolders() { : run.result.ok ? t('run.done') : failureText(run.result); return ( -
-
-

{t('nav.apps')}

{t('folders.lead')}

-
- +
+ - -
-
+ } /> {error && {error}} {!d.projects.length ? ( - +
- }>{t('folders.emptyText')} + }>{t('folders.emptyText')}
) : ( - +
{d.projects.map(p => { const e = d.statusOf(p); const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e); return ( -
- + +
- {e?.app?.name ?? p.name}{st.text} + {e?.app?.name ?? p.name}{st.text} {shortPath(p.path, d.info?.home)}
-
+
-
+
{p.missing ? : <> @@ -128,14 +131,14 @@ export function AppFolders() { }
-
+ ); })}
- +
)} - +
@@ -147,16 +150,16 @@ export function AppFolders() {

{t('folders.newNote')}

- +
{run && ( - {status}{run.result && <> · {run.result.command}}} +
{status}{run.result && <> · {run.result.command}}} actions={}>
{run.error && {run.error}}
{tildeText(run.result?.output ?? run.lines.join('\n'), d.info?.home) || ' '}
- +
)}
); diff --git a/apps/desktop/src/screens/Dependencies.tsx b/apps/desktop/src/screens/Dependencies.tsx index 9c388e2..e7a04c8 100644 --- a/apps/desktop/src/screens/Dependencies.tsx +++ b/apps/desktop/src/screens/Dependencies.tsx @@ -8,7 +8,9 @@ import { bytes } from '../lib/format'; import { shortPath } from '../paths'; import { useData } from '../store'; import { Icon, type IconName } from '../ui/icons'; -import { Card, Chip, Notice, Skeleton } from '../ui/kit'; +import { Chip, Notice, Skeleton } from '../ui/kit'; +import { PageHeader, Section, StatTile, StatusPill } from '../ui/ds'; +import '../styles/deps.css'; const TOOL_ICON: Record = { ffmpeg: 'image', ffprobe: 'search', chrome: 'globe', xcode: 'terminal', simctl: 'phone', adb: 'phone', git: 'layers' }; @@ -72,29 +74,32 @@ export function Dependencies() { const managed = (id: string): DownloadInfo | undefined => info.find(i => i.tool === (id === 'ffprobe' ? 'ffmpeg' : id)); return ( -
-
-

{t('nav.dependencies')}

{t('deps.lead')}

-
-
+
+ } /> {error && {error}} - + {tools &&
+ + !x.found || x.status === 'outdated') ? 'warn' : undefined} label={t('v2cfg.depsNeeds')} value={tools.filter(x => !x.found || x.status === 'outdated').length} /> + i.installed).length} sub={t('v2cfg.depsInAppSub')} /> +
} +
{!tools ?
: ( -
+
{tools.map(x => { const st = toolState(x); const m = x.install.kind === 'download' ? managed(x.id) : undefined; const running = m ? dl[m.tool] : undefined; const pct = running?.total ? running.received / running.total : null; return ( -
+
- {x.name}{st.text}{x.version && {x.version}} + {x.name} + {st.text}{x.version && {x.version}}{x.required && {t('v2cfg.needs', { version: x.required })}} {x.usedFor} {x.path && {shortPath(x.path, d.info?.home)}}
-
+
{running ? (
@@ -114,9 +119,9 @@ export function Dependencies() { })}
)} - +
{sdk && ( - {t('deps.installed')} : <>{t('deps.missing')}}> {sdk.path ? (
@@ -129,27 +134,27 @@ export function Dependencies() {
    {sdk.checked.map((c, i) =>
  • {d.info?.home ? c.split(d.info.home).join("~") : c}
  • )}
)} - + )} -
+
{info.map(m => ( - +
{t('deps.source')}
{m.source}
{t('deps.license')}
{m.license}
{t('deps.verify')}
{m.tool === 'chrome' ? t('deps.verifyChrome') : t('deps.verifyFfmpeg')}
- +
))}
- +
{['storebound doctor', 'bun add -d playwright', 'brew install ffmpeg'].map(c => (
{c}
))}
- +
); } diff --git a/apps/desktop/src/screens/Ignored.tsx b/apps/desktop/src/screens/Ignored.tsx index 6095165..57c9a49 100644 --- a/apps/desktop/src/screens/Ignored.tsx +++ b/apps/desktop/src/screens/Ignored.tsx @@ -5,7 +5,9 @@ import { withIgnored } from '../lib/identity'; import { useNav } from '../nav'; import { useData } from '../store'; import { Icon, StoreGlyph } from '../ui/icons'; -import { AppIcon, Card, Empty, StoreOnlyTag, StudioMark } from '../ui/kit'; +import { AppIcon, StoreOnlyTag, StudioMark } from '../ui/kit'; +import { EmptyState, PageHeader, Section } from '../ui/ds'; +import '../styles/folders.css'; export function Ignored() { const d = useData(); @@ -15,14 +17,11 @@ export function Ignored() { const restoreAll = () => d.updateOverrides(o => ({ ...o, ignored: [] })); const empty = !apps.length && !accounts.length; return ( -
-
-

{t('nav.ignored')}

{t('ignored.lead')}

- {!empty &&
} -
- {empty ? {t('ignored.emptyText')} : ( +
+ : undefined} /> + {empty ?
{t('ignored.emptyTitle')} {t('ignored.emptyText')}
: ( <> - +
{apps.length ? (
{apps.map(a => { @@ -44,8 +43,8 @@ export function Ignored() { })}
) :

{t('ignored.noApps')}

} - - +
+
{accounts.length ? (
{accounts.map(a => ( @@ -57,7 +56,7 @@ export function Ignored() { ))}
) :

{t('ignored.noAccounts')}

} - +
)}

{t('ignored.howTo')}

diff --git a/apps/desktop/src/screens/Onboarding.tsx b/apps/desktop/src/screens/Onboarding.tsx index ca535fb..9fa6026 100644 --- a/apps/desktop/src/screens/Onboarding.tsx +++ b/apps/desktop/src/screens/Onboarding.tsx @@ -11,6 +11,7 @@ import { Icon, type IconName, Mark } from '../ui/icons'; import { Illo } from '../ui/illustrations'; import { AppIcon, Chip, Notice, Seg, StudioMark } from '../ui/kit'; import { Swatches } from './Accounts'; +import '../styles/onboarding.css'; const STEPS: [Key, IconName][] = [['wz.s1', 'sparkle'], ['wz.s0', 'building'], ['wz.s2', 'folder'], ['wz.s3', 'key'], ['wz.s4', 'package'], ['wz.s5', 'refresh']]; const LAST = STEPS.length - 1; @@ -72,7 +73,7 @@ export function Onboarding({ onClose }: { onClose: () => void }) { const liveApps = d.live?.value.apps.length ?? 0; return ( -
+
+
+ {t('v2cfg.stepOf', { n: step + 1, total: STEPS.length })} · {t(STEPS[step]![0])} +
+ {STEPS.map(([k], i) => )} +
+
{step === 0 && ( <> diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index 3342203..59a67f8 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -3,6 +3,8 @@ // local tools, the CLI sidecar, privacy, keyboard shortcuts, updates and reset. A section list on the left jumps. import { useEffect, useState } from 'react'; import { useScrollSpy } from '../ui/useScrollSpy'; +import { PageHeader, Section } from '../ui/ds'; +import '../styles/settings.css'; import { api, type CacheInfo, errorText, onUpdateProgress, type Settings as S, type UpdateInfo } from '../api'; import { type Key, t } from '../i18n'; import { ago, bytes, date, num } from '../lib/format'; @@ -11,7 +13,7 @@ import { useNav } from '../nav'; import { usePrefs } from '../prefs-ctx'; import { useData } from '../store'; import { Icon, type IconName, StoreGlyph } from '../ui/icons'; -import { AppIcon, Card, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit'; +import { AppIcon, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit'; export const SECTIONS: [string, IconName, Key][] = [ ['studios', 'building', 'set.studios'], ['pairing', 'link', 'set.pairing'], ['refresh', 'refresh', 'set.refresh'], ['cache', 'database', 'set.cache'], @@ -83,8 +85,8 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; const singles = storeOnly.filter(a => !(a.ios && a.android)); return ( -
-

{t('nav.settings')}

{t('set.lead')}

+
+ {error && {error}}
- nav.go({ screen: 'accounts' })}>{t('set.manage')}}> +
nav.go({ screen: 'accounts' })}>{t('set.manage')}}> {d.studios.length ? (
{d.studios.map(st => { @@ -107,20 +109,20 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; })}
) :

{t('set.noStudios')}

} - +
- +
{storeOnly.length ? ( - +
{[...paired, ...singles].map(a => )}
{t('col.app')}{t('set.pairedHow')}{t('col.actions')}
) :

{t('set.noStoreOnly')}

}
{t('set.ignoredLine', { count: d.apps.filter(a => a.ignored).length, accounts: d.overrides.ignored.filter(i => i.startsWith('account:')).length })}
- +
- +
set(x => ({ ...x, refresh: { ...x.refresh, autoRead: v } }))} />
{t('set.everyMinutes')} @@ -138,9 +140,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; {d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}
- +
- +
{t('set.cacheSize')}
{cache ? `${bytes(cache.file_bytes)} · ${t('set.cacheRows', { count: cache.rows })}` : '–'}
@@ -152,9 +154,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;

{t('settings.dataText')}

- +
- +
{t('settings.language')} update({ ...prefs, lang })} label={t('settings.language')} options={[['system', t('settings.system')], ['en', 'English'], ['tr', 'Türkçe']]} />
@@ -163,9 +165,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
{t('settings.motion')} update({ ...prefs, motion })} label={t('settings.motion')} options={[['system', t('settings.followSystem')], ['reduce', t('settings.reduce')]]} />
- +
- +
set(x => ({ ...x, notify: { ...x.notify, rejected: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, approved: v } }))} /> @@ -183,18 +185,18 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
- +
- nav.go({ screen: 'dependencies' })}>{t('set.manage')}}> +
nav.go({ screen: 'dependencies' })}>{t('set.manage')}}> {tools ? (
{tools.tools.map(x => {x.name}{x.version ? {x.version} : null}{x.source === 'app' && {t('set.appCopy')}})}
) :

–

}

{t('set.depsText')}

- +
- +
{t('settings.app')}
{d.info ? `storebound desktop ${d.info.version}${d.info.debug ? ` (${t('settings.debug')})` : ''} · ${d.info.platform}` : '–'}
CLI
{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? t('foot.cliStarting')}
@@ -202,9 +204,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
{t('set.state')}
{d.sidecar?.running ? t('set.running') : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}
{t('set.writes')}
{t('set.writesText')}
- +
- +
  • {t('set.p1')}
  • {t('set.p2')}
  • @@ -215,9 +217,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
- + - +
{t('settings.scPalette')}
{t('nav.portfolio')}
@@ -227,18 +229,18 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
{t('settings.scClose')}
{t('set.scQuit')}
- +
- +
{t('set.channel')} set(x => ({ ...x, channel }))} options={[['stable', t('set.stable')], ['beta', t('set.beta')]]} />

{t('set.updatesText')}

- +
- +

{t('set.resetText', { accounts: d.accounts.length, studios: d.studios.length, folders: d.projects.length })}

@@ -246,7 +248,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
- +

{t('set.footer', { n: num(d.apps.length) })}

diff --git a/apps/desktop/src/screens/editors/AppInfoEditor.tsx b/apps/desktop/src/screens/editors/AppInfoEditor.tsx index 053f4ea..c0685af 100644 --- a/apps/desktop/src/screens/editors/AppInfoEditor.tsx +++ b/apps/desktop/src/screens/editors/AppInfoEditor.tsx @@ -8,7 +8,8 @@ import { type Key, t } from '../../i18n'; import { AGE_FREQUENCY, AGE_RATING_NONE, APPLE_CATEGORIES, APPLE_GAME_SUBCATEGORIES, KIDS_AGE_BANDS, PLAY_AGE_GROUPS, PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from '../../../../../cli/lib/store-catalogs'; import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, TextField, useEdits } from '../../ui/Editor'; import { StoreGlyph } from '../../ui/icons'; -import { Card } from '../../ui/kit'; +import { Section } from '../../ui/ds'; +import '../../styles/appinfo.css'; type O = Record; const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {}); @@ -30,7 +31,7 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult } const contact = data.contact; return ( <> - {t('ed.appleInfoSub')}}> +
{t('ed.appleInfoSub')}}>
{games && } @@ -43,8 +44,8 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult } options={[['false', t('ai.encNo')], ['true', t('ai.encYes')]]} hint={t('ed.encHint')} />
- - +
+
{Object.entries(AGE_RATING_NONE).map(([q, none]) => ( ))}
- - +
+
{data.demoAccount ? `${data.demoAccount} · ••••••••` : t('ed.notSet')}
{t('ed.demoHint')}
- + ); } @@ -75,7 +76,7 @@ function PlaySettings({ project, data }: { project: Project; data: EditableResul const acc = obj(data.config.accounts); const cats: [string, string][] = [...PLAY_GAME_CATEGORIES.map(c => [c, `${t('ed.game')}: ${c}`] as [string, string]), ...PLAY_APP_CATEGORIES.map(c => [c, `${t('ed.app')}: ${c}`] as [string, string])]; return ( - {t('ed.playSettingsSub')}}> +
{t('ed.playSettingsSub')}}>
@@ -83,14 +84,14 @@ function PlaySettings({ project, data }: { project: Project; data: EditableResul
- +
); } function Declarations({ project, data }: { project: Project; data: EditableResult }) { const ds = obj(data.config.dataSafety); return ( - +
{data.play && } {data.play && } @@ -101,14 +102,14 @@ function Declarations({ project, data }: { project: Project; data: EditableResul {data.play && } {data.play && } {data.play && } - +
); } function Promotion({ project, data }: { project: Project; data: EditableResult }) { const apple = obj(data.config.apple); return ( - {t('ed.promoSub')}}> +
{t('ed.promoSub')}}> @@ -116,7 +117,7 @@ function Promotion({ project, data }: { project: Project; data: EditableResult }
- + ); } @@ -124,13 +125,13 @@ export function AppInfoEditor({ project, data, onSaved }: { project: Project; da const e = useEdits(); const unsaved = e.count(project.id, 'appinfo') ? t('ed.saveFirst') : null; return ( -
+
{data.apple && } {data.play && } {data.apple && } - +
{data.apple && (
@@ -149,7 +150,7 @@ export function AppInfoEditor({ project, data, onSaved }: { project: Project; da
)}
- +
); } diff --git a/apps/desktop/src/screens/editors/ArtEditor.tsx b/apps/desktop/src/screens/editors/ArtEditor.tsx index 4b40d05..77f3e25 100644 --- a/apps/desktop/src/screens/editors/ArtEditor.tsx +++ b/apps/desktop/src/screens/editors/ArtEditor.tsx @@ -1,13 +1,15 @@ // Store art editor (Store art tab): the order of the screenshot scenes (the store shows them in this order) and the // raw frame each scene shows (replace a screenshot by pointing the scene at another frame). Saved into the config; // then render, and upload to each store with the dry run in the window (uploads replace the store's sets). +import '../../styles/art.css'; import { useState } from 'react'; import { api, errorText, type EditableResult, type Project } from '../../api'; import { useData, useWrite } from '../../store'; import { t } from '../../i18n'; import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Empty } from '../../ui/kit'; +import { Empty } from '../../ui/kit'; +import { Section } from '../../ui/ds'; import { keyOf } from '../../lib/edits'; type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Record; @@ -33,7 +35,7 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: const p = e.get(project.id, keyOf(op)); const scenes = (p && p.op.kind === 'config' ? p.op.value : saved) as Scene[]; const unsaved = e.count(project.id, 'art') ? t('ed.saveFirst') : null; - if (!saved.length) return {t('ed.artEmptyText')}; + if (!saved.length) return
{t('ed.artEmptyText')}
; const move = (i: number, d: -1 | 1) => { const next = [...scenes]; const j = i + d; @@ -42,8 +44,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: e.set(project.id, { ...op, value: next }, 'art', saved); }; return ( -
- +
1 ? : undefined}>
    {scenes.map((s, i) => ( @@ -61,16 +63,16 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: ))}
- +
{data.shotsCopy && s.id)} />} - +
{t('act.render')}
{data.play &&
Google Play
} {data.apple &&
App Store
}
- +
); } @@ -82,7 +84,7 @@ function Captions({ project, data, scenes }: { project: Project; data: EditableR const [lang, setLang] = useState(langs[0] ?? ''); const L = langs.includes(lang) ? lang : langs[0] ?? ''; return ( - +
{langs.map(x => )}
@@ -103,6 +105,6 @@ function Captions({ project, data, scenes }: { project: Project; data: EditableR ); })}
- + ); } diff --git a/apps/desktop/src/screens/editors/CatalogEditor.tsx b/apps/desktop/src/screens/editors/CatalogEditor.tsx index 27380c4..2a3be47 100644 --- a/apps/desktop/src/screens/editors/CatalogEditor.tsx +++ b/apps/desktop/src/screens/editors/CatalogEditor.tsx @@ -3,12 +3,14 @@ // offer blocks (introductory and promotional offers, offer codes, win-back) as checked JSON. Saved into the config, // then applied per store with the dry run in the window. A price change of a product that is already live goes // through the owner's price decision (the Catalog tab's "Apply the price changes" owner actions). +import '../../styles/catalog.css'; import { useState } from 'react'; import type { EditableResult, Project } from '../../api'; import { t } from '../../i18n'; import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, NumberField, TextField, useEdits } from '../../ui/Editor'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Chip, Empty } from '../../ui/kit'; +import { Chip, Empty } from '../../ui/kit'; +import { Section } from '../../ui/ds'; type O = Record; const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {}); @@ -44,8 +46,8 @@ function ProductCard({ project, data, p }: { project: Project; data: EditableRes return (
{open && (
@@ -71,7 +73,7 @@ function SubCard({ project, data, group, s }: { project: Project; data: Editable return (
{open && (
@@ -99,17 +101,17 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da const groups = data.config.subscriptions as O[]; const unsaved = e.count(project.id, 'catalog') ? t('ed.saveFirst') : null; if (!products.length && !groups.length) { - return {t('ed.catalogEmptyText')}; + return
{t('ed.catalogEmptyText')}
; } return ( -
+
{products.length > 0 && ( - +
{products.map(p => )}
- +
)} {groups.map(g => ( - +
{data.languages.map(l => ( @@ -120,10 +122,10 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da
{(g.subscriptions as O[]).map(s => )}
- +
))} - +
{data.play && (
@@ -142,7 +144,7 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da )}

{t('ed.priceGate')}

- +
); } diff --git a/apps/desktop/src/screens/editors/ListingEditor.tsx b/apps/desktop/src/screens/editors/ListingEditor.tsx index a09d1f5..69270fa 100644 --- a/apps/desktop/src/screens/editors/ListingEditor.tsx +++ b/apps/desktop/src/screens/editors/ListingEditor.tsx @@ -2,6 +2,7 @@ // counters against the store limits and the checks of `check --aso` / `scan` as the owner types; the shared store // details (URLs, contact, copyright, promo video); Save into store/listing.md, store/appstore.md and the config // (project.edit), Pull the live texts into the files (diff first), and Apply to each store (dry run in the window). +import '../../styles/listings.css'; import { useState } from 'react'; import type { EditableListing, EditableResult, Project } from '../../api'; import { type Key, t } from '../../i18n'; @@ -10,7 +11,8 @@ import { keyOf } from '../../lib/edits'; import { useData, useWrite } from '../../store'; import { ApplyPanel, EditBar, type FieldIssueView, TextField, useEdits } from '../../ui/Editor'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Chip, Notice } from '../../ui/kit'; +import { Chip, Notice } from '../../ui/kit'; +import { Section } from '../../ui/ds'; import { WriteButton } from '../../ui/WriteButton'; const PLAY: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [ @@ -30,6 +32,30 @@ function current(e: ReturnType, project: Project, store: 'play' } return out; } +/** The texts of one language as the store page shows them: icon, title, subtitle / short description, promo text and + * the first lines of the description (live, with the unsaved edits). */ +export function StorePreview({ store, vals, name }: { store: 'play' | 'apple'; vals: Record; name: string }) { + const title = (store === 'play' ? vals.title : vals.name) || name; + const sub = store === 'play' ? vals.short : vals.subtitle; + const promo = store === 'apple' ? vals.promotionalText : ''; + const desc = store === 'play' ? vals.full : vals.description; + return ( +
+ {t('v2lst.preview')} +
+ +
+
{title || '–'}
+ {sub ?
{sub}
:
{t('v2lst.noSub')}
} +
+
+ {promo &&

{promo}

} + {desc ?

{desc}

:

{t('v2lst.noDesc')}

} + {desc && desc.length > 180 && } +
+ ); +} + const byField = (issues: FieldIssue[], f: string): FieldIssueView[] => issues.filter(i => i.field === f).map(i => ({ level: i.level, msg: i.msg })); function StoreColumn({ project, data, store, l }: { project: Project; data: EditableResult; store: 'play' | 'apple'; l: EditableListing }) { @@ -48,6 +74,7 @@ function StoreColumn({ project, data, store, l }: { project: Project; data: Edit {l.status && {l.status === 'draft' ? t('ed.statusDraft') : t('ed.statusTranslate')}} {(l.codes?.length ?? 0) > 1 && {t('ed.alsoServes', { codes: l.codes!.filter(c => c !== l.lang).join(', ') })}}
+ {fresh && {t('ed.newSection', { lang: l.lang })}} {defs.map(d => ( - +
{data.play && {t('ed.pullPlay')}} {data.apple && {t('ed.pullApple')}} }>
{unsaved > 0 && {t('ed.pullNote')}} -
+
{data.languages.map(x => { const n = e.list(project.id, 'listings').filter(p => p.op.kind === 'listing' && (p.op.lang === x.apple || x.play.includes(p.op.lang))).length; return ( @@ -132,12 +159,12 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da {!pl && !al && {t('ed.noStoreForLang', { lang: L?.id ?? '' })}}
- - +
+
- +
- +
{data.play && (
@@ -157,7 +184,7 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da )}

{t('ed.applyNote')}

- - +
+
); } diff --git a/apps/desktop/src/screens/editors/StoreListingEditor.tsx b/apps/desktop/src/screens/editors/StoreListingEditor.tsx index a1640cc..0d2374b 100644 --- a/apps/desktop/src/screens/editors/StoreListingEditor.tsx +++ b/apps/desktop/src/screens/editors/StoreListingEditor.tsx @@ -2,6 +2,7 @@ // (listing.live), edited in place with the same counters and checks, and applied straight to the store per language // (listing.set: the store's dry run in the window, the native dialog, then the real run, which changes only the edited // fields). URLs, contact and app details stay with an app folder (they come from its config). +import '../../styles/listings.css'; import { useState } from 'react'; import type { Project } from '../../api'; import { type Key, t } from '../../i18n'; @@ -12,7 +13,9 @@ import { keyOf } from '../../lib/edits'; import { useRead } from '../../store'; import { ApplyPanel, type FieldIssueView, TextField, useEdits } from '../../ui/Editor'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Section } from '../../ui/ds'; +import { StorePreview } from './ListingEditor'; const PLAY: { f: 'title' | 'short' | 'full'; label: Key; limit: number; rows?: number }[] = [ { f: 'title', label: 'lst.title', limit: 30 }, { f: 'short', label: 'lst.short', limit: 80 }, { f: 'full', label: 'lst.full', limit: 4000, rows: 10 }, @@ -41,6 +44,7 @@ function Column({ project, store, lang, saved, appName, editable }: { project: P return (
{store === 'play' ? 'Google Play' : 'App Store'}{lang}
+ {store === 'apple' && !editable && {t('ed.noEditableVersion')}} {defs.map(d => ( ; - return {t('tf.read')}}>{r.error ?? t('ed.storeOnlyNote')}; + if (r.loading && !r.error) return
; + return
{t('tf.read')}}>{r.error ?? t('ed.storeOnlyNote')}
; } const L = r.data; const langs = [...new Set([...(L.play ?? []).map(x => x.language), ...(L.apple?.locales ?? []).map(x => x.locale)])].sort(); @@ -69,12 +73,12 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) { const pl = L.play?.find(x => x.language === cur); const al = L.apple?.locales.find(x => x.locale === cur); return ( - <> +
{Object.entries(L.errors).map(([s, e]) => )} - } id="store-listing-editor"> +
} id="store-listing-editor">
{t('ed.storeOnlyNote')} -
+
{langs.map(x => )}
@@ -83,7 +87,7 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) { {!pl && !al && {t('ed.noStoreForLang', { lang: cur })}}
- - +
+
); } diff --git a/apps/desktop/src/screens/editors/TabEditor.tsx b/apps/desktop/src/screens/editors/TabEditor.tsx index 462fae0..109eff5 100644 --- a/apps/desktop/src/screens/editors/TabEditor.tsx +++ b/apps/desktop/src/screens/editors/TabEditor.tsx @@ -6,7 +6,8 @@ import { t } from '../../i18n'; import { edits } from '../../lib/edits'; import type { AppTab } from '../../nav'; import { useEditable } from '../../ui/Editor'; -import { Card, ErrorNotice, Skeleton } from '../../ui/kit'; +import { ErrorNotice, Skeleton } from '../../ui/kit'; +import { Section } from '../../ui/ds'; import { AppInfoEditor } from './AppInfoEditor'; import { ArtEditor } from './ArtEditor'; import { CatalogEditor } from './CatalogEditor'; @@ -21,7 +22,7 @@ export const EDIT_TABS: Partial : ; + if (!r.data) return r.error ? :
; const p = { project, data: r.data, onSaved: () => { r.refresh(); } }; switch (tab) { case 'listings': return ; diff --git a/apps/desktop/src/screens/editors/TestFlightEditor.tsx b/apps/desktop/src/screens/editors/TestFlightEditor.tsx index 9507aab..07a3dd0 100644 --- a/apps/desktop/src/screens/editors/TestFlightEditor.tsx +++ b/apps/desktop/src/screens/editors/TestFlightEditor.tsx @@ -5,7 +5,8 @@ import type { EditableResult, Project } from '../../api'; import { t } from '../../i18n'; import { ApplyPanel, EditBar, JsonField, TextField, useEdits } from '../../ui/Editor'; -import { Card } from '../../ui/kit'; +import { Section } from '../../ui/ds'; +import '../../styles/testflight.css'; type O = Record; const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {}); @@ -17,8 +18,8 @@ export function TestFlightEditor({ project, data, onSaved }: { project: Project; const unsaved = e.count(project.id, 'testflight') ? t('ed.saveFirst') : null; if (!data.apple) return null; return ( -
- +
+
{data.apple.map(l => (
@@ -35,14 +36,14 @@ export function TestFlightEditor({ project, data, onSaved }: { project: Project; {typeof desc === 'string' && } - +
- -
+
+
- +
); } diff --git a/apps/desktop/src/screens/tabs/AppInfo.tsx b/apps/desktop/src/screens/tabs/AppInfo.tsx index 7b7e5bf..be4ce7c 100644 --- a/apps/desktop/src/screens/tabs/AppInfo.tsx +++ b/apps/desktop/src/screens/tabs/AppInfo.tsx @@ -12,10 +12,12 @@ import { declarationsMd, exportName } from '../../lib/exports'; import { ago, num } from '../../lib/format'; import { useData, useRead, useWrite } from '../../store'; import { useExports, useRunExport } from '../../ui/Exports'; -import { Icon, StoreGlyph } from '../../ui/icons'; +import { Icon, type IconName, StoreGlyph } from '../../ui/icons'; import { CONSOLE_FORM_NAME, ConsoleFillButton } from '../../ui/ConsoleFill'; import { ConsolePlanButton, PLAN_FORMS } from '../../ui/ConsolePlan'; -import { Bar, Card, Chip, Empty, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; +import { 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'; @@ -34,7 +36,7 @@ const yes = (b: boolean | null | undefined): string => (b === null || b === unde function AskCard({ ask }: { ask: DeclAsk[] }) { if (!ask.length) return {t('ai.allAnswered')}; return ( - +
{ask.map(a => (
@@ -45,7 +47,7 @@ function AskCard({ ask }: { ask: DeclAsk[] }) {
))}
- +
); } @@ -76,12 +78,12 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR const up = ds?.uploaded ?? null; const asked = d.ask.filter(a => a.area === 'dataSafety').length; return ( - {t('ai.dsSub')}} pad={false} +
{t('ai.dsSub')}} pad={false} actions={ds ? <> {t('ai.dsUpload')} : undefined}> - {!ds ? {t('ai.dsNoneText')} : ( + {!ds ? {t('ai.dsNoneTitle')}
{t('ai.dsNoneText')}
: ( <>
@@ -99,7 +101,7 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR {asked > 0 && {t('ai.askedHere', { count: asked })}}
{ds.types.length > 0 && ( - +
{ds.types.map(x => ( @@ -110,12 +112,12 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR ))} -
{t('ai.colType')}{t('ai.colCollected')}{t('ai.colShared')}
+
)}
{t('ai.dsFoot', { rows: ds.rows })}
)} -
+ ); } @@ -123,14 +125,14 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe const ap = d.appPrivacy; if (!ap) return null; return ( - {t('ai.apSub')}} pad={false} +
{t('ai.apSub')}} pad={false} actions={<> }>
{t('ai.apNote')}
- {!ap.items.length ? {ap.fromDataSafety ? t('ai.apNoneText') : t('ai.apUnknownText')} : ( - + {!ap.items.length ? {ap.fromDataSafety ? t('ai.apNoneTitle') : t('ai.apUnknownTitle')}
{ap.fromDataSafety ? t('ai.apNoneText') : t('ai.apUnknownText')}
: ( +
{ap.items.map(x => ( @@ -142,9 +144,9 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe ))} -
{t('ai.colAppleType')}{t('ai.colPurposes')}{t('ai.colLinked')}{t('ai.colTracking')}
+
)} - + ); } @@ -154,7 +156,7 @@ function ConsoleSteps({ project, stores }: { project: Project; stores: Declarati const forms = PLAN_FORMS.filter(f => (f.store === 'apple' ? stores.apple : stores.play)); if (!forms.length) return null; return ( - +
{forms.map(f => (
@@ -167,7 +169,7 @@ function ConsoleSteps({ project, stores }: { project: Project; stores: Declarati
))}
- +
); } @@ -181,27 +183,45 @@ export function AppInfoTab({ project }: { project: Project }) { { id: 'decl-md', label: t('export.declarations'), icon: 'clipboard', kind: 'md', file: exportName(name, 'store declarations'), disabled: r.data ? false : t('export.readFirst'), build: () => declarationsMd(name, r.data!) }, { id: 'ds-csv', label: t('export.dataSafety'), icon: 'shield', kind: 'csv', file: exportName(name, 'data safety'), disabled: r.data?.dataSafety ? false : t('export.noDataSafety'), build: () => r.data!.dataSafety!.csv }, ]); - if (!r.data) return r.error ? : ; + if (!r.data) return r.error ? :
; const d = r.data; const info = d.appInfo; const enc = d.encryption; const rv = d.review; const cat = info?.categories ? [info.categories.primary, info.categories.primarySubOne, info.categories.primarySubTwo].filter(Boolean).join(' / ') : null; + const ds = d.dataSafety; + const facts: { k: string; label: string; icon: IconName; value: string; tone: 'ok' | 'warn' | 'neutral' }[] = [ + { k: 'ask', label: t('v2ins.aiOpen'), icon: 'question', value: d.ask.length ? num(d.ask.length) : t('v2ins.aiNone'), tone: d.ask.length ? 'warn' : 'ok' }, + ...(d.stores.apple && info ? [ + { k: 'cat', label: t('ai.category'), icon: 'grid' as IconName, value: cat ?? t('ai.notSet'), tone: cat ? 'neutral' as const : 'warn' as const }, + { k: 'price', label: t('ai.price'), icon: 'coins' as IconName, value: info.free ? t('ai.free') : t('ai.paid'), tone: 'neutral' as const }, + { k: 'enc', label: t('ai.encTitle'), icon: 'lock' as IconName, value: enc === null ? t('ai.notAnswered') : enc ? t('ai.encYes') : t('ai.encNo'), tone: enc === null ? 'warn' as const : 'neutral' as const }, + ] : []), + ...(d.stores.play && ds ? [{ k: 'ds', label: t('ai.dsCollects'), icon: 'shield' as IconName, value: yes(ds.collectsData), tone: ds.collectsData === null || ds.collectsData === undefined ? 'warn' as const : 'neutral' as const }] : []), + ]; return ( - <> +
+
+
{t('v2ins.aiSheet')}

{name}

+ {d.stores.apple && App Store}{d.stores.play && Google Play} +
+
+ {facts.map(f =>
{f.label}
{f.value}
)} +
+
{d.stores.apple && e?.app?.platforms.ios && !e.app.platforms.ios.ascAppId && ( }>
{t('cf.noRecordTitle')}{t('cf.noRecordText')}
)} -
- {d.stores.play ? : {t('ai.noPlayText')}} - {d.stores.apple ? : {t('ai.noAppleText')}} +
+ {d.stores.play ? :
{t('ai.noPlay')}
{t('ai.noPlayText')}
} + {d.stores.apple ? :
{t('ai.noApple')}
{t('ai.noAppleText')}
}
{d.stores.apple && ( -
- +
{t('ai.push')} @@ -212,15 +232,15 @@ export function AppInfoTab({ project }: { project: Project }) {
{t('ai.rights')}
{info!.contentRights ? {info!.contentRights === 'USES_THIRD_PARTY_CONTENT' ? t('ai.rightsThird') : t('ai.rightsOwn')} : {t('ai.notSet')}}
{t('ai.price')}
{info!.free ? t('ai.free') : t('ai.paid')}
- - +
{t('ai.encAnswer')}}>
{enc === null ? {t('ai.notAnswered')} : {enc ? t('ai.encYes') : t('ai.encNo')}} {enc === null ? t('ai.encUnset') : enc ? t('ai.encYesText') : t('ai.encNoText')}
- - +
{t('ai.push')}}>
@@ -235,13 +255,13 @@ export function AppInfoTab({ project }: { project: Project }) {
{t('ai.reviewSafe')}
- +
)} -

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

- +

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

+
); } diff --git a/apps/desktop/src/screens/tabs/Art.tsx b/apps/desktop/src/screens/tabs/Art.tsx index 66ff5b9..43517fc 100644 --- a/apps/desktop/src/screens/tabs/Art.tsx +++ b/apps/desktop/src/screens/tabs/Art.tsx @@ -2,6 +2,7 @@ // size (`shots gallery --json`, offline), store check issues outlined. Images load one by one as they scroll into view // (`project.render`, a data: URL each: the window loads no files itself). Re-render (dry run, dialog, real run), write // the review page, open it or the folder. +import '../../styles/art.css'; import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api, errorText, type Project, type RenderImage, type RendersResult, type RenderTile, type ToolResult } from '../../api'; import { t } from '../../i18n'; @@ -9,7 +10,8 @@ import { ago, bytes, num } from '../../lib/format'; import { artRows, langIssues } from '../../lib/views'; import { useData, useRead, useWrite } from '../../store'; import { Icon } from '../../ui/icons'; -import { Card, Chip, Empty, ErrorNotice, Kpi, Seg, Skeleton } from '../../ui/kit'; +import { Chip, Empty, ErrorNotice, Seg, Skeleton } from '../../ui/kit'; +import { Section, StatTile, StatusPill } from '../../ui/ds'; import { WriteButton } from '../../ui/WriteButton'; import { LiveArt } from './LiveArt'; @@ -57,11 +59,13 @@ function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: Re return () => { alive = false; io.disconnect(); }; }, [projectId, file, stamp, src]); const lvl = tile.issues.some(i => i.level === 'error') ? 'err' : tile.issues.length ? 'warn' : ''; + const shape = tile.h > tile.w ? 'portrait' : 'landscape'; return ( -
+
{tile.name}{tile.w}×{tile.h}{tile.bytes ? ` · ${bytes(tile.bytes)}` : ''}
{tile.issues.length > 0 &&
    {tile.issues.slice(0, 2).map((i, k) =>
  • {i.msg}
  • )}
} @@ -125,7 +129,7 @@ export function ArtTab({ project }: { project: Project }) { }, [project.id, d, w, data?.dir, r.refresh]); // the live art stays the first child in both branches, so it keeps its state when the local renders arrive - if (!data) return <>{r.error ? : }; + if (!data) return
{r.error ? :
}
; const total = data.totals.images + data.totals.videos; const toolbar = ( <> @@ -136,15 +140,15 @@ export function ArtTab({ project }: { project: Project }) { ); return ( - <> +
-
- - - - +
+ + + +
- {dir}} actions={toolbar} pad={false}> +
{dir}} actions={toolbar} pad={false}>
{data.variants.length > 1 && [x.id, x.id ? t('gr.variant', { id: x.id }) : t('art.base')] as [string, string])} />}
@@ -163,9 +167,9 @@ export function ArtTab({ project }: { project: Project }) {
{rows.map(row => (
i.level === 'error') ? ' bad' : ''}`} key={row.size}> -

{row.label}{lg}/{row.size}{row.store === 'apple' ? 'App Store' : 'Google Play'}{t('art.count', { count: row.tiles.length })}

+

{row.label}{lg}/{row.size}{row.store === 'apple' ? 'App Store' : 'Google Play'}{row.issues.length > 0 && i.level === 'error') ? 'bad' : 'warn'}>{t('v2lst.issues', { count: row.issues.length })}}{t('art.count', { count: row.tiles.length })}

{row.issues.length > 0 &&
    {row.issues.map((i, k) =>
  • {i.msg}
  • )}
} -
row.tiles[0].h ? '260px' : '150px' }}> +
row.tiles[0].h ? '260px' : '170px' }}> {row.tiles.map((tile, i) => setOpen({ tiles: row.tiles, i })} />)}
@@ -173,8 +177,8 @@ export function ArtTab({ project }: { project: Project }) {
)}
{t('art.foot', { when: ago(r.at) })}
- +
{open && setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} onReplace={tile => { setOpen(null); replace(tile); }} />} - +
); } diff --git a/apps/desktop/src/screens/tabs/Catalog.tsx b/apps/desktop/src/screens/tabs/Catalog.tsx index 7dedd9d..290d34a 100644 --- a/apps/desktop/src/screens/tabs/Catalog.tsx +++ b/apps/desktop/src/screens/tabs/Catalog.tsx @@ -1,5 +1,6 @@ // Catalog: products and subscriptions defined once in the config against both stores (catalog diff): a parity // matrix (present, mismatched, missing, not sold there, not read), and price gaps between the stores per region. +import '../../styles/catalog.css'; import type { Project } from '../../api'; import { type Key, t } from '../../i18n'; import { ago, num } from '../../lib/format'; @@ -8,17 +9,19 @@ import { useData, useRead, useWrite } from '../../store'; import { Icon, type IconName, StoreGlyph } from '../../ui/icons'; import { OwnerAction } from '../../ui/OwnerAction'; import { WriteButton } from '../../ui/WriteButton'; -import { Card, Chip, Empty, ErrorNotice, Kpi, ReadControl, Skeleton } from '../../ui/kit'; +import { Chip, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit'; +import { EmptyState, Section, StatTile, StatusPill } from '../../ui/ds'; import { catalogMd, exportName } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; import { useNav } from '../../nav'; const CELL: Record = { ok: ['check', 'cat.ok'], warn: ['alert', 'cat.warn'], error: ['x', 'cat.error'], na: ['dot', 'cat.na'], unknown: ['info', 'cat.unknown'] }; -function Cell({ c }: { c: { state: CellState; notes: string[] } }) { +function Cell({ c, store }: { c: { state: CellState; notes: string[] }; store?: 'ios' | 'android' }) { const [icon, key] = CELL[c.state]; return ( -
+
+ {store && {store === 'ios' ? 'App Store' : 'Google Play'}} {t(key)} {c.notes.length > 0 &&
    {c.notes.slice(0, 3).map((n, i) =>
  • {n}
  • )}
}
@@ -33,7 +36,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project const e = d.statusOf(project); const ios = !!e?.app?.platforms.ios, android = !!e?.app?.platforms.android; return ( - +
{android && (
@@ -60,7 +63,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project

{t('act.catalogNote')}

- +
); } @@ -73,48 +76,54 @@ export function CatalogTab({ project }: { project: Project }) { useExports('catalog', [ { id: 'catalog-md', label: t('export.catalog'), icon: 'layers', kind: 'md', file: exportName(name, 'catalog parity'), disabled: diff.data ? false : t('export.readFirst'), build: () => catalogMd(name, diff.data!) }, ]); - if (!e) return ; + if (!e) return
; const rows = parityMatrix(s, diff.data); const read = !!diff.data; const inParity = rows.filter(r => r.ios.state !== 'error' && r.ios.state !== 'warn' && r.android.state !== 'error' && r.android.state !== 'warn' && r.kind !== 'extra').length; const gaps = diff.data?.parity ?? []; if (!rows.length) { - return {t('cat.emptyText')}; + return
{t('cat.emptyTitle')} {t('cat.emptyText')}
; } return ( - <> -
- - - r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} /> - +
+
+ + + r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} /> +
{diff.error && } { diff.refresh(); }} /> - }> -
- - - - {rows.map(r => ( - - - - - - - - - ))} - -
{t('col.item')}{t('col.type')}{t('col.config')}App StoreGoogle Play{t('col.priceGap')}
{r.id}{r.kind === 'extra' ? {t('cat.extra')} : {r.kind === 'subscription' ? t('cat.sub') : t('cat.product')}}{r.config ? [s?.catalog.ios.includes(r.id) ? 'iOS' : null, s?.catalog.android.includes(r.id) ? 'Android' : null].filter(Boolean).join(' + ') || t('cat.na') : {t('cat.notInConfig')}}{r.maxDiff !== null ? 25 ? 'bad' : 'warn'}>{`${num(r.maxDiff, 1)} %`} : {read ? t('cat.noGap') : '–'}}
-
-
+
    + {rows.map(r => { + const worst = [r.ios.state, r.android.state].includes('error') ? 'bad' : [r.ios.state, r.android.state].includes('warn') || r.kind === 'extra' ? 'warn' : 'ok'; + return ( +
  • +
    + +
    + {r.id} + {r.kind === 'extra' ? {t('cat.extra')} : {r.kind === 'subscription' ? t('cat.sub') : t('cat.product')}} + {t('col.config')}: {r.config ? [s?.catalog.ios.includes(r.id) ? 'iOS' : null, s?.catalog.android.includes(r.id) ? 'Android' : null].filter(Boolean).join(' + ') || t('cat.na') : t('cat.notInConfig')} +
    + + {r.maxDiff !== null ? 25 ? 'bad' : 'warn'} title={t('col.priceGap')}>{`Δ ${num(r.maxDiff, 1)} %`} : {read ? t('cat.noGap') : '–'}} +
    +
    + + +
    +
  • + ); + })} +
+ {gaps.length > 0 && ( - - +
+
{gaps.slice(0, 12).map((g, i) => ( @@ -125,9 +134,9 @@ export function CatalogTab({ project }: { project: Project }) { ))} -
{t('col.item')}{t('col.region')}App StoreGoogle Play{t('col.diff')}
-
+
+ )} - +
); } diff --git a/apps/desktop/src/screens/tabs/Checks.tsx b/apps/desktop/src/screens/tabs/Checks.tsx index 1752997..6dc0ad1 100644 --- a/apps/desktop/src/screens/tabs/Checks.tsx +++ b/apps/desktop/src/screens/tabs/Checks.tsx @@ -10,10 +10,12 @@ import { AiFix, aiFinding } from '../../ui/AiFix'; import { api, errorText, newJob } from '../../api'; import type { ToolResult } from '../../api'; import type { ReportData } from '../../../../../cli/lib/report'; -import { useEffect, useState } from 'react'; -import { Icon } from '../../ui/icons'; +import { type ReactNode, useEffect, useState } from 'react'; +import { Icon, type IconName, StoreGlyph } from '../../ui/icons'; import { OpenLink } from '../../ui/OpenLink'; -import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit'; +import { Bar, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit'; +import { EmptyState, Section, StatusPill } from '../../ui/ds'; +import '../../styles/checks.css'; import { exportName, validateMd } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; @@ -35,8 +37,27 @@ function FixButton({ action, what, w }: { action: FixAction; what: string; w: Re } const SEV: Record = { blocker: ['bad', 'alertCircle'], high: ['bad', 'alert'], medium: ['warn', 'alert'], low: ['info', 'info'] }; +const SEV_ORDER: ScanSeverity[] = ['blocker', 'high', 'medium', 'low']; -/** The `storebound scan` card: rejection risks with guideline, evidence file:line, fix and skill. */ +/** One check as a card: tone icon tile, title, detail lines, the fix, and its actions on the right. */ +function CheckCard({ tone, icon, title, children, actions, tags }: { tone: 'bad' | 'warn' | 'ok' | 'info'; icon: IconName; title: ReactNode; children?: ReactNode; actions?: ReactNode; tags?: ReactNode }) { + return ( +
+ +
+
{title}
+ {children} +
+
+ {tags &&
{tags}
} + {actions &&
{actions}
} +
+
+ ); +} + +/** The `storebound scan` section: rejection risks with guideline, evidence file:line, fix and skill; open risks first, + * accepted ones collapsed. */ function ScanCard({ project }: { project: Project }) { const s = useRead(project.id, 'scan', { network: false, title: t('checks.scan') }); const w = useWrite(project.id); @@ -57,43 +78,49 @@ function ScanCard({ project }: { project: Project }) { }, [project.id, firstAi, claude]); const sdks = Array.isArray(s.data?.sdks) ? s.data!.sdks : []; const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length; + const keyed = rows.map(f => ({ f, k: keyOf(f) })); + const open = keyed.filter(x => !x.f.accepted).sort((a, b) => SEV_ORDER.indexOf(a.f.severity) - SEV_ORDER.indexOf(b.f.severity)); + const accepted = keyed.filter(x => !!x.f.accepted); + const card = ({ f, k }: { f: ScanFinding; k: string }) => { + const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low; + const ev = Array.isArray(f.evidence) ? f.evidence : []; + return ( + {f.guideline}{f.title}} + tags={<>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters[0])}{f.store === 'apple' ? 'App Store' : 'Google Play'}} + actions={<>{!f.accepted && }{!f.accepted && fixable(f.action) && }}> + {ev.slice(0, 2).map((e, j) => {e.file}:{e.line}{e.text ? ` ${e.text}` : ''})} + {f.accepted ? {t('checks.accepted', { reason: f.accepted })} + : {f.fix} {f.skill}} + {!f.accepted && f.aiFix && !fixable(f.action) &&
{ s.refresh(); }} + stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />
} +
+ ); + }; return ( - x.name).join(', ') }) : t('checks.scanSub')} pad={false} +
x.name).join(', ') }) : t('checks.scanSub')} actions={}> - {s.error &&
} - {!s.data ? (s.loading && !s.error ?
: null) - : !rows.length ? {t('checks.scanCleanText')} : ( -
- {rows.map(f => { - const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low; - const ev = Array.isArray(f.evidence) ? f.evidence : []; - return ( -
- -
- {f.guideline} · {f.title} - {ev.slice(0, 2).map((e, j) => {e.file}:{e.line}{e.text ? ` ${e.text}` : ''})} - {f.accepted ? {t('checks.accepted', { reason: f.accepted })} - : {f.fix} {f.skill}} - {!f.accepted && } - {!f.accepted && f.aiFix && !fixable(f.action) && { s.refresh(); }} - stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />} -
- {!f.accepted && fixable(f.action) && } - {t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters[0])} - {f.store === 'apple' ? 'App Store' : 'Google Play'} -
- ); - })} +
+ {s.error && } + {!s.data ? (s.loading && !s.error ? : null) + : !rows.length ? {t('checks.scanClean')}
{t('checks.scanCleanText')}
: ( + <> + {open.map(card)} + {accepted.length > 0 && ( +
+ {t('v2ins.cAccepted', { count: accepted.length })} +
{accepted.map(card)}
+
+ )} + + )} + {s.data && (asks > 0 || s.data.declarations?.changed) && ( +
+ {asks > 0 &&
{t('checks.scanAsk', { count: asks })}
} + {s.data.declarations?.changed &&
{t('checks.scanWrite')}
}
)} - {s.data && (asks > 0 || s.data.declarations?.changed) && ( -
- {asks > 0 &&
{t('checks.scanAsk', { count: asks })}
} - {s.data.declarations?.changed &&
{t('checks.scanWrite')}
} -
- )} - +
+
); } @@ -114,10 +141,10 @@ function ReportBar({ project }: { project: Project }) { finally { setBusy(false); } }; return ( - {t('checks.reportOpen')}}> - {null} - +
+ {t('checks.report')}{last ? reportSummary(last) : t('checks.reportSub')} + +
); } @@ -143,46 +170,77 @@ export function ChecksTab({ project }: { project: Project }) { const problems = [...(e?.errors ?? []), ...(e?.check?.problems ?? [])]; const warnings = e?.check?.warnings ?? []; const findings = [...(v.data?.findings ?? [])].sort((a, b) => ['blocker', 'warn', 'ok'].indexOf(a.level) - ['blocker', 'warn', 'ok'].indexOf(b.level)); + const vBlock = findings.filter(f => f.level === 'blocker'), vWarn = findings.filter(f => f.level === 'warn'), vOk = findings.filter(f => f.level === 'ok'); + const blockers = problems.length + vBlock.length; + const warns = warnings.length + vWarn.length; + const total = findings.length + problems.length + warnings.length; + const passed = vOk.length; + const checked = !!v.data && !!e; + const state: 'bad' | 'warn' | 'ok' | 'neutral' = blockers ? 'bad' : !checked ? 'neutral' : warns ? 'warn' : 'ok'; + const headline = blockers ? t('v2ins.cBlocked', { count: blockers }) : !checked ? t('v2ins.cNotRun') : t('v2ins.cReady'); + const finding = (f: Finding, i: number) => { + const [tone, icon] = LEVEL[f.level]; + return ( + {f.store === 'apple' ? 'App Store' : 'Google Play'}} + actions={<>{f.level !== 'ok' && fixable(f.action) && }}> + {f.fix && {f.fix}} + + ); + }; return ( -
-
-
- d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}>{t('checks.again')}}> - {!e ?
: !problems.length && !warnings.length ? ( - {t('checks.cleanText')} - ) : ( -
- {problems.map((p, i) =>
{p}{t('checks.problem')}
)} - {warnings.map((w, i) =>
{w}{t('checks.warning')}
)} +
+
+
+ +
+ {t('v2ins.cScore')} +

{headline}

+

{v.data ? t('v2ins.cScoreSub', { ok: passed, total }) : t('checks.validateSub')}{v.data ? ` · ${v.fromCache ? t('common.cachedFrom', { when: ago(v.at) }) : t('common.readWhen', { when: ago(v.at) })}` : ''}

+ {total > 0 && }
- )} - - }> - {v.error &&
} - {!v.data ? ( - v.loading && !v.error ?
: {t('checks.validateEmptyText')} - ) : !findings.length ? {t('checks.readyText')} : ( -
- {findings.map((f, i) => { - const [tone, icon] = LEVEL[f.level]; - return ( -
- -
- {f.msg} - {f.fix && {f.fix}} -
- - {f.level !== 'ok' && fixable(f.action) && } - {f.store === 'apple' ? 'App Store' : 'Google Play'} -
- ); - })} +
+
+ {blockers}{t('v2ins.cBlockers')} + {warns}{t('v2ins.cWarnings')} + {passed}{t('v2ins.cPassedN')} +
+
+ + +
+
+ {v.error && } + +
+
+ {!e ? : null} + {problems.map((p, i) => {t('checks.problem')})} + {vBlock.map(finding)} + {!v.data && !v.error && (v.loading ? : {t('checks.validateEmpty')}
{t('checks.validateEmptyText')}
)} + {e && v.data && !blockers && {t('checks.ready')}
{t('checks.readyText')}
} +
+
+ + + + {(warnings.length > 0 || vWarn.length > 0) && ( +
+
+ {warnings.map((x, i) => {t('checks.warning')})} + {vWarn.map(finding)}
- )} - +
+ )} + + {vOk.length > 0 && ( +
+ {t('v2ins.cPassed', { count: vOk.length })} +
{vOk.map(finding)}
+
+ )} + +
); } diff --git a/apps/desktop/src/screens/tabs/Growth.tsx b/apps/desktop/src/screens/tabs/Growth.tsx index 7849f96..e4bbcc9 100644 --- a/apps/desktop/src/screens/tabs/Growth.tsx +++ b/apps/desktop/src/screens/tabs/Growth.tsx @@ -14,7 +14,9 @@ import { useNav } from '../../nav'; import { useData, useRead, useWrite } from '../../store'; import { useExports } from '../../ui/Exports'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit'; +import { ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit'; +import { EmptyState, Section, StatTile, StatusPill, tintStyle, useTint } from '../../ui/ds'; +import '../../styles/growth.css'; import { LineChart } from '../../ui/LineChart'; import { RankChart } from '../../ui/RankChart'; import { WriteButton } from '../../ui/WriteButton'; @@ -43,21 +45,39 @@ function Analytics({ projectId, hasApple, hasPlay, name, playReports }: { projec ); const sum = (xs: number[]) => xs.reduce((n, v) => n + v, 0); const reach = s ? sum(s.reach) : 0, inst = s ? sum(s.installs) : 0; + // deltas: the later half of the window against the earlier half (same number of days), in percent + const half = s ? Math.floor(s.dates.length / 2) : 0; + const change = (xs: number[]): number | null => { + if (half < 1) return null; + const before = sum(xs.slice(0, half)), after = sum(xs.slice(-half)); + return before ? Math.round(((after - before) / before) * 1000) / 10 : null; + }; + const convChange = (): number | null => { + if (!s || half < 1) return null; + const rb = sum(s.reach.slice(0, half)), ra = sum(s.reach.slice(-half)); + if (!rb || !ra) return null; + const cb = sum(s.installs.slice(0, half)) / rb, ca = sum(s.installs.slice(-half)) / ra; + return Math.round((ca - cb) * 1000) / 10; + }; + const deltaSub = half ? t('v2ins.gVsPrev', { count: half }) : t('gr.days', { count: s?.dates.length ?? 0 }); const effects = (a?.effects ?? []).filter(e => e.store === st).slice(-8).reverse(); return ( - {stores.length > 1 && }{actions}}> {none ? ( - {t('gr.noneText')}{hasPlay && !playReports ? ` ${t('gr.noBucket')}` : ''} - ) : r.error ?
: !a || !s ?
: !s.dates.length ? ( - {a.errors?.[st] ?? t('gr.noDataText')}{st === 'play' && !playReports ? ` ${t('gr.noBucket')}` : ''} + {t('gr.noneTitle')}
{t('gr.noneText')}{hasPlay && !playReports ? ` ${t('gr.noBucket')}` : ''}
+ ) : r.error ? : !a || !s ? : !s.dates.length ? ( + {t('gr.noDataTitle', { store: st === 'apple' ? 'App Store' : 'Google Play' })}
{a.errors?.[st] ?? t('gr.noDataText')}{st === 'play' && !playReports ? ` ${t('gr.noBucket')}` : ''}
) : ( -
-
- - - +
+
+ + + + +
+

{t('gr.days', { count: s.dates.length })} · {t('gr.conversionSub')}

{s.dates.length > 1 ? ( ) : {t('gr.oneDay')}} {effects.length > 0 && ( - +
{effects.map((e, i) => { @@ -79,17 +99,17 @@ function Analytics({ projectId, hasApple, hasPlay, name, playReports }: { projec - + ); })} -
{t('gr.change')}{t('col.date')}{t('gr.before')}{t('gr.after')}{t('col.diff')}
{date(e.event.date)} {pct(e.before.conversion ?? null, 1)}
{t('gr.daysN', { count: e.daysBefore })}
{pct(e.after.conversion ?? null, 1)}
{t('gr.daysN', { count: e.daysAfter })}
{dlt === null ? '–' : = 0 ? 'ok' : 'warn'} dot={false}>{`${dlt >= 0 ? '+' : ''}${num(dlt, 1)} %`}}{dlt === null ? '–' : = 0 ? 'ok' : 'warn'}>{`${dlt >= 0 ? '+' : ''}${num(dlt, 1)} %`}}
+
)} -

{t('gr.reading')}

+

{t('gr.reading')}

)} - + ); } @@ -106,20 +126,20 @@ function Aso({ projectId }: { projectId: string }) { ranks: a.ranks.series.find(s => s.storefront === row.storefront && s.keyword === row.keyword)?.ranks ?? [], })) : []; return ( - }> - {r.error ?
: !a ?
: ( -
-
- : !a ? : ( +
+
+ - - +
{a.ranks.dates.length > 1 && lines.length > 0 && } {a.ranks.rows.length > 0 && ( - +
{a.ranks.rows.slice(0, 8).map(x => ( @@ -127,25 +147,25 @@ function Aso({ projectId }: { projectId: string }) { - + ))} -
{t('gr.asoKeyword')}{t('col.store')}{t('gr.asoRank')}{t('col.diff')}{t('gr.asoBest')}
{x.keyword} {x.storefront} {x.rank === null ? '–' : `#${x.rank}`}{x.change === null ? x.arrow : `${x.arrow} ${x.change > 0 ? '+' : ''}${x.change}`}{x.change === null ? x.arrow : `${x.arrow} ${x.change > 0 ? '+' : ''}${x.change}`} {x.best === null ? '–' : `#${x.best}`}
+
)} {a.next.length > 0 && ( -
-
{t('gr.asoNext')}
-
    - {a.next.map((x, i) =>
  1. {x.what}
    {x.why}{x.command ? <> · {x.command} : null}
  2. )} +
    +
    {t('gr.asoNext')}
    +
      + {a.next.map((x, i) =>
    1. {i + 1}{x.what}{x.why}{x.command ? <> · {x.command} : null}
    2. )}
    )} -

    {t('gr.asoReading')}

    +

    {t('gr.asoReading')}

)} - + ); } @@ -164,21 +184,22 @@ function Experiments({ app }: { app: UnifiedApp }) { const editable = tests.find(x => !started(x) && !DONE.includes(x.state)); const asc = app.ios ? ascUrl(app.ios.app.id) : ascUrl(d.statusFor(app)?.app?.platforms.ios?.ascAppId); return ( - {r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}} pad={false} +
{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}} actions={<> }> - {r.error &&
} - {!r.data ? (r.loading && !r.error ?
: {t('gr.ppoNotReadText')}) - : !tests.length ? {variants.length ? t('gr.ppoNoneText') : t('gr.ppoNoVariants')} : ( - +
+ {r.error && } + {!r.data ? (r.loading && !r.error ? : {t('gr.ppoNotRead')}
{t('gr.ppoNotReadText')}
) + : !tests.length ? {t('gr.ppoNone')}
{variants.length ? t('gr.ppoNoneText') : t('gr.ppoNoVariants')}
: ( +
{tests.map(x => ( - + @@ -190,10 +211,10 @@ function Experiments({ app }: { app: UnifiedApp }) { ))} -
{t('gr.test')}{t('gr.state')}{t('gr.traffic')}{t('gr.dates')}{t('gr.treatments')}{t('gr.owner')}
{x.name}{x.state.replace(/_/g, ' ').toLowerCase()}{x.state.replace(/_/g, ' ').toLowerCase()} {x.trafficProportion !== undefined ? `${x.trafficProportion} %` : '–'} {x.startDate ? `${date(x.startDate)} → ${x.endDate ? date(x.endDate) : t('gr.running')}` : t('gr.notStarted')} {x.treatments.length ? x.treatments.map(tr => t('gr.treatment', { name: tr.name, count: tr.locales })).join(' · ') : '–'}
+
)} {app.project ? ( -
+
{!variants.length ? {t('gr.ppoNoVariants')} : (
@@ -215,37 +236,39 @@ function Experiments({ app }: { app: UnifiedApp }) { )}

{t('gr.ppoNote')}

- ) :
{t('gr.ppoStoreOnly')}
} - + ) :
{t('gr.ppoStoreOnly')}
} +
+ ); } function PlayExperiments({ app }: { app: UnifiedApp }) { const nav = useNav(); return ( - {t('gr.playExpSub')}} +
{t('gr.playExpSub')}} actions={}> -
-

{t('gr.playExpText')}

+
+

{t('gr.playExpText')}

{app.project &&
}
- +
); } export function GrowthTab({ app }: { app: UnifiedApp }) { const d = useData(); + const hue = useTint(d.iconFor(app), app.name); const s = d.statusFor(app)?.summary; const hasApple = !!(s?.apple ?? app.ios), hasPlay = !!(s?.play ?? app.android); - if (app.project && !s) return ; + if (app.project && !s) return
; return ( - <> +
{app.project && } {app.project && (hasApple || hasPlay) && } -
+
{hasApple && } {hasPlay && }
- +
); } diff --git a/apps/desktop/src/screens/tabs/Health.tsx b/apps/desktop/src/screens/tabs/Health.tsx index 9986790..846e64e 100644 --- a/apps/desktop/src/screens/tabs/Health.tsx +++ b/apps/desktop/src/screens/tabs/Health.tsx @@ -9,31 +9,56 @@ import { api } from '../../api'; import { appleSignatureView, hintFor, type IssueView, issueMarkdown, playIssueView } from '../../lib/issues'; import type { IssueSample } from '../../lib/model'; import { useData, useRead } from '../../store'; -import { Icon } from '../../ui/icons'; -import { Bar, Card, Chip, Empty, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit'; +import { Icon, type IconName, StoreGlyph } from '../../ui/icons'; +import { Bar, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit'; +import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds'; +import '../../styles/health.css'; import { exportName, healthMd } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; import { StoreReadNotice } from '../../ui/StoreError'; -function Metric({ title, s }: { title: string; s: Series }) { +const toneWord = (tone: Series['tone']): string => (tone === 'bad' ? t('health.over') : tone === 'warn' ? t('health.near') : tone === 'ok' ? t('health.under') : t('common.unknown')); + +/** One big metric tile: the 28-day value, its state against the threshold, a large daily sparkline and its axis. */ +function Metric({ title, s, icon }: { title: string; s: Series; icon: IconName }) { return ( - {s.tone === 'bad' ? t('health.over') : s.tone === 'warn' ? t('health.near') : s.tone === 'ok' ? t('health.under') : t('common.unknown')}}> -
-
-
-
{pct(s.current)}
-
{t('health.current28')}
-
-
- {t('health.daily')} - {t('health.threshold', { value: pct(s.threshold) })} -
-
- -
{date(s.dates[0])}{date(s.dates[Math.floor(s.dates.length / 2)])}{date(s.dates.at(-1))}
+
+
+ {title} + {toneWord(s.tone)}
- +
+ {pct(s.current)} + {t('health.current28')} +
+ +
{date(s.dates[0])}{date(s.dates[Math.floor(s.dates.length / 2)])}{date(s.dates.at(-1))}
+
+ {t('health.daily')} + {t('health.threshold', { value: pct(s.threshold) })} + {t('health.window', { from: date(s.dates[0]), to: date(s.dates.at(-1)) })} +
+
+ ); +} + +/** Launch (startup) time from the App Store power and performance metrics, as the third big tile. */ +function Startup({ h }: { h: AppleHealth }) { + const m = h.metrics.find(x => /launch/i.test(`${x.category} ${x.metric}`)); + if (!m) return null; + const tone = m.goal === 'poor' ? 'bad' : m.goal === 'fair' ? 'warn' : m.goal ? 'ok' : 'neutral'; + return ( +
+
+ {t('v2ins.hStartup')} + {m.goal ? {m.goal} : null} +
+
+ {num(m.value, m.value < 10 ? 2 : 0)}{m.unit} + {m.metric} +
+
{t('v2ins.hStartupSub', { version: m.version })}
+
); } @@ -76,66 +101,58 @@ function IssueDetail({ app, v }: { app: UnifiedApp; v: IssueView }) { ); } -function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) { - const [open, setOpen] = useState(null); - const crash = healthSeries(h.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); - const anr = healthSeries(h.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr); - const devices = (h.devices ?? []).slice(0, 6); - const worst = Math.max(PLAY_THRESHOLDS.perDevice, ...devices.map(x => Math.max(x.crash ?? 0, x.anr ?? 0))); +/** An issue as a card: a header button (type, title, where, counts) that opens its detail below (Enter / Space). */ +function IssueCard({ open, onToggle, tone, kind, title, where, stats, detail }: { open: boolean; onToggle: () => void; tone: 'bad' | 'warn' | 'info'; kind: string; title: string; where?: string; stats: React.ReactNode; detail: React.ReactNode }) { return ( - <> -
- - -
-
- - {devices.length ? ( -
- {devices.map(x => { - const v = Math.max(x.crash ?? 0, x.anr ?? 0); - return ( -
- {x.model} - = PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} /> - {pct(v)} -
- ); - })} -
- ) :

{t('health.noDevices')}

} -
- - {h.issues.length ? ( - - - - {h.issues.slice(0, 6).map((i, n) => ( - setOpen(open === n ? null : n)} cols={3} - head={<>
{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}{i.cause || '?'}
-
{i.location}
} - cells={<>
} - detail={} /> - ))} - -
{t('col.issue')}{t('col.reports')}{t('col.users')}
{num(i.reports)}{num(i.users)}
- ) : } -
-
- +
+ + {open &&
{detail}
} +
); } -/** A table row that opens its detail row below it (click or Enter / Space). */ -function IssueRows({ open, onToggle, head, cells, detail, cols }: { open: boolean; onToggle: () => void; head: React.ReactNode; cells: React.ReactNode; detail: React.ReactNode; cols: number }) { +function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) { + const [open, setOpen] = useState(null); + const devices = (h.devices ?? []).slice(0, 6); + const worst = Math.max(PLAY_THRESHOLDS.perDevice, ...devices.map(x => Math.max(x.crash ?? 0, x.anr ?? 0))); return ( - <> - { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }} tabIndex={0} aria-expanded={open} role="button"> -
{head}
- {cells} - - {open && {detail}} - +
+
+ {h.issues.length ? ( +
+ {h.issues.slice(0, 6).map((i, n) => ( + setOpen(open === n ? null : n)} tone={i.type === 'ANR' ? 'warn' : 'bad'} + kind={i.type === 'ANR' ? 'ANR' : t('health.crashShort')} title={i.cause || '?'} where={i.location} + stats={<>{num(i.reports)}{t('col.reports')}{num(i.users)}{t('col.users')}} + detail={} /> + ))} +
+ ) : {t('health.noIssues')}} +
+
+ {devices.length ? ( +
+ {devices.map(x => { + const v = Math.max(x.crash ?? 0, x.anr ?? 0); + return ( +
+ {x.model} + = PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} /> + {pct(v)} +
+ ); + })} +
+ ) : {t('health.noDevices')}} +
+
); } @@ -143,70 +160,83 @@ function Apple({ h, app }: { h: AppleHealth; app: UnifiedApp }) { const [open, setOpen] = useState(null); const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count)); return ( -
- - {h.metrics.length ? ( - - - +
+
+ {h.signatures.length ? ( +
+ {h.signatures.slice(0, 5).map((s, i) => ( + setOpen(open === i ? null : i)} tone="bad" kind={s.type} + title={s.signature || s.type} where={t('health.build', { build: s.build })} + stats={{num(s.weight * 100, 1)}%} + detail={} /> + ))} +
+ ) : {t('health.noSignatures')}} +
+
+
+ {h.metrics.length ? ( +
{h.metrics.slice(0, 8).map((m, i) => ( -
- +
+ {m.metric}{m.category} · {m.version} + {num(m.value, m.value < 10 ? 2 : 0)} {m.unit} + {m.goal ? {m.goal} : –} +
))} - -
{t('col.metric')}{t('col.value')}{t('col.version')}{t('col.goal')}
{m.metric}
{m.category}
{num(m.value, m.value < 10 ? 2 : 0)} {m.unit}{m.version}{m.goal ? {m.goal} : '–'}
- ) : } -
-
- - {h.signatures.length ? ( - - - {h.signatures.slice(0, 5).map((s, i) => ( - setOpen(open === i ? null : i)} cols={2} - head={<>
{s.signature || s.type}
{s.type} · {t('health.build', { build: s.build })}
} - cells={
} - detail={} /> - ))} - -
{num(s.weight * 100, 1)}%
- ) : } -
- +
+ ) : {t('health.noMetrics')}} + +
{h.betaCrashes.length ? ( -
{h.betaCrashes.slice(0, 5).map(b => ( -
{t('health.build', { build: b.build })}{num(b.count)}
+
{h.betaCrashes.slice(0, 5).map(b => ( +
{t('health.build', { build: b.build })}{num(b.count)}
))}
- ) :

{t('health.noBetaCrashes')}

} - + ) : {t('health.noBetaCrashes')}} +
); } export function HealthTab({ app }: { app: UnifiedApp }) { + const d = useData(); + const hue = useTint(d.iconFor(app), app.name); const r = useRead(app.target, 'health', { network: true, title: t('tab.health'), appName: app.name }); useExports('health', [{ id: 'health-md', label: t('export.health'), icon: 'activity', kind: 'md', file: exportName(app.name, 'health'), disabled: r.data ? false : t('export.readFirst'), build: () => healthMd(app.name, r.data!) }]); if (!r.data) { - if (r.loading && !r.error) return ; + if (r.loading && !r.error) return
; return ( - - {t('health.read')}}>{t('health.emptyText')} - {r.error && } - +
+
+ {t('health.read')}}> + {t('health.emptyTitle')}
{t('health.emptyText')} +
+ {r.error && } +
+
); } const h = r.data; + const play = h.play !== undefined && !isError(h.play) ? h.play : null; + const apple = h.apple !== undefined && !isError(h.apple) ? h.apple : null; + const crash = play ? healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash) : null; + const anr = play ? healthSeries(play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr) : null; + const tiles = !!crash || !!(apple && apple.metrics.some(x => /launch/i.test(`${x.category} ${x.metric}`))); return ( - <> -
- - -
-

Google Play

+
+
+ {tiles && ( +
+ {crash && } + {anr && } + {apple && } +
+ )} +

Google Play

{h.play === undefined ? {t('health.noPlay')} : isError(h.play) ? : <>{h.play.issuesError && }} -

App Store

+

App Store

{h.apple === undefined ? {t('health.noApple')} : isError(h.apple) ? : } - +
); } diff --git a/apps/desktop/src/screens/tabs/Listings.tsx b/apps/desktop/src/screens/tabs/Listings.tsx index 44753b1..d4aa4bf 100644 --- a/apps/desktop/src/screens/tabs/Listings.tsx +++ b/apps/desktop/src/screens/tabs/Listings.tsx @@ -1,5 +1,6 @@ // Listings: per language and store, which listing fields are written and within the store limits (from the listing // files in the app folder), and how many screenshots are rendered per language. +import '../../styles/listings.css'; import { useState } from 'react'; import type { ListingField, ListingLocale, ListingsResult, Project } from '../../api'; import { type Key, t } from '../../i18n'; @@ -7,7 +8,8 @@ import { ago, num } from '../../lib/format'; import { useData, useRead, useWrite } from '../../store'; import { Icon, StoreGlyph } from '../../ui/icons'; import { WriteButton } from '../../ui/WriteButton'; -import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, Skeleton } from '../../ui/kit'; +import { Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; +import { EmptyState, Section, StatTile, StatusPill } from '../../ui/ds'; import { reads } from '../../api'; import { exportName, listingsMd } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; @@ -39,25 +41,41 @@ function Ring({ value }: { value: number }) { ); } -function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: ListingLocale[] }) { - const fields = locales.find(l => l[store])?.[store]?.fields.map(f => f.field) ?? []; - const done = (l: ListingLocale) => { - const fs = l[store]?.fields.filter(f => f.required) ?? []; - return fs.length ? fs.filter(f => f.state === 'ok').length / fs.length : 0; - }; +/** One language as the store page shows it: the fields in store order, each with its length against the limit. */ +function LangPage({ store, l }: { store: 'play' | 'apple'; l: ListingLocale }) { + const fs = l[store]?.fields ?? []; + const req = fs.filter(f => f.required); + const v = req.length ? req.filter(f => f.state === 'ok').length / req.length : 0; + const bad = fs.filter(f => f.state === 'missing' || f.state === 'over').length; return ( - -
-
{t('col.language')}{fields.map(f => {t(FIELD_KEY[f] ?? 'lst.title')})}{t('lst.complete')}
- {locales.map(l => ( -
-
{l.lang}{l[store]?.code ?? ''}{l.rtl && RTL}
- {fields.map(f => x.field === f)} />)} -
+
= 1 ? ' done' : ''}`} data-lang={l.lang} aria-label={`${store === 'play' ? 'Google Play' : 'App Store'} ${l.lang}`}> +
+ {l.lang} + {l[store]?.code ?? ''} + {l.rtl && RTL} + + {bad > 0 ? {t('v2lst.toFix', { count: bad })} : v >= 1 ? {t('v2lst.ready')} : null} + +
+
+ {fs.map(f => ( +
+ {t(FIELD_KEY[f.field] ?? 'lst.title')} +
))}
- +
+ ); +} + +function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: ListingLocale[] }) { + return ( +
{store === 'play' ? 'Google Play' : 'App Store'}} icon="text" sub={store === 'play' ? t('lst.playSub') : t('lst.appleSub')}> +
+ {locales.filter(l => l[store]).map(l => )} +
+
); } @@ -71,7 +89,7 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] }) const ios = !!e?.app?.platforms.ios, android = !!e?.app?.platforms.android; const only = lang ? { lang } : {}; return ( - +
{android && (
@@ -99,7 +117,7 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] })

{t('act.note')}

- +
); } @@ -110,7 +128,7 @@ export function ListingsTab({ project }: { project: Project }) { build: async () => { const x = await reads.listingText(project.id); if (!x.data) throw new Error(x.output || t('export.failed')); return listingsMd(project.name, x.data); } }]); if (!r.data) { if (r.error) return ; - return ; + return
; } const L = r.data; const missing = L.locales.reduce((n, l) => n + [...(l.play?.fields ?? []), ...(l.apple?.fields ?? [])].filter(f => f.state === 'missing' || f.state === 'over').length, 0); @@ -118,20 +136,19 @@ export function ListingsTab({ project }: { project: Project }) { const shots = L.locales.reduce((n, l) => n + (l.screenshots ?? 0), 0); const noFiles = !L.files.play && !L.files.apple; return ( - <> -
- l.lang).join(' · ')} /> - - - l.screenshots !== null) ? shots : '–'} sub={L.locales.some(l => l.screenshots !== null) ? t('lst.screenshotsSub') : t('lst.noRenders')} /> +
+
+ l.lang).join(' · ')} /> + + + l.screenshots !== null) ? shots : '–'} sub={L.locales.some(l => l.screenshots !== null) ? t('lst.screenshotsSub') : t('lst.noRenders')} />
{L.problems.map((p, i) => {p})} {!noFiles && l.lang)} />} {noFiles ? ( - - {t('lst.noFilesText')} -
storebound init{t('lst.noFilesHint')}
-
+
+ storebound init{t('lst.noFilesHint')}
}>{t('lst.noFilesTitle')} {t('lst.noFilesText')} + ) : ( <> {L.files.play && } @@ -139,6 +156,6 @@ export function ListingsTab({ project }: { project: Project }) { )}

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

- +
); } diff --git a/apps/desktop/src/screens/tabs/LiveArt.tsx b/apps/desktop/src/screens/tabs/LiveArt.tsx index 92866fd..f86dbea 100644 --- a/apps/desktop/src/screens/tabs/LiveArt.tsx +++ b/apps/desktop/src/screens/tabs/LiveArt.tsx @@ -2,14 +2,16 @@ // thumbnails (art.thumb: one data: URL each, loaded as they scroll into view; the window loads no remote images // itself) and, for an app folder, whether the folder has the same file. "Pull from stores" runs art.pull (dry run, // native dialog, then the files are written: local only, read-only on the stores). Store-only apps: read-only. +import '../../styles/art.css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { api, type ArtThumb, type LiveArtResult, type LiveArtTile, type Project, type ToolResult } from '../../api'; import { t } from '../../i18n'; import { num } from '../../lib/format'; import { useRead, useWrite } from '../../store'; import { Icon } from '../../ui/icons'; -import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; import { WriteButton } from '../../ui/WriteButton'; +import { Section, StatusPill } from '../../ui/ds'; // thumbnails by target + URL, three at a time const cache = new Map(); @@ -56,12 +58,12 @@ function LiveThumb({ target, tile, name }: { target: string; tile: LiveArtTile; const w = tile.w ?? 9, h = tile.h ?? 16; const state = tile.state === 'n/a' ? null : tile.state; return ( -
+
w ? 'portrait' : 'landscape'}`} style={{ ['--ar' as string]: `${w} / ${h}` }} data-live-tile={tile.state}>
{src ? : {src === null && tile.thumb ? <>{t('art.liveNoThumb')} : }}
{name}{tile.w && tile.h ? `${tile.w}×${tile.h}` : ''}
- {state && {t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}} + {state && {t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}}
); } @@ -84,10 +86,10 @@ export function LiveArt({ target, project }: { target: string; project?: Project const actions = <>{pull}; if (!data) { return ( - +
{r.loading && !r.error ?
: { r.refresh(); }}>{t('art.liveRead')}}>{r.error ?? t('art.liveEmptyText')}} - +
); } const total = data.sets.reduce((n, s) => n + s.tiles.length, 0); @@ -95,7 +97,7 @@ export function LiveArt({ target, project }: { target: string; project?: Project const missing = data.sets.reduce((n, s) => n + s.tiles.filter(x => x.state === 'missing').length, 0); const sub = data.stores.map(s => `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.ok ? t('art.liveCount', { count: s.images }) : t('art.liveFailed')}${s.version ? ` · ${s.version}` : ''}`).join(' · '); return ( - +
{data.stores.filter(s => !s.ok).map(s =>
)}
{!project && {t('art.liveReadOnly')}} @@ -120,7 +122,7 @@ export function LiveArt({ target, project }: { target: string; project?: Project {sets.map(s => (

{s.label}{s.lang && {s.lang}/{s.size}}{s.store === 'apple' ? 'App Store' : 'Google Play'}{t('art.count', { count: s.tiles.length })}

-
(s.tiles[0].h ?? 1) ? '260px' : '150px' }}> +
(s.tiles[0].h ?? 1) ? '260px' : '170px' }}> {s.tiles.map(tile => )}
@@ -128,6 +130,6 @@ export function LiveArt({ target, project }: { target: string; project?: Project
)}
{project ? t('art.liveFoot') : t('art.liveFootStoreOnly')}
- +
); } diff --git a/apps/desktop/src/screens/tabs/Prices.tsx b/apps/desktop/src/screens/tabs/Prices.tsx index 4f51cb1..7dc37f3 100644 --- a/apps/desktop/src/screens/tabs/Prices.tsx +++ b/apps/desktop/src/screens/tabs/Prices.tsx @@ -1,6 +1,7 @@ // Prices: what every product costs in every territory on both stores (`prices export --json`, read-only), the price // sheet as a CSV (save it anywhere, or write store/prices.csv for the terminal), and a price sheet compared with the // stores (`prices import --dry --json`) and applied as an owner action: dry run, native dialog, then the real run. +import '../../styles/prices.css'; import { useEffect, useMemo, useState } from 'react'; import { api, errorText, type PricesCurrent, type PricesDiff, type Project } from '../../api'; import { t } from '../../i18n'; @@ -10,7 +11,11 @@ import { changePct, diffSummary, priceMatrix, priceStats, type RegionRow, region import { type ReadState, useData, useRead, useWrite } from '../../store'; import { type ExportItem, useExports, useRunExport } from '../../ui/Exports'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Section, StatTile } from '../../ui/ds'; + +/** A region code as a small badge (the stores use ISO country codes; longer names stay as text). */ +const Region = ({ code }: { code: string }) => {code}; import { WriteButton } from '../../ui/WriteButton'; const GAP = 10; @@ -18,7 +23,7 @@ const GAP = 10; function PriceStack({ r, apple, play }: { r: RegionRow | undefined; apple: boolean; play: boolean }) { if (!r) return –; return ( -
+
= GAP ? (r.gapPct > 25 ? ' gap-bad' : ' gap-warn') : ''}`}> {apple && {r.apple ? money(r.apple.price, r.apple.currency) : '–'}} {play && {r.play ? money(r.play.price, r.play.currency) : '–'}} {r.gapPct !== null && r.gapPct >= GAP && 25 ? 'bad' : 'warn'} dot={false}>{`Δ ${num(r.gapPct, 0)} %`}} @@ -34,7 +39,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff const sum = d ? diffSummary(d.diff, d.changed) : null; const name = sheet.csv ? sheet.csv.split(/[\\/]/).pop() : d?.csv.split(/[\\/]/).pop() ?? 'prices.csv'; return ( - {sum && sum.pairs > 0 && {t('prices.apply', { count: sum.pairs })}} @@ -51,8 +56,8 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff {sum!.down > 0 && {t('prices.dDown', { count: sum!.down })}} {t('prices.dProducts', { list: sum!.products.join(', ') })}
-
- +
+
{rows.slice(0, 200).map((r, i) => { @@ -61,7 +66,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff - + @@ -76,7 +81,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff
{t('prices.applyNote')}
)} - + ); } @@ -109,18 +114,18 @@ export function PricesTab({ project }: { project: Project }) { try { const f = await api.pickPriceSheet(project.id); if (f) setSheet({ csv: f }); } catch (x) { d.toast({ kind: 'bad', title: t('prices.pickFailed'), detail: errorText(x) }); } }; - if (!e) return ; + if (!e) return
; if (!s?.products) { return ( - +
{t('prices.emptyText')}
products: [{'{'} id, type, usd {'}'}]{t('prices.emptyHint')}
- +
); } return ( - <> - +
runExport(exportItem)} disabled={!p}>{t('prices.exportCsv')}}>
@@ -136,33 +141,33 @@ export function PricesTab({ project }: { project: Project }) {

{t('prices.sheetNote')}

- +
{sheet && { cur.refresh(); diff.refresh(); }} />} {cur.error && } {!p ? ( - cur.loading && !cur.error ? : ( - + cur.loading && !cur.error ?
: ( +
{t('prices.readNow')}}>{t('prices.notReadText')} - +
) ) : ( <> -
- - - - `${m.product} (${m.store === 'apple' ? 'App Store' : 'Play'})`).join(', ') : t('prices.kMissingNone')} tone={stats!.missing ? 'bad' : undefined} /> +
+ + + + `${m.product} (${m.store === 'apple' ? 'App Store' : 'Play'})`).join(', ') : t('prices.kMissingNone')} tone={stats!.missing ? 'bad' : undefined} />
- }> -
-
{t('col.item')}{t('col.store')}{t('col.region')}{t('prices.colStarts')}{t('prices.colNow')}{t('prices.colNew')}{t('col.diff')}
{r.product} {r.store === 'apple' ? 'App Store' : 'Google Play'}{r.region} {r.startDate ?? t('prices.now')} {r.from === null ? '–' : money(r.from, r.currency)} {money(r.to, r.currency)}
- {matrix!.regions.map(r => )} +
+
{t('col.item')}{r}
+ {matrix!.regions.map(r => )} {matrix!.rows.map(row => ( setProduct(row.product)}> @@ -174,19 +179,19 @@ export function PricesTab({ project }: { project: Project }) {
{t('col.item')}
App StoreGoogle Play{t('prices.autoLegend')}{t('prices.gapLegend', { pct: GAP })}
- - +
setQ(ev.target.value)} placeholder={t('prices.searchRegion')} aria-label={t('prices.searchRegion')} />
}> -
- +
+
{apple && }{apple && }{play && } {regions.map(r => ( - - + = GAP ? (r.gapPct > 25 ? 'gap-bad' : 'gap-warn') : undefined}> + {apple && } {apple && } {play && } @@ -196,10 +201,10 @@ export function PricesTab({ project }: { project: Project }) {
{t('col.region')}App Store{t('prices.colKind')}Google Play{t('col.priceGap')}
{r.region}
{r.apple ? money(r.apple.price, r.apple.currency) : –}{r.scheduled &&
{t('prices.scheduled', { price: money(r.scheduled.price, r.scheduled.currency), date: r.scheduled.startDate ?? '' })}
}
{r.apple ? {r.apple.kind === 'auto' ? t('prices.auto') : t('prices.manual')} : null}{r.play ? money(r.play.price, r.play.currency) : –}
- +
{p.missing.length > 0 && {t('prices.missingNote', { list: p.missing.map(m => `${m.product} (${m.store === 'apple' ? 'App Store' : 'Google Play'})`).join(', ') })}} )} - +
); } diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index 29a4c53..498306e 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -3,7 +3,7 @@ // resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API // as console plans. Releasing an approved version (manual release) stays in App Store Connect. import { plainError } from '../../../../../cli/lib/google-errors'; -import { useState } from 'react'; +import { type CSSProperties, type ReactNode, useState } from 'react'; import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api'; import { laneGap, storesDiffer } from '../../../../../cli/lib/train'; import type { UnifiedApp } from '../../lib/identity'; @@ -14,8 +14,10 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } import { useData, useRead, useWrite } from '../../store'; import { Icon, StoreGlyph } from '../../ui/icons'; import { RolloutBar } from '../../ui/charts'; -import { Card, Chip, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit'; +import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit'; +import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds'; import { AppleSubmitCard, PlayProductionCard } from './Submit'; +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' }; @@ -39,32 +41,55 @@ function gapText(lane: Lane, version: string, st: AppStatus): { who: string; lin return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') }; } -function LaneRow({ lane, version, st }: { lane: Lane; version: string; st: AppStatus }) { +/** One store's card of a release train: store mark, version (build), the current state as a pill, the five stages + * as a stepper, a big rollout ring when it is rolling out, and that store's rollout controls. */ +function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: AppStatus; app: UnifiedApp }) { const r = lane.release; const gap = lane.at < 0 ? gapText(lane, version, st) : null; + const ph = r ? phaseLabel(r) : null; + const rolling = lane.at === STAGES.length - 1 && lane.share !== null; + const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent'; return ( -
-
- -
-
{storeName(lane.store)}
-
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}
+
+
+ +
+
{storeName(lane.store)}
+
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}
-
- {gap ?
{gap.line}
: STAGES.map((s, i) => { - const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : ''; - const last = s === 'rollout'; - return ( -
- {i < lane.at && } - {last && i === lane.at && lane.share !== null ? ( -
= 1 ? 'ok' : 'accent'} label={t('tl.rolloutAria')} /> - {lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}
- ) : {stageCaption(lane, i)}} -
- ); - })} -
+ {ph && {ph.text}} + + {gap ?
{gap.line}
: ( +
+
    + {STAGES.map((s, i) => { + const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : 'todo'; + const cap = stageCaption(lane, i); + return ( +
  1. + {i < lane.at ? : {i + 1}} + {t(STAGE_KEY[s])} + {cap && {cap}} +
  2. + ); + })} +
+ {rolling && lane.share !== null && ( +
+ +
+ {lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`} + {t('v2rel.ofUsers')} + +
+
+ )} +
+ )} + {r && app.project && } + ); } @@ -122,19 +147,22 @@ function Train({ title, train, tf, tone, app, st }: { title: string; train: Rele // "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title; const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version; + const created = train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null; return ( - {ver}{label}} - sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}> -
-
{t('tl.store')}{STAGES.map(s => {t(STAGE_KEY[s])})}
- {lanes.map(l => )} -
- {lanes.some(l => l.release) && app.project && ( -
- {lanes.map(l => l.release && )} +
+
+
+ {t('v2rel.version')} + {ver}
- )} - + {label} + + {created && {created}} +
+
+ {lanes.map(l => )} +
+
); } @@ -142,22 +170,26 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { const d = useData(); const l = d.liveFor(app); const project = app.project; + const hue = useTint(d.iconFor(app), app.name); const tf = useRead(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight', skip: !app.ios && !d.statusFor(app)?.app?.platforms.ios }); const sum = project ? d.statusOf(project)?.summary : null; const submit = project && (sum?.apple || sum?.play) ? ( -
-

{t('sub.section')}

-
+
+
+

{t('sub.section')}

+

{t('releases.ownerNote')}

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

{t('releases.ownerNote')}

) : null; + const wrap = (children: ReactNode) =>
{children}
; - if (!l && d.portfolioBusy === 'live') return <>{submit}; + if (!l && d.portfolioBusy === 'live') return wrap(<>{submit}); if (!l) { - return ( + return wrap( <> @@ -166,12 +198,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { }>{t('releases.notReadText')} {submit} - + , ); } const history = versionHistory(l.release.all ?? []); const errs = Object.entries(l.storeErrors); - return ( + return wrap( <> {errs.map(([s, msg]) => { const when = l.staleStores?.[s as 'ios']; @@ -179,17 +211,18 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { })} {l.release.next && } {l.release.current && } - {!l.release.next && !l.release.current && {t('releases.noVersionsText')}} + {!l.release.next && !l.release.current &&
{t('releases.noVersions')} {t('releases.noVersionsText')}
} - +
{t('v2rel.versions', { count: history.length })} : undefined}> {history.length ? ( -
- +
+
{history.map(h => ( - + @@ -200,10 +233,10 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
{t('col.version')}App Store{t('col.build')}Google Play{t('col.versionCode')}{t('col.track')}
{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')} {h.ios ? : –} {h.ios?.build ?? '–'} {h.android ? : –}
- ) : } - + ) :
{t('releases.noVersions')}
} +
{submit} - + , ); } diff --git a/apps/desktop/src/screens/tabs/Reviews.tsx b/apps/desktop/src/screens/tabs/Reviews.tsx index 95b5348..d5a8152 100644 --- a/apps/desktop/src/screens/tabs/Reviews.tsx +++ b/apps/desktop/src/screens/tabs/Reviews.tsx @@ -7,10 +7,12 @@ import type { UnifiedApp } from '../../lib/identity'; import { t } from '../../i18n'; import { date, num } from '../../lib/format'; import { draftName, filterReviews, type Review, type ReviewFilter, starStats } from '../../lib/model'; -import { useRead, useWrite } from '../../store'; +import { useData, useRead, useWrite } from '../../store'; import { WriteButton } from '../../ui/WriteButton'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Bar, Card, Chip, Empty, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit'; +import { Bar, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit'; +import { EmptyState, Section, StatusPill, tintStyle, Toolbar, useTint } from '../../ui/ds'; +import '../../styles/reviews.css'; import { exportName, reviewsCsv } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; @@ -29,57 +31,60 @@ function Detail({ r, draft, w, onChanged }: { r: Review; draft: ReplyDraft | und const live = text && INCENTIVE.some(x => x.test(text)) ? t('reviews.incentive') : len > limit ? t('reviews.tooLong', { limit }) : null; const file = draft ? base(draft.file) : ''; return ( - {r.title || t('reviews.noTitle')}} - sub={{r.store === 'apple' ? 'App Store' : 'Google Play'}{r.where ? ` · ${r.where}` : ''} · {date(r.date)}{r.appVersion ? ` · ${t('reviews.version', { v: r.appVersion })}` : ''}}> -
-

{r.body || t('reviews.noText')}

- {r.reply && ( -
- {t('reviews.currentReply')}{r.reply.date ? ` · ${date(r.reply.date)}` : ''} -
{r.reply.text}
-
- )} -
-
-
- {t('reviews.draft')} - {draft && (draft.sent ? {t('reviews.sentOn', { date: date(draft.sent) })} : draft.approved ? {t('reviews.approved')} : {t('reviews.notApproved')})} -
- {!draft ?

{t('reviews.noDraft')}

: draft.sent ? ( -
{draft.reply}
- ) : ( - <> -