- app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the context menu list sections and sub views; old tab ids, section and section/sub links all resolve - Players > Game services (games only): both stores, one row per leaderboard / achievement with its languages and each store's state, Game Center locale map, Play API-disabled state with the console plan - games status --json: items, localeMap, playApiDisabled; inspect summary: games - sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio filter, workspace as a footer icon row - boot check opens places like a person (section, then sub view)
715 lines
68 KiB
TypeScript
715 lines
68 KiB
TypeScript
// DEV-ONLY FIXTURE MODE (VITE_FIXTURES=1). Answers every app command with made-up data built around the two demo apps
|
|
// of this repo (examples/demo-game "Prism Puddle", examples/demo-app "Tidy Lists"), so every screen can be shown and
|
|
// screenshotted without store accounts, in a browser or in `tauri dev`. Nothing here is real store data; the window
|
|
// shows a "DEV FIXTURES" badge. src/api.ts only imports this module behind import.meta.env.DEV, so release builds do
|
|
// not contain it (tests/webview.test.ts checks the built files for FIXTURE_MARKER).
|
|
import { type Account, type AccountCheck, type ConsoleFillResult, type ConsoleForm, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, type DownloadEvent, type DraftsResult, type ListingField, type ListingsResult,
|
|
type PortfolioResult, type Project, type ProjectSummary, type Settings, type StoreApp, type StoreRelease, type Studio, type ToolResult } from '../api';
|
|
import { buildReport } from '../../../../cli/lib/report';
|
|
import { buildApps, type Overrides } from '../lib/identity';
|
|
import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model';
|
|
import { editableFixture, liveFixture } from './editor-fixtures';
|
|
import { serviceDisabledMessage } from './error-fixtures';
|
|
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures';
|
|
import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more';
|
|
import { TOOLBOX } from '../../../../cli/lib/desktop-coverage';
|
|
|
|
/** system.methods for the More tools tab: the toolbox methods with a small schema each (reads end in a read verb). */
|
|
const toolMethods = () => TOOLBOX.flatMap(g => g.methods).map(m => {
|
|
const read = /status|list|devices|check|diff|next_|app_id|info|wait|plan|verify|rank|competitors|suggest|scan|manifest|triage|originality|aso/.test(m);
|
|
return { method: m, title: m.replace('.', ' ').replace(/_/g, ' '), description: `Fixture descriptor of ${m}.`, kind: read ? 'read' : 'write', network: m.startsWith('apple.') || m.startsWith('play.'), destructive: false, json: false,
|
|
args: { type: 'object', additionalProperties: false, properties: m === 'play.upload' ? { track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, name: { type: 'string', description: 'Release name' } } : m === 'agent.install' ? { agent: { type: 'string', enum: ['claude', 'codex'] }, no_mcp: { type: 'boolean' } } : {}, ...(m === 'agent.install' ? { required: ['agent'] } : {}) } };
|
|
});
|
|
|
|
export const FIXTURE_MARKER = 'storebound-dev-fixtures';
|
|
|
|
const HOME = '/home/you';
|
|
const NOW = Date.now();
|
|
const iso = (minutesAgo: number) => new Date(NOW - minutesAgo * 60_000).toISOString();
|
|
const day = (daysAgo: number) => new Date(NOW - daysAgo * 86_400_000).toISOString().slice(0, 10);
|
|
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
|
|
// ---- events ----
|
|
type Cb = (p: unknown) => void;
|
|
const listeners = new Map<string, Set<Cb>>();
|
|
export async function mockListen(event: string, cb: Cb): Promise<() => void> {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event)!.add(cb);
|
|
return () => { listeners.get(event)?.delete(cb); };
|
|
}
|
|
const emit = (event: string, payload: unknown) => { for (const cb of listeners.get(event) ?? []) cb(payload); };
|
|
const cancelled = new Set<string>();
|
|
|
|
// ---- icons: PNGs rendered by tools/fixture-art.ts (Phosphor icons on gradients), served by Vite in dev ----
|
|
const art = (name: string) => new URL(`./art/${name}.png`, import.meta.url).href;
|
|
const ICONS: Record<string, string> = { 'p-prism': art('icon-prism'), 'p-tidy': art('icon-tidy') };
|
|
|
|
// ---- workspace: 3 studios, 2 Play service accounts + 3 App Store Connect teams, 2 app folders, store-only apps ----
|
|
// Placeholder names only (example.com style): no real studio, person, team or id.
|
|
const P1 = '0a1b2c3d4e5f6071', P2 = '1b2c3d4e5f607182', A1 = '2c3d4e5f60718293', A2 = '3d4e5f6071829304', A3 = '4e5f607182930415';
|
|
const S1 = 's-example', S2 = 's-sample', S3 = 's-demo';
|
|
let studios: Studio[] = [
|
|
{ id: S1, name: 'Example Games', color: 'indigo', logo: null, created: iso(60 * 24 * 30) },
|
|
{ id: S2, name: 'Sample Labs', color: 'emerald', logo: null, created: iso(60 * 24 * 20) },
|
|
{ id: S3, name: 'Demo Indie', color: 'amber', logo: null, created: iso(60 * 24 * 9) },
|
|
];
|
|
let projects: Project[] = [
|
|
{ id: 'p-prism', path: `${HOME}/apps/prism-puddle`, name: 'Prism Puddle', playAccount: P1, appleAccount: A1, added: iso(60 * 24 * 12) },
|
|
{ id: 'p-tidy', path: `${HOME}/apps/tidy-lists`, name: 'Tidy Lists', playAccount: P1, appleAccount: A2, added: iso(60 * 24 * 5) },
|
|
];
|
|
let accounts: Account[] = [
|
|
{ id: P1, label: 'Example Games · Play', kind: 'play', created: iso(60 * 24 * 30), studio: S1, profile: 'example-games' },
|
|
{ id: A1, label: 'Example Games · App Store (company)', kind: 'apple', created: iso(60 * 24 * 30), studio: S1, profile: 'example-games' },
|
|
{ id: P2, label: 'Sample Labs · Play', kind: 'play', created: iso(60 * 24 * 20), studio: S2, profile: 'sample-labs' },
|
|
{ id: A2, label: 'Sample Labs · App Store', kind: 'apple', created: iso(60 * 24 * 20), studio: S2, profile: 'sample-labs' },
|
|
{ id: A3, label: 'Demo Indie · App Store (individual)', kind: 'apple', created: iso(60 * 24 * 9), studio: S3, profile: 'demo-indie' },
|
|
];
|
|
if (import.meta.env.VITE_FIXTURES === 'empty') { projects = []; accounts = []; studios = []; }
|
|
|
|
/** Store app icons (tools/fixture-art.ts). */
|
|
const G = {
|
|
orbit: art('icon-orbit'), block: art('icon-block'), lantern: art('icon-lantern'), tycoon: art('icon-tycoon'), racer: art('icon-racer'),
|
|
habit: art('icon-habit'), focus: art('icon-focus'), sketch: art('icon-sketch'), night: art('icon-night'), mood: art('icon-mood'),
|
|
};
|
|
const ios = (id: string, bundleId: string, name: string, icon: string | null, latest: [string, string] | null): StoreApp =>
|
|
({ store: 'ios', id, bundleId, name, platforms: ['IOS'], sku: null, primaryLocale: 'en-US', latest: latest ? { version: latest[0], state: latest[1], platform: 'IOS' } : null, icon });
|
|
const droid = (pkg: string, name: string, icon: string | null): StoreApp => ({ store: 'android', id: pkg, bundleId: pkg, name, platforms: ['ANDROID'], sku: null, primaryLocale: null, latest: null, icon });
|
|
const STORE_APPS: Record<string, StoreApp[]> = {
|
|
// one Play service account invited to two developer accounts: it sees Example Games' and Sample Labs' Play apps
|
|
[P1]: [
|
|
droid('com.example.prismpuddle', 'Prism Puddle', null), droid('com.example.tidylists', 'Tidy Lists', null), droid('com.example.orbithop', 'Orbit Hop', G.orbit),
|
|
droid('com.example.blockgarden', 'Block Garden', G.block), droid('com.example.lantern.words', 'Word Lantern', G.lantern), droid('com.example.pockettycoon', 'Pocket Tycoon', G.tycoon),
|
|
droid('com.example.nightnotes.free', 'Nightnotes Free', G.night), droid('com.example.moodgrid', 'Mood Grid', G.mood),
|
|
],
|
|
[A1]: [
|
|
ios('6400000001', 'com.example.prismpuddle', 'Prism Puddle', null, ['2.4.0', 'WAITING_FOR_REVIEW']), ios('6400000011', 'com.example.orbithop', 'Orbit Hop', G.orbit, ['3.0.4', 'READY_FOR_SALE']),
|
|
ios('6400000012', 'com.example.blockgarden.ios', 'Block Garden', G.block, ['1.2.0', 'IN_REVIEW']), ios('6400000013', 'com.example.wordlantern', 'Word Lantern', G.lantern, ['2.0.1', 'READY_FOR_SALE']),
|
|
ios('6400000014', 'com.example.oldracer', 'Old Racer', G.racer, ['1.9.3', 'REMOVED_FROM_SALE']),
|
|
],
|
|
[A2]: [
|
|
ios('6400000002', 'com.example.tidylists', 'Tidy Lists', null, ['1.5.0', 'PREPARE_FOR_SUBMISSION']), ios('6400000021', 'com.example.habithatch', 'Habit Hatch', G.habit, ['1.0.4', 'READY_FOR_SALE']),
|
|
ios('6400000022', 'com.example.focustimer', 'Focus Timer Mini', G.focus, ['2.2.0', 'READY_FOR_SALE']),
|
|
],
|
|
[A3]: [ios('6400000031', 'com.example.starsketch', 'Star Sketch', G.sketch, ['1.1.0', 'READY_FOR_SALE']), ios('6400000032', 'com.example.nightnotes', 'Night Notes', G.night, ['4.0.0', 'READY_FOR_SALE'])],
|
|
};
|
|
// Sample Labs' Play service account was never invited: no app list, and its check says what to do
|
|
const IMPORTED: Record<string, number> = { [P1]: 60 * 3, [A1]: 60 * 3, [A2]: 60 * 3, [A3]: 60 * 26 };
|
|
let overrides: Overrides = {
|
|
pairs: [{ ios: `ios:${A3}:6400000032`, android: `android:${P1}:com.example.nightnotes.free` }],
|
|
unpairs: [], links: [],
|
|
appStudio: [{ key: `android:${P1}:com.example.moodgrid`, studio: S3 }],
|
|
ignored: [`app:ios:${A1}:6400000014`],
|
|
};
|
|
if (import.meta.env.VITE_FIXTURES === 'empty') overrides = { pairs: [], unpairs: [], links: [], appStudio: [], ignored: [] };
|
|
let settings: Settings = { ...DEFAULT_SETTINGS, refresh: { ...DEFAULT_SETTINGS.refresh, autoMinutes: 60 } };
|
|
const PLAY_INVITE = (who: string) => `Play Console > Users and permissions: https://play.google.com/console > Invite new users: add ${who}. Account permissions: "View app information and download bulk reports (read-only)"; per app, as needed: "Manage store presence", "Manage testing tracks", "Release to production, exclude devices and use Play App Signing" (rollouts), "Reply to reviews", "Manage orders and subscriptions". One service account can be invited to several developer accounts.`;
|
|
const CHECKS: Record<string, AccountCheck> = {
|
|
[P1]: { store: 'android', status: 'ok', principal: 'play-bot@example.com', apps: 8, message: 'The service account can see 8 apps.', fix: null, docs: null },
|
|
[P2]: { store: 'android', status: 'permission', principal: 'sample-labs-bot@example.com', apps: 0, message: 'The key works, but no developer account has invited this service account (it sees no apps).', fix: PLAY_INVITE('sample-labs-bot@example.com'), docs: 'https://play.google.com/console/developers/users-and-permissions' },
|
|
[A1]: { store: 'ios', status: 'ok', principal: null, apps: 5, message: 'The key works and can read the team\'s apps.', fix: null, docs: null },
|
|
[A2]: { store: 'ios', status: 'ok', principal: null, apps: 3, message: 'The key works and can read the team\'s apps.', fix: null, docs: null },
|
|
[A3]: { store: 'ios', status: 'ok', principal: null, apps: 2, message: 'The key works and can read the team\'s apps.', fix: null, docs: null },
|
|
};
|
|
const appsEntry = (id: string): CacheEntry<AppsListResult> | null => (STORE_APPS[id] && IMPORTED[id] !== undefined && import.meta.env.VITE_FIXTURES !== 'cold'
|
|
? { value: { store: accounts.find(a => a.id === id)?.kind === 'play' ? 'android' : 'ios', apps: STORE_APPS[id]!, warnings: [], at: iso(IMPORTED[id]!) }, at: iso(IMPORTED[id]!) } : null);
|
|
/** Store-only apps as the window pairs them (so the cached live read covers them too). */
|
|
const storeTargets = () => buildApps({
|
|
projects, accounts, overrides, summaryOf: p => SUMMARY[p.id],
|
|
storeApps: accounts.map(a => ({ account: a.id, apps: appsEntry(a.id)?.value.apps ?? [] })),
|
|
}).filter(a => !a.project && !a.ignored);
|
|
|
|
const SUMMARY: Record<string, ProjectSummary> = {
|
|
'p-prism': {
|
|
dir: `${HOME}/apps/prism-puddle`, config: `${HOME}/apps/prism-puddle/storebound.config.ts`, name: 'Prism Puddle', languages: ['en', 'tr', 'ja', 'ar'],
|
|
play: { packageName: 'com.example.prismpuddle' }, apple: { bundleId: 'com.example.prismpuddle', appId: '6400000001' }, platforms: ['ios', 'android'],
|
|
products: 4, productIds: ['coins_small', 'coins_large', 'no_ads', 'starter_pack'], subscriptions: 1, subscriptionIds: ['vip_pass_monthly'],
|
|
catalog: { ios: ['coins_small', 'coins_large', 'no_ads', 'starter_pack', 'vip_pass_monthly'], android: ['coins_small', 'coins_large', 'no_ads', 'starter_pack', 'vip_pass_monthly'] },
|
|
shots: true, testflightGroups: 3, profile: 'game', icon: `${HOME}/apps/prism-puddle/store/icons/app-store-1024.png`,
|
|
playRelease: { managedPublishing: null, countries: null, category: 'Puzzle' },
|
|
},
|
|
'p-tidy': {
|
|
dir: `${HOME}/apps/tidy-lists`, config: `${HOME}/apps/tidy-lists/storebound.config.ts`, name: 'Tidy Lists', languages: ['en', 'tr'],
|
|
play: { packageName: 'com.example.tidylists' }, apple: { bundleId: 'com.example.tidylists', appId: '6400000002' }, platforms: ['ios', 'android'],
|
|
products: 1, productIds: ['tip_jar'], subscriptions: 2, subscriptionIds: ['pro_monthly', 'pro_yearly'],
|
|
catalog: { ios: ['tip_jar', 'pro_monthly', 'pro_yearly'], android: ['tip_jar', 'pro_monthly', 'pro_yearly'] },
|
|
shots: true, testflightGroups: 2, profile: 'app', icon: `${HOME}/apps/tidy-lists/store/icons/app-store-1024.png`,
|
|
playRelease: { managedPublishing: true, countries: 12, category: 'Productivity' },
|
|
},
|
|
};
|
|
|
|
const rel = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase'], o: Partial<StoreRelease> = {}): StoreRelease =>
|
|
({ store, version, build: null, state: phase.toUpperCase(), phase, fraction: null, track: store === 'android' ? 'production' : null, ...o });
|
|
|
|
const RELEASES: Record<string, StoreRelease[]> = {
|
|
'p-prism': [
|
|
rel('ios', '2.4.0', 'review', { build: '52', state: 'WAITING_FOR_REVIEW', date: iso(60 * 26) }),
|
|
rel('ios', '2.3.0', 'rollout', { build: '48', state: 'READY_FOR_SALE', fraction: 0.2, phased: { state: 'ACTIVE', day: 5 }, date: iso(60 * 24 * 9) }),
|
|
rel('ios', '2.2.1', 'other', { build: '44', state: 'REPLACED_WITH_NEW_VERSION', date: iso(60 * 24 * 30) }),
|
|
rel('android', '2.4.0', 'testing', { build: '240', state: 'completed', track: 'internal' }),
|
|
rel('android', '2.3.0', 'rollout', { build: '231', state: 'inProgress', fraction: 0.2 }),
|
|
rel('android', '2.2.1', 'live', { build: '221', state: 'completed' }),
|
|
],
|
|
'p-tidy': [
|
|
rel('ios', '1.5.0', 'draft', { build: '31', state: 'PREPARE_FOR_SUBMISSION', date: iso(60 * 20) }),
|
|
rel('ios', '1.4.2', 'live', { build: '29', state: 'READY_FOR_SALE', fraction: 1, phased: { state: 'COMPLETE', day: 7 }, date: iso(60 * 24 * 21) }),
|
|
rel('android', '1.5.0', 'testing', { build: '150', state: 'completed', track: 'beta' }),
|
|
rel('android', '1.4.2', 'live', { build: '142', state: 'completed' }),
|
|
],
|
|
};
|
|
|
|
const train = (all: StoreRelease[], v: string) => ({ version: v, ios: all.find(r => r.store === 'ios' && r.version === v) ?? null, android: all.find(r => r.store === 'android' && r.version === v) ?? null });
|
|
|
|
function status(id: string, live: boolean): AppStatus {
|
|
const s = SUMMARY[id]!;
|
|
const all = RELEASES[id]!;
|
|
const prism = id === 'p-prism';
|
|
return {
|
|
project: s.dir, ok: !prism, summary: s,
|
|
app: { id: s.dir, name: s.name, platforms: { ios: { bundleId: s.apple!.bundleId, ascAppId: s.apple!.appId }, android: { packageName: s.play!.packageName } } },
|
|
check: prism ? { ok: true, problems: [], warnings: ['store/listing.md: ja-JP "what\'s new" is empty', 'store/appstore.md: ar-SA subtitle is 31 characters (limit 30)'] } : { ok: true, problems: [], warnings: [] },
|
|
release: live ? (prism ? { source: 'live', current: train(all, '2.3.0'), next: train(all, '2.4.0'), all } : { source: 'live', current: train(all, '1.4.2'), next: train(all, '1.5.0'), all }) : { source: 'none', current: null, next: null },
|
|
catalog: live
|
|
? (prism ? { checked: 'live', products: 4, subscriptions: 1, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: ['starter_pack'], ok: false }
|
|
: { checked: 'live', products: 1, subscriptions: 2, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [], ok: true })
|
|
: { checked: 'config', products: s.products, subscriptions: s.subscriptions, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [], ok: true },
|
|
storeErrors: {}, errors: [],
|
|
};
|
|
}
|
|
/** Store-only app releases (fixture), by bundle id / package name. */
|
|
const STORE_RELEASES: Record<string, StoreRelease[]> = {
|
|
// the stores do not always move together: Play is on 3.1.0, the App Store still on 3.0.4
|
|
'com.example.orbithop': [rel('ios', '3.0.4', 'live', { build: '88', state: 'READY_FOR_SALE', date: iso(60 * 24 * 6) }), rel('android', '3.1.0', 'rollout', { build: '310', state: 'inProgress', fraction: 0.5 })],
|
|
'com.example.blockgarden.ios': [rel('ios', '1.2.0', 'review', { build: '17', state: 'IN_REVIEW', date: iso(60 * 5) }), rel('ios', '1.1.2', 'live', { build: '15', state: 'READY_FOR_SALE', date: iso(60 * 24 * 40) })],
|
|
'com.example.blockgarden': [rel('android', '1.2.0', 'live', { build: '120', state: 'completed' })],
|
|
'com.example.wordlantern': [rel('ios', '2.0.1', 'live', { build: '41', state: 'READY_FOR_SALE', date: iso(60 * 24 * 18) })],
|
|
'com.example.lantern.words': [rel('android', '2.0.1', 'halted', { build: '201', state: 'halted', fraction: 0.1 })],
|
|
'com.example.pockettycoon': [rel('android', '5.4.0', 'live', { build: '540', state: 'completed' })],
|
|
'com.example.habithatch': [rel('ios', '1.0.4', 'live', { build: '9', state: 'READY_FOR_SALE', date: iso(60 * 24 * 60) })],
|
|
'com.example.focustimer': [rel('ios', '2.2.0', 'live', { build: '30', state: 'READY_FOR_SALE', date: iso(60 * 24 * 3) }), rel('ios', '2.3.0', 'rejected', { build: '31', state: 'REJECTED', date: iso(60 * 10) })],
|
|
'com.example.starsketch': [rel('ios', '1.1.0', 'live', { build: '12', state: 'READY_FOR_SALE', date: iso(60 * 24 * 12) })],
|
|
'com.example.nightnotes': [rel('ios', '4.0.0', 'live', { build: '77', state: 'READY_FOR_SALE', date: iso(60 * 24 * 2) })],
|
|
'com.example.nightnotes.free': [rel('android', '4.0.0', 'rollout', { build: '400', state: 'inProgress', fraction: 0.2 })],
|
|
'com.example.moodgrid': [rel('android', '0.9.0', 'testing', { build: '9', state: 'completed', track: 'beta' })],
|
|
};
|
|
function storeStatus(target: string, live: boolean): AppStatus {
|
|
const ios = /ios:[^:]+:(\d+):([^;]+)/.exec(target), android = /android:[^:]+:([^;]+)/.exec(target);
|
|
const bundle = ios?.[2] ?? null, pkg = android?.[1] ?? null;
|
|
const all = [...(bundle ? STORE_RELEASES[bundle] ?? [] : []), ...(pkg ? STORE_RELEASES[pkg] ?? [] : [])];
|
|
const name = Object.values(STORE_APPS).flat().find(a => a.bundleId === (bundle ?? pkg))?.name ?? bundle ?? pkg ?? '?';
|
|
const versions = [...new Set(all.map(r => r.version!))].sort().reverse();
|
|
const live1 = versions.find(v => all.some(r => r.version === v && ['live', 'rollout', 'halted'].includes(r.phase))) ?? null;
|
|
const next1 = versions.find(v => !live1 || v > live1) ?? null;
|
|
const s: ProjectSummary = { dir: '', config: '', name, languages: ['en'], play: pkg ? { packageName: pkg } : null, apple: bundle ? { bundleId: bundle, appId: ios![1]! } : null,
|
|
platforms: [...(bundle ? ['ios' as const] : []), ...(pkg ? ['android' as const] : [])], products: 0, productIds: [], subscriptions: 0, subscriptionIds: [], catalog: { ios: [], android: [] }, shots: false, testflightGroups: 0 };
|
|
return {
|
|
project: target, ok: true, virtual: true, summary: s, check: { ok: true, problems: [], warnings: [] },
|
|
app: { id: target, name, platforms: { ios: bundle ? { bundleId: bundle, ascAppId: ios![1]! } : null, android: pkg ? { packageName: pkg } : null } },
|
|
release: live ? { source: 'live', current: live1 ? train(all, live1) : null, next: next1 && next1 !== live1 ? train(all, next1) : null, all } : { source: 'none', current: null, next: null },
|
|
catalog: { checked: 'config', products: 0, subscriptions: 0, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [], ok: true }, storeErrors: {}, errors: [],
|
|
};
|
|
}
|
|
const portfolio = (live: boolean, stores: string[] = []): PortfolioResult => ({ apps: [...projects.map(p => status(p.id, live)), ...(live ? stores.map(s => storeStatus(s, true)) : [])], at: live ? iso(14) : iso(0) });
|
|
|
|
// ---- per-app store reads ----
|
|
const series = (n: number, base: number, drift: number, seed: number): number[] =>
|
|
Array.from({ length: n }, (_, i) => Math.max(0.0001, base + drift * (i / n) + Math.sin(i * 1.7 + seed) * base * 0.09 + Math.cos(i * 0.6 + seed) * base * 0.05));
|
|
function rows(metric: 'Crash' | 'Anr', vals: number[], w28: number): MetricRow[] {
|
|
return vals.map((v, i) => ({ date: day(vals.length - i), dims: {}, values: {
|
|
[`userPerceived${metric}Rate`]: v, [`${metric === 'Crash' ? 'crash' : 'anr'}Rate`]: v * 1.4, distinctUsers: 18_000 + i * 140,
|
|
...(i === vals.length - 1 ? { [`userPerceived${metric}Rate28dUserWeighted`]: w28 } : {}),
|
|
} }));
|
|
}
|
|
const HEALTH: Record<string, HealthSnapshot> = {
|
|
'p-prism': {
|
|
date: day(0),
|
|
play: {
|
|
window: { start: day(28), end: day(1), days: 28 },
|
|
crash: rows('Crash', series(28, 0.0068, 0.0032, 1), 0.0091),
|
|
anr: rows('Anr', series(28, 0.0024, 0.0004, 2), 0.0027),
|
|
devices: [{ model: 'Galaxy A12', crash: 0.061, users: 1900 }, { model: 'Redmi 9A', crash: 0.042, users: 1400 }, { model: 'Moto G Power (2021)', crash: 0.019, users: 900 }, { model: 'Pixel 6a', crash: 0.008, users: 2100 }, { model: 'Galaxy S23', crash: 0.004, users: 2600 }],
|
|
issues: [
|
|
{ type: 'CRASH', cause: 'java.lang.IllegalStateException: ComboMeter.update called after the level ended (com.example.prismpuddle.combo.ComboMeter)', location: 'com.example.prismpuddle.combo.ComboMeter.update', reports: 412, users: 268, lastVersion: '231', firstVersion: '224', firstOs: '26', lastOs: '35', lastSeen: iso(95), id: 'a1b2c3', uri: 'https://play.google.com/console/developers/app/vitals/crashes/a1b2c3/details' },
|
|
{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.prismpuddle.MainActivity', reports: 133, users: 120, lastVersion: '231', firstVersion: '229', lastSeen: iso(300), id: 'd4e5f6', uri: 'https://play.google.com/console/developers/app/vitals/crashes/d4e5f6/details' },
|
|
{ type: 'CRASH', cause: 'java.lang.OutOfMemoryError', location: 'android.graphics.Bitmap.createBitmap', reports: 74, users: 61, lastVersion: '221' },
|
|
],
|
|
},
|
|
apple: {
|
|
metrics: [
|
|
{ category: 'Hangs', metric: 'Hang rate', unit: 's/h', version: '2.3.0', value: 1.9, goal: 'fair' },
|
|
{ category: 'Launch', metric: 'Launch time', unit: 's', version: '2.3.0', value: 1.21, goal: 'good' },
|
|
{ category: 'Memory', metric: 'Peak memory', unit: 'MB', version: '2.3.0', value: 412, goal: 'fair' },
|
|
{ category: 'Disk', metric: 'Disk writes', unit: 'MB', version: '2.3.0', value: 38, goal: 'good' },
|
|
{ category: 'Battery', metric: 'Foreground energy', unit: '%/h', version: '2.3.0', value: 9.4, goal: 'poor' },
|
|
],
|
|
signatures: [
|
|
{ build: '48', type: 'HANGS', signature: 'ComboMeter.update(delta:) + 212', weight: 0.31, id: 'sig-hang-1' },
|
|
{ build: '48', type: 'DISK_WRITES', signature: 'SaveStore.flush() + 88', weight: 0.12 },
|
|
{ build: '47', type: 'LAUNCHES', signature: 'AssetCache.warmUp() + 64', weight: 0.07 },
|
|
],
|
|
betaCrashes: [{ build: '52', count: 3 }, { build: '51', count: 9 }, { build: '50', count: 5 }],
|
|
},
|
|
},
|
|
'p-tidy': {
|
|
date: day(0),
|
|
play: { window: { start: day(28), end: day(1), days: 28 }, crash: rows('Crash', series(28, 0.0021, -0.0004, 3), 0.0019), anr: rows('Anr', series(28, 0.0009, 0.0001, 4), 0.001), devices: [{ model: 'Pixel 7', crash: 0.004, users: 800 }, { model: 'Galaxy A54', crash: 0.003, users: 1100 }], issues: [{ type: 'CRASH', cause: 'kotlin.UninitializedPropertyAccessException', location: 'com.example.tidylists.share.ShareSheet', reports: 21, users: 19, lastVersion: '142' }] },
|
|
apple: { metrics: [{ category: 'Launch', metric: 'Launch time', unit: 's', version: '1.4.2', value: 0.62, goal: 'good' }, { category: 'Hangs', metric: 'Hang rate', unit: 's/h', version: '1.4.2', value: 0.2, goal: 'good' }], signatures: [], betaCrashes: [{ build: '31', count: 1 }] },
|
|
},
|
|
};
|
|
|
|
const R = (store: 'apple' | 'play', id: string, rating: number, title: string, body: string, where: string, daysAgo: number, reply?: string): Review =>
|
|
({ store, id, rating, title: title || undefined, body, where, date: new Date(NOW - daysAgo * 86_400_000 - id.length * 3_600_000).toISOString(), appVersion: store === 'play' ? '2.3.0' : undefined, ...(reply ? { reply: { text: reply, date: new Date(NOW - (daysAgo - 1) * 86_400_000).toISOString() } } : {}) });
|
|
const REVIEWS: Record<string, Review[]> = {
|
|
'p-prism': [
|
|
R('apple', 'r-a1', 5, 'Calm and clever', 'The color puzzles are so relaxing. Love that it works offline on the train.', 'USA', 1),
|
|
R('play', 'r-p1', 2, 'Crashes after combo', 'Game closes when I reach a 20x combo on level 31. Lost my streak twice.', 'en', 1),
|
|
R('apple', 'r-a2', 4, 'Great, but ads', 'Really pretty game. The ad after every level is a bit much, I bought no ads.', 'GBR', 2, 'Thank you! Remove Ads turns them off for good, and you can restore it on a new device from Settings.'),
|
|
R('play', 'r-p2', 5, '', 'Çok güzel bir oyun, renkler harika ve müzik çok sakin.', 'tr', 2),
|
|
R('apple', 'r-a3', 1, 'Lost progress', 'Updated and my map progress is gone. Please fix.', 'DEU', 3),
|
|
R('play', 'r-p3', 3, 'Okay', 'Nice idea, later levels get repetitive. More star challenges please.', 'en', 4),
|
|
R('apple', 'r-a4', 5, '最高', '色がきれいで、毎日少しずつ遊んでいます。', 'JPN', 5),
|
|
R('play', 'r-p4', 4, 'Fun', 'Good time killer. Would love a dark mode for night play.', 'en', 5),
|
|
R('apple', 'r-a5', 5, 'Perfect puzzle game', 'No timers, no pressure. Exactly what I wanted.', 'CAN', 8, 'Thank you for playing! More calm levels arrive in 2.4.'),
|
|
R('apple', 'r-a6', 2, 'Hard to read', 'Text on the star map is tiny on my iPad.', 'AUS', 11),
|
|
R('play', 'r-p5', 5, '', 'Beautiful and offline. Five stars from me.', 'en', 6),
|
|
R('apple', 'r-a7', 4, 'Very nice', 'I like the new combo meter. Starter pack price feels high in Turkey.', 'TUR', 13),
|
|
],
|
|
'p-tidy': [
|
|
R('apple', 't-a1', 5, 'Finally simple', 'Shared groceries list with my partner in seconds. No clutter.', 'USA', 1),
|
|
R('play', 't-p1', 4, 'Good', 'Works well. Widgets would be great.', 'en', 2),
|
|
R('apple', 't-a2', 5, 'Harika', 'Ailecek ortak listeleri burada tutuyoruz, çok pratik.', 'TUR', 3, 'Teşekkürler! Widget\'lar 1.5 ile geliyor.'),
|
|
R('play', 't-p2', 3, 'Sync delay', 'Sometimes the shared list takes a minute to update.', 'en', 4),
|
|
R('apple', 't-a3', 5, 'Pro is worth it', 'Unlimited lists and themes. Happy to support a small studio.', 'GBR', 9),
|
|
],
|
|
};
|
|
|
|
const draft = (store: 'apple' | 'play', reviewId: string, reply: string, approved: boolean, issues: DraftsResult['drafts'][number]['issues'] = []) =>
|
|
({ file: `${HOME}/apps/prism-puddle/.storebound/replies/${store}-${reviewId}.md`, store, reviewId, approved, sent: null, reply, rating: null, title: null, body: null, date: null, issues });
|
|
const DRAFTS: Record<string, DraftsResult> = {
|
|
'p-prism': {
|
|
dir: `${HOME}/apps/prism-puddle/.storebound/replies`, problems: [], drafts: [
|
|
draft('play', 'r-p1', 'Sorry about the lost streak! A fix for the crash at high combos is in 2.4.0, now in review. Thank you for the level number, it helped.', true),
|
|
draft('apple', 'r-a3', 'We are sorry! Your progress is saved in iCloud: open Settings > Restore progress. If it is still missing, write to support@example.com.', false,
|
|
[{ level: 'warn', msg: 'contains an email address: only your public support address, never a user\'s' }]),
|
|
draft('play', 'r-p3', '', false),
|
|
draft('apple', 'r-a6', 'Thanks! Change your rating to 5 stars and we will add bigger text with free coins.', false,
|
|
[{ level: 'error', msg: 'offers something for a rating or asks to change / give stars: not allowed (no incentives, no rating manipulation)' }]),
|
|
],
|
|
},
|
|
'p-tidy': { dir: `${HOME}/apps/tidy-lists/.storebound/replies`, problems: [], drafts: [] },
|
|
};
|
|
|
|
const TF: Record<string, TestFlightStatus> = {
|
|
'p-prism': {
|
|
groups: [
|
|
{ id: 'g1', name: 'Studio team', internal: true, testers: 6, builds: 'all', publicLink: false },
|
|
{ id: 'g2', name: 'Friends and family', internal: false, testers: 42, builds: 3, publicLink: true },
|
|
{ id: 'g3', name: 'Press', internal: false, testers: 8, builds: 1, publicLink: false },
|
|
],
|
|
builds: [
|
|
{ version: '2.4.0', build: '52', processing: 'VALID', internal: 'IN_BETA_TESTING', external: 'WAITING_FOR_BETA_REVIEW', uploaded: iso(60 * 28) },
|
|
{ version: '2.4.0', build: '51', processing: 'VALID', internal: 'IN_BETA_TESTING', external: 'BETA_APPROVED', uploaded: iso(60 * 50) },
|
|
{ version: '2.3.0', build: '48', processing: 'VALID', internal: 'IN_BETA_TESTING', external: 'BETA_APPROVED', uploaded: iso(60 * 24 * 10) },
|
|
{ version: '2.3.0', build: '47', processing: 'VALID', internal: 'EXPIRED', external: 'EXPIRED', uploaded: iso(60 * 24 * 12) },
|
|
{ version: '2.2.1', build: '44', processing: 'VALID', internal: 'EXPIRED', external: 'EXPIRED', uploaded: iso(60 * 24 * 31) },
|
|
],
|
|
missingGroups: [],
|
|
},
|
|
'p-tidy': {
|
|
groups: [{ id: 'g4', name: 'Studio team', internal: true, testers: 4, builds: 'all', publicLink: false }, { id: 'g5', name: 'Beta club', internal: false, testers: 118, builds: 2, publicLink: true }],
|
|
builds: [{ version: '1.5.0', build: '31', processing: 'VALID', internal: 'IN_BETA_TESTING', external: 'IN_BETA_TESTING', uploaded: iso(60 * 22) }, { version: '1.4.2', build: '29', processing: 'VALID', internal: 'EXPIRED', external: 'EXPIRED', uploaded: iso(60 * 24 * 22) }],
|
|
missingGroups: ['Accessibility testers'],
|
|
},
|
|
};
|
|
|
|
const CATALOG: Record<string, CatalogDiff> = {
|
|
'p-prism': {
|
|
errors: 1,
|
|
rows: [
|
|
{ id: 'starter_pack', store: 'play', level: 'error', code: 'missing', msg: 'not on Google Play (storebound play products)' },
|
|
{ id: 'vip_pass_monthly', store: 'apple', level: 'warn', code: 'locale', msg: 'no localization for ar-SA' },
|
|
{ id: 'coins_large', store: 'apple', level: 'warn', code: 'price', msg: 'no US price on the store' },
|
|
{ id: 'coins_small', store: 'apple', level: 'ok', code: 'ok', msg: 'consumable, 175 region prices' },
|
|
{ id: 'coins_small', store: 'play', level: 'ok', code: 'ok', msg: 'one_time, 173 region prices' },
|
|
{ id: 'coins_large', store: 'play', level: 'ok', code: 'ok', msg: 'one_time, 173 region prices' },
|
|
{ id: 'no_ads', store: 'apple', level: 'ok', code: 'ok', msg: 'non_consumable, 175 region prices' },
|
|
{ id: 'no_ads', store: 'play', level: 'ok', code: 'ok', msg: 'one_time, 173 region prices' },
|
|
{ id: 'starter_pack', store: 'apple', level: 'ok', code: 'ok', msg: 'consumable, 175 region prices' },
|
|
{ id: 'vip_pass_monthly', store: 'play', level: 'ok', code: 'ok', msg: 'subscription P1M, 173 region prices' },
|
|
{ id: 'legacy_hint_pack', store: 'apple', level: 'warn', code: 'extra', msg: 'on the store but not in the config (consumable, REMOVED_FROM_SALE)' },
|
|
],
|
|
parity: [
|
|
{ id: 'starter_pack', region: 'TR', currency: 'TRY', apple: 149.99, play: 99.99, diffPct: 33.3 },
|
|
{ id: 'no_ads', region: 'BR', currency: 'BRL', apple: 24.9, play: 19.99, diffPct: 19.7 },
|
|
{ id: 'coins_large', region: 'IN', currency: 'INR', apple: 899, play: 780, diffPct: 13.2 },
|
|
],
|
|
},
|
|
'p-tidy': { errors: 0, rows: ['tip_jar', 'pro_monthly', 'pro_yearly'].flatMap(id => (['apple', 'play'] as const).map(store => ({ id, store, level: 'ok' as const, code: 'ok', msg: 'matches' }))), parity: [] },
|
|
};
|
|
|
|
const VALIDATE: Record<string, ValidateResult> = {
|
|
'p-prism': {
|
|
blockers: 1, findings: [
|
|
{ store: 'play', level: 'blocker', code: 'listing', msg: 'ja-JP: missing shortDescription', fix: 'storebound play listing', action: { method: 'play.listing', args: {} } },
|
|
{ store: 'play', level: 'blocker', code: 'product-missing', msg: 'starter_pack is in the config but not on Google Play', fix: 'storebound play products --only starter_pack (dry run first)' },
|
|
{ store: 'apple', level: 'warn', code: 'screenshots-ipad', msg: 'ar-SA has no 13" iPad screenshots', fix: 'storebound shots render --lang ar, then storebound apple screenshots --only ar-SA' },
|
|
{ store: 'apple', level: 'warn', code: 'whats-new', msg: '2.4.0 has no "What\'s New" for ja', fix: 'storebound whats-new --since v2.3.0' },
|
|
// a long unbreakable path (a merged manifest of a local build) must wrap inside the card
|
|
{ store: 'play', level: 'warn', code: 'scan', msg: 'Play: Foreground service types: FOREGROUND_SERVICE is requested but no <service> declares android:foregroundServiceType (apps/native/android/app/build/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml:74)', fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission, then fill Play Console > App content > Foreground service permissions: https://play.google.com/console/u/0/developers/5550000000000000001/app/4970000000000000002/app-content/foreground-services', url: 'https://play.google.com/console/u/0/developers/5550000000000000001/app/4970000000000000002/app-content/foreground-services' },
|
|
],
|
|
},
|
|
'p-tidy': { blockers: 0, findings: [] },
|
|
};
|
|
|
|
const scanCounts = (f: ScanResult['findings']): ScanResult['counts'] => {
|
|
const c = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 };
|
|
for (const x of f) { if (x.accepted) c.accepted++; else c[x.severity]++; }
|
|
return c;
|
|
};
|
|
const SCAN_PRISM: ScanResult['findings'] = [
|
|
{ rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: 'high', title: 'Tracking-capable code without App Tracking Transparency (AppLovin MAX, Unity Ads)',
|
|
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Add NSUserTrackingUsageDescription and request ATT before the SDKs start, or configure non-personalized ads and answer "no tracking".', skill: 'ads-mediation-checks', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
|
|
{ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'blocker', title: 'targetSdk 35 is below 36, which new apps and updates need since 2026-08-31',
|
|
evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk', aiFix: true },
|
|
{ rule: 'restore-purchases', guideline: '3.1.1', store: 'apple', severity: 'high', title: 'Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
|
|
evidence: [{ file: 'storebound.config.ts', line: 41, text: 'no_ads (non_consumable)' }], fix: 'Add a visible "Restore Purchases" button on the store screen or in Settings.', skill: 'store-iap-setup', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
|
|
{ rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'blocker', title: 'Sign-in is required but there is no demo account for App Review',
|
|
evidence: [{ file: 'storebound.config.ts', line: 17, text: 'reviewAccess: { signInRequired: true }' }], fix: 'Add the App Review demo account in .storebound/ (review-access skill).', skill: 'review-access', ref: 'https://developer.apple.com/app-store/review/guidelines/', accepted: 'leaderboards only; the game plays without signing in' },
|
|
// code changes: Fix with AI (the owner's Claude Code) or Copy for AI
|
|
{ rule: 'ats', guideline: '2.5.1 / ATS', store: 'apple', severity: 'medium', title: 'NSAllowsArbitraryLoads is true: App Review asks for a reason',
|
|
evidence: [{ file: 'ios/Unity-iPhone/Info.plist', line: 48, text: '<key>NSAllowsArbitraryLoads</key><true/>' }], fix: 'Remove NSAllowsArbitraryLoads (or limit it with NSExceptionDomains) unless the app must load arbitrary http content.', skill: 'review-rejections', ref: 'https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity', aiFix: true },
|
|
// a deterministic fix (scan.write: the detected answers into the config)
|
|
{ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', store: 'play', severity: 'medium', title: 'Data safety likely misses PSL_DEVICE_ID: an SDK collects it, dataSafety does not declare it',
|
|
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or an edit of the config), then `storebound play data-safety`.', skill: 'store-declarations', ref: 'https://support.google.com/googleplay/android-developer/answer/10787469', action: { method: 'scan.write', args: {} } },
|
|
];
|
|
const aiRuns = new Map<string, ScanResult['findings'][number]>();
|
|
const SCAN: Record<string, ScanResult> = {
|
|
'p-prism': { stacks: ['unity'], sdks: [{ id: 'applovin', name: 'AppLovin MAX', category: 'ads', tracking: true }, { id: 'unity-ads', name: 'Unity Ads', category: 'ads', tracking: true }, { id: 'gameanalytics', name: 'GameAnalytics', category: 'analytics', tracking: false }],
|
|
findings: SCAN_PRISM, counts: scanCounts(SCAN_PRISM),
|
|
declarations: { changed: true, items: [{ area: 'appPrivacy', what: 'tracking', value: 'AppLovin MAX, Unity Ads can track', status: 'ask', evidence: [] }, { area: 'dataSafety', what: 'PSL_DEVICE_ID', value: 'collect: advertising, analytics, fraud', status: 'detected', evidence: [{ file: 'Packages/manifest.json', line: 4 }] }] } },
|
|
'p-tidy': { stacks: ['ios-native', 'android-native'], sdks: [{ id: 'sentry', name: 'Sentry', category: 'crash', tracking: false }], findings: [], counts: scanCounts([]),
|
|
declarations: { changed: false, items: [] } },
|
|
};
|
|
const SCAN_EMPTY: ScanResult = { stacks: [], sdks: [], findings: [], counts: scanCounts([]), declarations: { changed: false, items: [] } };
|
|
|
|
function field(f: string, length: number, limit: number, required = true): ListingField {
|
|
return { field: f, length, limit, required, state: !length ? (required ? 'missing' : 'empty-optional') : length > limit ? 'over' : 'ok' };
|
|
}
|
|
const LISTINGS: Record<string, ListingsResult> = {
|
|
'p-prism': {
|
|
files: { play: `${HOME}/apps/prism-puddle/store/listing.md`, apple: `${HOME}/apps/prism-puddle/store/appstore.md` }, problems: [],
|
|
locales: [
|
|
{ lang: 'en', rtl: false, screenshots: 24, complete: 1, play: { code: 'en-US', fields: [field('title', 12, 30), field('short', 74, 80), field('full', 2210, 4000), field('whatsNew', 182, 500, false)] }, apple: { code: 'en-US', fields: [field('name', 12, 30), field('subtitle', 28, 30), field('promotionalText', 141, 170), field('keywords', 97, 100), field('description', 2380, 4000), field('whatsNew', 210, 4000, false)] } },
|
|
{ lang: 'tr', rtl: false, screenshots: 24, complete: 1, play: { code: 'tr-TR', fields: [field('title', 12, 30), field('short', 79, 80), field('full', 2390, 4000), field('whatsNew', 170, 500, false)] }, apple: { code: 'tr', fields: [field('name', 12, 30), field('subtitle', 26, 30), field('promotionalText', 150, 170), field('keywords', 99, 100), field('description', 2511, 4000), field('whatsNew', 190, 4000, false)] } },
|
|
{ lang: 'ja', rtl: false, screenshots: 24, complete: 0.89, play: { code: 'ja-JP', fields: [field('title', 9, 30), field('short', 41, 80), field('full', 1310, 4000), field('whatsNew', 0, 500, false)] }, apple: { code: 'ja', fields: [field('name', 9, 30), field('subtitle', 16, 30), field('promotionalText', 0, 170), field('keywords', 88, 100), field('description', 1402, 4000), field('whatsNew', 0, 4000, false)] } },
|
|
{ lang: 'ar', rtl: true, screenshots: 16, complete: 0.89, play: { code: 'ar', fields: [field('title', 11, 30), field('short', 66, 80), field('full', 1980, 4000), field('whatsNew', 150, 500, false)] }, apple: { code: 'ar-SA', fields: [field('name', 11, 30), field('subtitle', 31, 30), field('promotionalText', 120, 170), field('keywords', 93, 100), field('description', 2005, 4000), field('whatsNew', 160, 4000, false)] } },
|
|
],
|
|
},
|
|
'p-tidy': {
|
|
files: { play: `${HOME}/apps/tidy-lists/store/listing.md`, apple: `${HOME}/apps/tidy-lists/store/appstore.md` }, problems: [],
|
|
locales: [
|
|
{ lang: 'en', rtl: false, screenshots: 16, complete: 1, play: { code: 'en-US', fields: [field('title', 10, 30), field('short', 70, 80), field('full', 1500, 4000), field('whatsNew', 120, 500, false)] }, apple: { code: 'en-US', fields: [field('name', 10, 30), field('subtitle', 24, 30), field('promotionalText', 120, 170), field('keywords', 92, 100), field('description', 1610, 4000), field('whatsNew', 130, 4000, false)] } },
|
|
{ lang: 'tr', rtl: false, screenshots: 16, complete: 1, play: { code: 'tr-TR', fields: [field('title', 11, 30), field('short', 77, 80), field('full', 1620, 4000), field('whatsNew', 118, 500, false)] }, apple: { code: 'tr', fields: [field('name', 11, 30), field('subtitle', 27, 30), field('promotionalText', 133, 170), field('keywords', 96, 100), field('description', 1700, 4000), field('whatsNew', 128, 4000, false)] } },
|
|
],
|
|
},
|
|
};
|
|
|
|
const TOOLS = [
|
|
{ id: 'ffmpeg', name: 'ffmpeg', found: true, path: `${HOME}/Library/Application Support/dev.storebound.desktop/tools/bin/ffmpeg`, version: '9.0.2', source: 'app', usedFor: 'icons, screenshot renders, app preview videos, frame crops', install: { kind: 'download', url: 'https://ffmpeg.org/download.html', note: '' } },
|
|
{ id: 'ffprobe', name: 'ffprobe', found: true, path: `${HOME}/Library/Application Support/dev.storebound.desktop/tools/bin/ffprobe`, version: '9.0.2', source: 'app', usedFor: 'checks of app preview videos and clips', install: { kind: 'download', url: 'https://ffmpeg.org/download.html', note: '' } },
|
|
{ id: 'chrome', name: 'Google Chrome', found: false, path: null, version: null, source: null, usedFor: 'screenshot renders, captions, web captures and recordings', install: { kind: 'download', url: 'https://googlechromelabs.github.io/chrome-for-testing/', note: '' } },
|
|
{ id: 'xcode', name: 'Xcode command line tools', found: true, path: '/Applications/Xcode.app/Contents/Developer', version: '27.0', source: 'system', usedFor: 'iOS builds, the iOS Simulator (simctl)', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: '' } },
|
|
{ id: 'simctl', name: 'simctl (iOS Simulator)', found: true, path: '/Applications/Xcode.app/Contents/Developer/usr/bin/simctl', version: null, source: 'system', usedFor: 'iOS Simulator screenshots and recordings', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: '' } },
|
|
{ id: 'adb', name: 'adb (Android platform tools)', found: false, path: null, version: null, source: null, usedFor: 'Android emulator / device screenshots and recordings', install: { kind: 'link', url: 'https://developer.android.com/tools/releases/platform-tools', note: '' } },
|
|
{ id: 'git', name: 'git', found: true, path: '/usr/bin/git', version: '2.55.0', source: 'system', usedFor: 'release notes from history, analytics compare', install: { kind: 'link', url: 'https://git-scm.com/downloads', note: '' } },
|
|
];
|
|
let chromeInstalled = false;
|
|
|
|
// games status (cli/games-status.ts) of the game demo: Game Center on, one leaderboard to create; PGS project set
|
|
const GAMES_PRISM = {
|
|
apple: { capability: true, enabled: true, version: '1.2.0', versionEnabled: true, leaderboards: 1, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: ['leaderboard com.example.prismpuddle.daily: create'] },
|
|
play: { appId: '123456789012', resource: '123456789012 (android/app/src/main/res/values/games-ids.xml)', leaderboards: 2, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: [] },
|
|
findings: [{ store: 'apple', level: 'ok', id: 'gc.entitlement', msg: 'com.apple.developer.game-center in ios/App/App/App.entitlements' }, { store: 'play', level: 'ok', id: 'pgs.app-id', msg: 'games_APP_ID 123456789012 matches the config' }],
|
|
links: [{ label: 'Play Console: Play Games Services configuration', url: 'https://play.google.com/console/u/0/developers/4444/app/9876/play-games-services/setup' }],
|
|
items: [
|
|
{ kind: 'leaderboard', id: 'com.example.prismpuddle.best', name: 'Best score', locales: ['en', 'tr', 'ja', 'ar'], ios: true, android: true,
|
|
apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }, { locale: 'tr', action: 'keep' }, { locale: 'ja', action: 'keep' }, { locale: 'ar-SA', action: 'keep' }] }, play: { action: 'keep', changed: [] } },
|
|
{ kind: 'leaderboard', id: 'com.example.prismpuddle.daily', name: 'Daily puddle', locales: ['en', 'tr'], ios: true, android: true,
|
|
apple: { action: 'create', changed: ['referenceName'], locales: [{ locale: 'en-US', action: 'create' }, { locale: 'tr', action: 'create' }] }, play: { action: 'keep', changed: [] } },
|
|
...['first-drop', 'ten-levels', 'no-hints', 'rainbow', 'speedrun'].map((k, i) => ({ kind: 'achievement', id: `com.example.prismpuddle.${k}`, name: ['First drop', 'Ten levels', 'No hints', 'Rainbow', 'Speedrun'][i]!, locales: ['en', 'tr', 'ja', 'ar'], points: [10, 20, 30, 20, 20][i], hidden: k === 'speedrun', ios: true, android: true,
|
|
apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }] }, play: { action: i === 2 ? 'update' : 'keep', changed: i === 2 ? ['draft.description'] : [] } })),
|
|
],
|
|
localeMap: { configured: { ar: 'ar-SA' }, suggested: [{ id: 'en', from: 'en-US', to: 'en-GB' }] },
|
|
playApiDisabled: null,
|
|
};
|
|
const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data });
|
|
|
|
const STORE_REVIEWS: Review[] = [
|
|
R('apple', 's-a1', 5, 'Lovely', 'Simple and quick, does exactly what it says.', 'USA', 1),
|
|
R('play', 's-p1', 1, 'Stuck on loading', 'Since the last update it stays on the loading screen on my tablet.', 'en', 1),
|
|
R('apple', 's-a2', 4, 'Nice update', 'The new widgets are great. Please add a dark icon.', 'GBR', 3),
|
|
R('play', 's-p2', 2, 'Too many ads', 'An ad after every round is too much.', 'en', 5),
|
|
R('apple', 's-a3', 5, 'Harika', 'Her gün kullanıyorum, çok sade.', 'TUR', 7),
|
|
];
|
|
/** A sample report of a Health issue (`health --issue / --signature --json`). */
|
|
const ISSUE_SAMPLE = (args: Record<string, unknown>) => ({ sample: args.signature ? {
|
|
store: 'apple', version: '2.3.0 (48)', device: 'iPhone15,2', os: 'iOS 18.6', stack: ['PrismPuddle ComboMeter.update(delta:) + 212', 'PrismPuddle GameLoop.tick() + 88', 'QuartzCore CA::Display::DisplayLink::dispatch_items + 1012', 'UIKitCore -[UIApplication _run] + 888', 'PrismPuddle main + 64'],
|
|
} : {
|
|
store: 'play', type: 'CRASH', version: '231', device: 'Galaxy A12', os: 'API 31', time: iso(95),
|
|
stack: ['java.lang.IllegalStateException: ComboMeter.update called after the level ended', '\tat com.example.prismpuddle.combo.ComboMeter.update(ComboMeter.kt:88)', '\tat com.example.prismpuddle.level.Level.tick(Level.kt:214)', '\tat com.example.prismpuddle.GameLoop.run(GameLoop.kt:51)', '\tat android.os.Handler.handleCallback(Handler.java:958)', '\tat android.os.Looper.loop(Looper.java:294)'],
|
|
} });
|
|
|
|
/** One read as the sidecar would answer it (exported for the desktop boot check's auto-read case). */
|
|
export function fixtureRead(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
|
const r = read(method, id, args);
|
|
// App Store Connect reads carry the key's hourly quota (X-Rate-Limit), so the status line has something to show
|
|
return method.startsWith('apple.') && r.ok ? { ...r, quota: { store: 'apple', limit: 3600, remaining: 3412 } } : r;
|
|
}
|
|
|
|
/** ?blocked: every Play health read fails with Google's SERVICE_DISABLED (the portfolio's "Check again" notice). */
|
|
const blockedSwitch = (): boolean => typeof location !== 'undefined' && new URLSearchParams(location.search).has('blocked');
|
|
|
|
function read(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
|
const r = read0(method, id, args);
|
|
const h = r.data as HealthSnapshot | undefined;
|
|
if (method !== 'health' || !blockedSwitch() || !h?.play) return r;
|
|
return { ...r, data: { ...h, play: { error: serviceDisabledMessage('com.example.app') } } };
|
|
}
|
|
|
|
function read0(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
|
if (method === 'health.issue') return ok(method, ISSUE_SAMPLE(args));
|
|
if (id.startsWith('store:')) {
|
|
const play = /android:/.test(id);
|
|
switch (method) {
|
|
case 'health': return ok(method, play ? HEALTH['p-tidy'] : { date: day(0), apple: HEALTH['p-tidy']!.apple });
|
|
case 'reviews.list': return ok(method, STORE_REVIEWS.filter(r => (play || r.store === 'apple') && (/ios:/.test(id) || r.store === 'play')));
|
|
case 'apple.testflight_status': return ok(method, TF['p-tidy']);
|
|
case 'apple.ppo_status': return ok(method, /ios:/.test(id) ? PPO_STORE : { tests: [] });
|
|
case 'listing.live': return ok(method, liveFixture(id));
|
|
default: return { method, ok: false, exitCode: 1, dryRun: false, command: `storebound ${method}`, output: `fixture: ${method} needs an app folder` };
|
|
}
|
|
}
|
|
switch (method) {
|
|
case 'project.icon': return ok(method, { path: SUMMARY[id]?.icon ?? null, dataUrl: ICONS[id] ?? null });
|
|
case 'project.listings': return ok(method, LISTINGS[id]);
|
|
case 'project.editable': return ok(method, editableFixture(SUMMARY[id]));
|
|
case 'reviews.drafts': return ok(method, DRAFTS[id]);
|
|
case 'health': return ok(method, HEALTH[id]);
|
|
case 'reviews.list': return ok(method, REVIEWS[id]);
|
|
case 'apple.testflight_status': return ok(method, TF[id]);
|
|
case 'catalog.diff': return ok(method, CATALOG[id]);
|
|
case 'validate': return ok(method, VALIDATE[id]);
|
|
case 'report': {
|
|
const sum = SUMMARY[id];
|
|
const r = buildReport({ app: { name: sum?.name ?? id }, stores: (['apple', 'play'] as const).filter(x => !!sum?.[x]), storeRead: { apple: true, play: 'no Play service account' },
|
|
generatedAt: new Date().toISOString(), storebound: 'fixture', validate: VALIDATE[id] as never, scan: (SCAN[id] ?? SCAN_EMPTY) as never });
|
|
return ok(method, { file: `/fixture/${id}/.storebound/reports/readiness.html`, path: '.storebound/reports/readiness.html', ...r });
|
|
}
|
|
case 'scan': return ok(method, SCAN[id] ?? SCAN_EMPTY);
|
|
case 'project.declarations': return ok(method, DECLARATIONS[id]);
|
|
case 'project.listing_text': return ok(method, LISTING_TEXT[id]);
|
|
case 'project.renders': return ok(method, RENDERS[id]);
|
|
case 'project.render': return ok(method, { file: String(args.file), dataUrl: renderArt(String(args.file)), bytes: 1_200_000 });
|
|
case 'prices.current': return ok(method, PRICES[id]);
|
|
case 'console.fill': return { ...ok(method, CONSOLE_FILL[args.form as ConsoleForm]), dryRun: true, command: `storebound console fill ${String(args.form)} --dry --json` };
|
|
case 'prices.diff': return ok(method, PRICE_DIFF[id]);
|
|
case 'apple.ppo_status': return ok(method, PPO[id]);
|
|
case 'analytics.compare': return ANALYTICS[id] ? ok(method, ANALYTICS[id]) : { method, ok: false, exitCode: 1, dryRun: false, command: 'storebound analytics compare --json', output: `error: no snapshots in ${HOME}/apps/tidy-lists/.storebound/analytics (storebound analytics snapshot)` };
|
|
case 'aso.audit': return ASO[id] ? ok(method, ASO[id]) : { method, ok: false, exitCode: 1, dryRun: false, command: 'storebound aso audit --json', output: 'fixture: no data for aso.audit' };
|
|
case 'check': return { method, ok: true, exitCode: 0, dryRun: false, command: 'storebound check', output: '12:00:01 [check] config ok: storebound.config.ts\n12:00:01 [check] listing: 4 languages\n12:00:01 [check] 0 problems, 2 warnings' };
|
|
// send for review: Prism Puddle is ready to submit, Tidy Lists is back from App Review (UNRESOLVED_ISSUES)
|
|
case 'apple.status': return ok(method, id === 'p-tidy' ? APPLE_STATUS_UNRESOLVED : APPLE_STATUS_READY);
|
|
case 'play.status': return ok(method, PLAY_STATUS);
|
|
case 'games.status': return ok(method, id === 'p-prism' ? GAMES_PRISM : { apple: null, play: null, findings: [], links: [] });
|
|
case 'console.plan': return ok(method, consolePlan(String(args.form)));
|
|
case 'console.verify': return ok(method, consoleVerify(String(args.form)));
|
|
default:
|
|
if (method.startsWith('owner.')) return ownerDry(method, args); return { method, ok: false, exitCode: 1, dryRun: false, command: `storebound ${method}`, output: `fixture: no data for ${method}` };
|
|
}
|
|
}
|
|
|
|
// ---- console.fill: the answers `console fill <form> --dry --json` would type (made up, from the demo configs) ----
|
|
const CF = (form: ConsoleForm, title: string, url: string, answers: [string, string, string | null, string][], guidance: string[]): ConsoleFillResult => {
|
|
const list = answers.map(([id, label, value, source]) => ({ id, label, value: value ?? '', certain: value !== null, source, ...(value === null ? { note: 'Only you know this: answer it in the console' } : {}) }));
|
|
return { form, store: form.startsWith('apple') ? 'apple' : 'play', title, url, answers: list, uncertain: list.filter(a => !a.certain).length, guidance };
|
|
};
|
|
const ASC = 'https://appstoreconnect.apple.com/apps', PLAY = 'https://play.google.com/console';
|
|
const CONSOLE_FILL: Record<ConsoleForm, ConsoleFillResult> = {
|
|
'apple-app-record': CF('apple-app-record', 'New App', ASC, [['platform', 'Platforms', 'iOS', 'app.platforms'], ['name', 'Name', 'Prism Puddle', 'app.name'], ['language', 'Primary language', 'English (U.S.)', 'languages[0].apple'],
|
|
['bundleId', 'Bundle ID', 'com.example.prismpuddle', 'apple.bundleId'], ['sku', 'SKU', 'prismpuddle', 'apple.sku'], ['access', 'User access', 'Full access', 'default']], ['After Create, copy the Apple ID into apple.appId.']),
|
|
'apple-age-rating': CF('apple-age-rating', 'Age rating', ASC, [['violenceCartoonOrFantasy', 'Cartoon or fantasy violence', 'Infrequent/Mild', 'apple.ageRating'], ['gamblingSimulated', 'Simulated gambling', 'None', 'apple.ageRating'],
|
|
['messagingAndChat', 'Messaging and chat', null, ''], ['advertising', 'Advertising', 'Yes', 'ads.enabled']], ['The age rating follows from your answers; check it before you save.']),
|
|
'apple-app-privacy': CF('apple-app-privacy', 'App Privacy', ASC, [['collects', 'Do you collect data from this app?', 'Yes', 'play.dataSafety'], ['deviceId', 'Device ID', 'Third-party advertising, Analytics', 'play.dataSafety'],
|
|
['linked', 'Device ID linked to the user', null, ''], ['tracking', 'Device ID used for tracking', null, '']], ['App Privacy has no API: publish the answers in App Store Connect yourself.']),
|
|
'play-app-record': CF('play-app-record', 'Create app', PLAY, [['name', 'App name', 'Prism Puddle', 'app.name'], ['language', 'Default language', 'English (United States) – en-US', 'languages[0].play'],
|
|
['type', 'App or game', 'Game', 'app.profile'], ['price', 'Free or paid', 'Free', 'products + ads.appAdsTxt'], ['agree:policies', 'Declarations: Developer Program Policies', null, 'none'], ['agree:exportLaws', 'Declarations: US export laws', null, 'none']],
|
|
['The package name is set by the first AAB upload, not in this form.', 'Free or paid is an owner check: a free app can never become paid later.']),
|
|
'play-content-rating': CF('play-content-rating', 'Content rating', PLAY, [['category', 'Category', 'Game', 'app.profile'], ['violence', 'Violence', 'No', 'play.contentRating'], ['userInteraction', 'Users can interact', 'No', 'play.contentRating'],
|
|
['ads', 'Contains ads', 'Yes', 'ads.enabled'], ['purchases', 'Digital purchases', 'Yes', 'products']], ['Submitting the questionnaire is yours; storebound stops before it.']),
|
|
'play-target-audience': CF('play-target-audience', 'Target audience and content', PLAY, [['ages', 'Target age groups', '13-15, 16-17, 18 and over', 'play.targetAudience'], ['appeal', 'Could appeal to children', null, '']], []),
|
|
};
|
|
|
|
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 };
|
|
const NETWORK = new Set(['games.status', 'health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'report', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
|
|
|
|
async function progressLines(job: string | null, lines: string[]) {
|
|
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
|
|
}
|
|
|
|
/** Every fake-host call, for checks that count them (window.__fakeCalls: "cmd method target args"). */
|
|
const fakeCalls: string[] = [];
|
|
if (typeof window !== 'undefined') (window as unknown as { __fakeCalls: string[] }).__fakeCalls = fakeCalls;
|
|
|
|
export async function mockInvoke<T>(cmd: string, a: Record<string, unknown>): Promise<T> {
|
|
fakeCalls.push([cmd, a.method ?? '', a.projectId ?? a.id ?? '', JSON.stringify((a.params as { args?: unknown } | null)?.args ?? a.live ?? '')].join(' '));
|
|
const r = await answer(cmd, a);
|
|
return r as T;
|
|
}
|
|
|
|
async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown> {
|
|
const job = (a.job as string | null) ?? null;
|
|
switch (cmd) {
|
|
case 'app_info': return { version: '0.1.0', identifier: 'dev.storebound.desktop', platform: 'macos', debug: true, home: HOME };
|
|
case 'sidecar_status': return { running: true, version: '0.5.0', protocol: 'storebound-desktop/1', methods: Array.from({ length: 64 }, (_, i) => ({ method: `m${i}` })), tools: { tools: TOOLS.map(x => (x.id === 'chrome' && chromeInstalled ? { ...x, found: true, name: 'Chrome for Testing', version: '141.0.7390.54', source: 'app' } : x)) } };
|
|
case 'projects_list': return projects;
|
|
case 'accounts_list': return accounts;
|
|
case 'workspace_get': return { studios, overrides, settings };
|
|
case 'store_apps_cached': return accounts.map(x => ({ account: x.id, entry: appsEntry(x.id) }));
|
|
case 'store_apps_refresh': {
|
|
const id = a.id as string;
|
|
await progressLines(job, ['[apps] reading the account\'s apps', '[apps] icons']);
|
|
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
|
|
if (!STORE_APPS[id]) throw { code: -32603, message: `Google Play: ${CHECKS[id]?.message ?? 'no apps'}` };
|
|
IMPORTED[id] = 0;
|
|
return appsEntry(id);
|
|
}
|
|
case 'account_check': await sleep(350); return CHECKS[a.id as string];
|
|
case 'studio_save': {
|
|
const s = a.id ? studios.find(x => x.id === a.id)! : { id: `s-${studios.length + 1}`, logo: null, created: iso(0) } as Studio;
|
|
Object.assign(s, { name: a.name, color: a.color });
|
|
if (!a.id) studios = [...studios, s];
|
|
return s;
|
|
}
|
|
case 'studio_remove': studios = studios.filter(s => s.id !== a.id); accounts = accounts.map(x => (x.studio === a.id ? { ...x, studio: null } : x)); return null;
|
|
case 'studio_logo': return null;
|
|
case 'account_set_play_developer': { const x = accounts.find(y => y.id === a.id)!; const v = (a.developerId as string | null) ?? null; if (v && !/^\d{1,25}$/.test(v)) throw new Error('the Play developer id is digits'); (x as { developerId?: string | null }).developerId = v; return x; }
|
|
case 'account_update': { const x = accounts.find(y => y.id === a.id)!; x.label = a.label as string; x.studio = (a.studio as string | null) ?? null; return x; }
|
|
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': 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;
|
|
case 'portfolio_status': {
|
|
const stores = (a.stores as string[] | undefined) ?? [];
|
|
if (a.live) {
|
|
const total = projects.length + stores.length;
|
|
for (let n = 1; n <= total; n++) {
|
|
await sleep(total > 6 ? 180 : 700);
|
|
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
|
|
emit('sidecar-progress', { params: { requestId: 0, n, total, project: projects[n - 1]?.path ?? stores[n - 1 - projects.length] }, job });
|
|
}
|
|
return portfolio(true, stores);
|
|
}
|
|
await sleep(120);
|
|
return portfolio(false);
|
|
}
|
|
case 'cache_get': {
|
|
const method = a.method as string, id = a.projectId as string;
|
|
if (id === '*' && method === 'portfolio.status') return projects.length ? { value: portfolio(true, storeTargets().map(x => x.target)), at: iso(14) } satisfies CacheEntry<PortfolioResult> : null;
|
|
if (method === 'project.icon') return ICONS[id] ? { value: read(method, id), at: iso(60) } : null;
|
|
if (CACHED_AGO[method] !== undefined && import.meta.env.VITE_FIXTURES !== 'cold') return { value: read(method, id), at: iso(CACHED_AGO[method]!) };
|
|
return null;
|
|
}
|
|
case 'cache_clear': return null;
|
|
case 'sidecar_call': {
|
|
const method = a.method as string;
|
|
if (method === 'system.methods') return { methods: toolMethods() };
|
|
if (method === 'system.tools') return { tools: (await answer('sidecar_status', {}) as { tools: { tools: unknown[] } }).tools.tools, androidSdk: { path: `${HOME}/Library/Android/sdk`, source: 'default', checked: ['ANDROID_HOME: not set', 'ANDROID_SDK_ROOT: not set', `Android Studio default folder: ${HOME}/Library/Android/sdk`] } };
|
|
const id = a.projectId as string;
|
|
if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]);
|
|
else await sleep(150);
|
|
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
|
|
return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
|
|
}
|
|
case 'job_cancel': cancelled.add(a.job as string); return true;
|
|
case 'ai_fix_info': {
|
|
const f = a.finding as { rule: string; evidence: { file: string; line: number }[] };
|
|
return { claude: !new URLSearchParams(location.search).has('noclaude'), git: true, gitError: null, dirty: new URLSearchParams(location.search).has('dirty') ? [{ path: 'ios/Unity-iPhone/Info.plist', status: 'M' }] : [],
|
|
prompt: `Fix one App Store rejection risk that \`storebound scan\` found in this app folder.\n\nFinding (${f.rule}): ...\nEvidence:\n${f.evidence.map(e => `- ${e.file}:${e.line}`).join('\n')}\n` };
|
|
}
|
|
case 'ai_fix_run': {
|
|
const job = a.job as string, f = a.finding as { rule: string; title: string; evidence: { file: string }[] };
|
|
for (const line of ['Read ios/Unity-iPhone/Info.plist', 'Grep NSAllowsArbitraryLoads', 'Edit ios/Unity-iPhone/Info.plist', 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.']) {
|
|
await sleep(400); if (cancelled.has(job)) break; emit('ai-fix', { job, line });
|
|
}
|
|
if (cancelled.has(job)) return { runId: job, outcome: { code: null, timed_out: false, cancelled: true, summary: '' }, changes: [], diff: '' };
|
|
const file = f.evidence[0]?.file ?? 'ios/Unity-iPhone/Info.plist';
|
|
const i = SCAN_PRISM.findIndex(x => x.rule === f.rule && x.title === f.title);
|
|
const removed = i >= 0 ? SCAN_PRISM.splice(i, 1)[0] : undefined;
|
|
if (removed) { aiRuns.set(job, removed); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); }
|
|
return { runId: job, outcome: { code: 0, timed_out: false, cancelled: false, summary: 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.' }, changes: [{ path: file, status: 'M' }],
|
|
diff: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -45,8 +45,6 @@\n <key>NSAppTransportSecurity</key>\n <dict>\n- <key>NSAllowsArbitraryLoads</key>\n- <true/>\n </dict>\n` };
|
|
}
|
|
case 'ai_fix_undo': {
|
|
const r = aiRuns.get(a.runId as string);
|
|
if (r) { SCAN_PRISM.push(r); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); aiRuns.delete(a.runId as string); }
|
|
return ['ios/Unity-iPhone/Info.plist'];
|
|
}
|
|
case 'write_with_confirm': {
|
|
const method = a.method as string, args = (a.args ?? {}) as Record<string, unknown>;
|
|
if (method === 'console.fill') {
|
|
// the real run lasts until the owner closes the browser: here a few seconds, or until Cancel
|
|
const r = CONSOLE_FILL[args.form as ConsoleForm];
|
|
await progressLines(job, ['[console] opening Chromium (profile in ~/.storebound/console-profile/apple)', '[console] waiting for you to sign in', `[console] ${r.title}: filling ${r.answers.length - r.uncertain} fields`, `[console] ${r.uncertain} fields marked for you`]);
|
|
for (let i = 0; i < 20 && !(job && cancelled.has(job)); i++) await sleep(250);
|
|
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
|
|
const res: ToolResult = { method, ok: true, exitCode: 0, dryRun: false, command: `storebound console fill ${String(args.form)} --json`, output: '[console] browser closed', data: { ...r, filled: r.answers.length - r.uncertain, highlighted: r.uncertain, fallback: false, stoppedAt: null } };
|
|
return { confirmed: true, stage: 'done', dryRun: { ...res, dryRun: true, data: r }, result: res };
|
|
}
|
|
await progressLines(job, method === 'owner.apple_submit' || method === 'owner.apple_resubmit'
|
|
? [`[${method}] dry run`, `[${method}] confirmed in the dialog`, '[submit] build attached', '[submit] review submission rs-new-1: not ready to be submitted yet, no item left REJECTED (try 1, next in 15s)', '[submit] accepted on try 2']
|
|
: [`[${method}] dry run`, `[${method}] confirmed in the dialog`, `[${method}] applying`]);
|
|
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
|
|
const d = DRAFTS[a.projectId as string]?.drafts.find(x => x.file.endsWith(`/${String(args.file)}`));
|
|
if (method === 'reviews.approve' && d) d.approved = !args.undo;
|
|
if (method === 'reviews.reply' && d) { d.reply = String(args.text); d.approved = false; d.issues = []; }
|
|
if (method === 'owner.reply_send' && d) d.sent = iso(0);
|
|
// a Fix button's write: the finding it fixed is gone on the next read
|
|
if (method === 'scan.write') { const i = SCAN_PRISM.findIndex(f => f.rule === 'privacy-labels'); if (i >= 0) SCAN_PRISM.splice(i, 1); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); }
|
|
if (method === 'play.listing') { const v = VALIDATE[a.projectId as string]; if (v) v.findings = v.findings.filter(f => f.code !== 'listing'); }
|
|
const res: ToolResult = { method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')}`, output: `[${method}] done (fixture)` };
|
|
return { confirmed: true, stage: 'done', dryRun: { ...res, dryRun: true }, result: res };
|
|
}
|
|
case 'project_add': case 'project_init': case 'project_import_fastlane': await sleep(300); return null;
|
|
case 'export_save': await sleep(200); return `~/Downloads/${String(a.name)}.${String(a.kind)}`;
|
|
case 'price_sheet_pick': return `${HOME}/Downloads/prices-autumn.csv`;
|
|
case 'project_reveal': return null;
|
|
case 'project_locate': await sleep(200); return null;
|
|
case 'state_reset': return null;
|
|
case 'log_webview': console.info('[webview.log]', a.level, a.message); return null;
|
|
case 'project_remove': projects = projects.filter(p => p.id !== a.id); return null;
|
|
case 'project_set_accounts': { const p = projects.find(x => x.id === a.id)!; p.playAccount = (a.play as string | null) ?? null; p.appleAccount = (a.apple as string | null) ?? null; return p; }
|
|
case 'account_add_play': case 'account_add_apple': return null;
|
|
case 'account_remove': accounts = accounts.filter(x => x.id !== a.id); return null;
|
|
case 'open_external': return null;
|
|
case 'tools_info': return [
|
|
{ tool: 'ffmpeg', available: true, version: '9.0.2', size: 56_713_400, license: 'GPL-3.0-or-later (ffmpeg built with GPL components)', licenseUrl: 'https://ffmpeg.org/legal.html', source: 'https://ffmpeg.martin-riedl.de/', installed: true, folder: 'tools/bin' },
|
|
{ tool: 'chrome', available: true, version: null, size: null, license: 'Chromium BSD-style license and third-party notices; Google Chrome for Testing terms', licenseUrl: 'https://github.com/GoogleChromeLabs/chrome-for-testing#readme', source: 'https://googlechromelabs.github.io/chrome-for-testing/', installed: chromeInstalled, folder: 'tools/chrome' },
|
|
];
|
|
case 'tools_install': {
|
|
const total = 168_000_000;
|
|
const ev = (stage: DownloadEvent['stage'], received: number) => emit('tool-download', { job: String(a.job), tool: String(a.tool), stage, received, total } satisfies DownloadEvent);
|
|
ev('resolve', 0); await sleep(400);
|
|
for (let i = 1; i <= 20; i++) {
|
|
if (cancelled.has(a.job as string)) { ev('cancelled', 0); throw 'cancelled'; }
|
|
ev('download', (total * i) / 20); await sleep(140);
|
|
}
|
|
ev('unpack', total); await sleep(400); ev('verify', total); await sleep(400); ev('install', total); await sleep(200);
|
|
if (a.tool === 'chrome') chromeInstalled = true;
|
|
return a.tool === 'chrome' ? 'Chrome for Testing 141.0.7390.54' : 'ffmpeg version 9.0.2';
|
|
}
|
|
case 'tools_uninstall': if (a.tool === 'chrome') chromeInstalled = false; return null;
|
|
default: throw { code: -1, message: `fixture: ${cmd} not mocked` };
|
|
}
|
|
}
|