desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case

This commit is contained in:
CRTkafa 2026-10-13 23:36:30 +08:00
parent 89256f1e80
commit 0fb4ce0001
46 changed files with 1531 additions and 141 deletions

View file

@ -2,6 +2,20 @@
## Unreleased
- **Desktop reads everything by itself.** No more "Not read yet" and a Read button on every page: on start, when the
window shows again and every 15 minutes while it is open (Settings: auto-read on / off, interval), every app's
status, releases and submission state, TestFlight, catalog, prices, listings, health, reviews, analytics and checks
are read in the background when their saved copy is older than its TTL (5 min to 6 h). Saved reads show at once
with "read 3 min ago"; a failed refresh keeps them with "couldn't refresh: <reason>". One scheduler keeps it inside
the stores' limits: 4 at a time per store, starts spaced per account, slower below 20 % of App Store Connect's
hourly limit (read from `X-Rate-Limit`) and a pause at 100 left, exponential backoff with jitter on 429 /
RESOURCE_EXHAUSTED, the visible app first. "Refresh all" in the toolbar, a refresh icon per card, writes refresh
what they changed, and a status line: "Reading 12 of 48 · App Store limit 3,412/3,600 left this hour".
- **Health: open a top issue.** Full title, type, counts, versionCode range, Android API range, last report, a sample
stack trace (`storebound health --issue <id>` / `--signature <id>`, new read `health_issue`), Open in Play Console,
Copy for AI, and a likely cause for known signatures (`cli/lib/crash-hints.ts`; first entry: the Capacitor
`Bridge.getPermissionStates` NullPointerException from `checkPermissions()` during an activity transition).
- **Google API errors in words, not JSON.** Google's error bodies are parsed centrally (`cli/lib/google-errors.ts`)
into typed findings: `api-disabled` (403 SERVICE_DISABLED: service, Cloud project id, the console page to turn it
on), `no-app-access` (403 PERMISSION_DENIED: which app, the Users and permissions step), `auth-revoked` (401,

View file

@ -251,6 +251,12 @@ pub struct RefreshSettings {
pub parallel: u8,
/// Read app icons from the stores when importing (an extra request per app).
pub icons: bool,
/// Background reading of every app (status, releases, catalog, prices, listings, TestFlight, health, reviews,
/// analytics, checks): on start, when the window shows again and every `every_minutes` while it is open.
pub auto_read: bool,
/// Minutes between background passes while the window is visible (only reads past their TTL run); 0 = only on
/// start and when the window shows again.
pub every_minutes: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -282,7 +288,7 @@ pub struct Settings {
// build that lacks a newer setting keeps the rest.
impl Default for RefreshSettings {
fn default() -> RefreshSettings {
RefreshSettings { auto_minutes: 0, on_launch: false, parallel: 4, icons: true }
RefreshSettings { auto_minutes: 0, on_launch: false, parallel: 4, icons: true, auto_read: true, every_minutes: 15 }
}
}
@ -306,11 +312,15 @@ impl Default for Settings {
pub const AUTO_MINUTES: &[u32] = &[0, 15, 30, 60, 180, 720];
pub const KEEP_DAYS: &[u32] = &[0, 7, 30, 90, 365];
pub const EVERY_MINUTES: &[u32] = &[0, 5, 15, 30, 60];
pub fn check_settings(s: &Settings) -> Result<(), String> {
if !AUTO_MINUTES.contains(&s.refresh.auto_minutes) {
return Err("refresh: 0 (off), 15, 30, 60, 180 or 720 minutes".into());
}
if !EVERY_MINUTES.contains(&s.refresh.every_minutes) {
return Err("background reading: 0 (only on start), 5, 15, 30 or 60 minutes".into());
}
if !(1..=8).contains(&s.refresh.parallel) {
return Err("parallel reads: 1 to 8".into());
}

View file

@ -106,6 +106,15 @@ fn settings_have_safe_defaults_and_bounds() {
s = d.clone();
s.refresh.auto_minutes = 1;
assert!(check_settings(&s).is_err(), "no tight polling of the store APIs");
assert_eq!((d.refresh.auto_read, d.refresh.every_minutes), (true, 15), "background reading is on by default, every 15 minutes");
s = d.clone();
s.refresh.every_minutes = 1;
assert!(check_settings(&s).is_err(), "no background pass more often than every 5 minutes");
s = d.clone();
s.refresh.every_minutes = 0;
assert!(check_settings(&s).is_ok());
let old: Settings = serde_json::from_value(serde_json::json!({ "refresh": { "autoMinutes": 15 } })).unwrap();
assert_eq!((old.refresh.auto_read, old.refresh.every_minutes, old.refresh.auto_minutes), (true, 15, 15), "an older settings file gets the new defaults");
s = d.clone();
s.cache.keep_days = 3;
assert!(check_settings(&s).is_err());

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { FIXTURES, onDeepLink, onDeepLinkFolder } from './api';
import { devSwitch } from './dev/switches';
import { type Key, t } from './i18n';
import { ago } from './lib/format';
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 { usePrefs } from './prefs-ctx';
@ -133,6 +133,9 @@ export function App({ onTheme }: { onTheme: () => void }) {
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }]
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
const lastRead = d.live?.at ?? null;
// the app on screen is read first by the background reader
const { setFocus } = d;
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
const groups = d.studios.length ? groupByStudio(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }];
@ -202,6 +205,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
<span>{d.sidecar?.running ? t('foot.cli', { version: d.sidecar.version }) : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}</span></div>
<div className="row"><Icon name="clock" className="sr-icon" /><span>{lastRead ? t('foot.lastRead', { when: ago(lastRead) }) : t('foot.neverRead')}</span></div>
<ReaderLine />
</div>
</nav>
<div className="main">
@ -217,8 +221,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
<span className="spacer" />
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy || !visible.length} title={t('action.readStoresHint')}>
<Icon name="refresh" className={d.portfolioBusy === 'live' ? 'spin' : undefined} />{d.portfolioBusy === 'live' ? t('action.reading') : t('action.readStores')}
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length} title={t('action.refreshAllHint')} data-testid="refresh-all">
<Icon name="refresh" className={d.reader.running ? 'spin' : undefined} />{t('action.refreshAll')}
</button>
<ExportMenu />
</header>
@ -242,3 +246,18 @@ export function App({ onTheme }: { onTheme: () => void }) {
</NavCtx.Provider>
);
}
/** The background reader in one line: "Reading 12 of 48 · App Store limit 3 412/3 600 left this hour". */
function ReaderLine() {
const d = useData();
const r = d.reader;
const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0];
const parts: string[] = [];
if (r.total > 0 && (r.running || r.queued)) parts.push(t('reader.progress', { done: r.done, total: r.total }));
else if (!d.reader.autoRead) parts.push(t('reader.off'));
else if (d.ready && d.apps.length) parts.push(t('reader.upToDate'));
if (Object.keys(r.paused).length) parts.push(t('reader.backoff'));
if (q) parts.push(t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) }));
if (!parts.length) return null;
return <div className="row reader-line" data-testid="reader-line" aria-live="polite"><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span title={parts.join(' · ')}>{parts.join(' · ')}</span></div>;
}

View file

@ -28,12 +28,13 @@ export type StudioColor = (typeof STUDIO_COLORS)[number];
/** A user-named group of store accounts (and so of apps). */
export interface Studio { id: string; name: string; color: StudioColor; logo: string | null; created: string }
export interface Settings {
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean };
/** 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 };
channel: 'stable' | 'beta';
}
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true }, 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, lowReviews: true, crash: true }, channel: 'stable' };
export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings }
export interface AccountApps { account: string; entry: CacheEntry<AppsListResult> | null }
export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number }

View file

@ -216,8 +216,8 @@ const HEALTH: Record<string, HealthSnapshot> = {
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', location: 'com.example.prismpuddle.combo.ComboMeter.update', reports: 412, users: 268, lastVersion: '231' },
{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.prismpuddle.MainActivity', reports: 133, users: 120, lastVersion: '231' },
{ 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' },
],
},
@ -230,7 +230,7 @@ const HEALTH: Record<string, HealthSnapshot> = {
{ 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 },
{ 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 },
],
@ -411,7 +411,23 @@ const STORE_REVIEWS: Review[] = [
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;
}
function read(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) {
@ -475,7 +491,7 @@ const CONSOLE_FILL: Record<ConsoleForm, ConsoleFillResult> = {
};
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 };
const NETWORK = new Set(['health', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
async function progressLines(job: string | null, lines: string[]) {
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
@ -549,7 +565,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
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 read(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
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 'write_with_confirm': {

View file

@ -753,6 +753,39 @@ export const en = {
'set.refreshSub': 'Reads use each account\'s API quota: they run when you ask, or on the schedule here',
'set.autoRefresh': 'Read the stores automatically',
'set.autoHint': 'While the app is open. Off by default.',
'health.likelyCause': 'Likely cause:',
'health.suggestedFix': 'Fix:',
'health.copied': "Copied for AI: paste it into your agent",
'health.copyFailed': "Could not copy to the clipboard",
'health.issueType': "Type",
'health.issueWhere': "Location",
'health.issueShare': "Share of reports",
'health.issueVersions': "versionCode",
'health.issueBuild': "Build",
'health.issueOs': "Android API",
'health.issueLastSeen': "Last report",
'health.sample': "Sample stack trace",
'health.noSample': "No sample report in the last 28 days.",
'health.copyForAi': "Copy for AI",
'health.openConsole': "Open in Play Console",
'health.issuesUnread': "Top issues not read: {error}",
'set.autoRead': "Read everything automatically",
'set.autoReadHint': "Status, releases, catalog, prices, listings, TestFlight, health, reviews and checks of every app, in the background, inside the stores' limits",
'set.everyMinutes': "While the app is open, read again every",
'set.onlyOnStart': "Only on start",
'set.everyHint': "Only reads past their age limit run (status 5 min, reviews 15 min, catalog and prices 30 min, health and analytics 6 h). Paused while the window is hidden.",
'action.refreshAll': "Refresh all",
'action.refreshAllHint': "Read everything of every app again now (read-only, inside the stores' limits)",
'read.ago': "read {when}",
'read.refreshing': "Refreshing…",
'read.couldNotRefresh': "couldn't refresh: {reason}",
'read.refresh': "Refresh",
'read.waiting': "Reading…",
'reader.progress': "Reading {done} of {total}",
'reader.upToDate': "Everything read",
'reader.off': "Auto-read is off",
'reader.appleQuota': "App Store limit {left}/{limit} left this hour",
'reader.backoff': "The store asked to slow down: waiting",
'set.off': 'Off',
'set.min': '{n} min',
'set.hour': '{n} h',

View file

@ -755,6 +755,39 @@ export const tr: Record<keyof typeof en, string> = {
'set.refreshSub': 'Okumalar her hesabın API kotasını kullanır: siz istediğinizde ya da buradaki zamanlamayla çalışır',
'set.autoRefresh': 'Mağazaları otomatik oku',
'set.autoHint': 'Uygulama açıkken. Varsayılan olarak kapalı.',
'health.likelyCause': 'Olası neden:',
'health.suggestedFix': 'Çözüm:',
'health.copied': "Yapay zekâ için kopyalandı: ajanınıza yapıştırın",
'health.copyFailed': "Panoya kopyalanamadı",
'health.issueType': "Tür",
'health.issueWhere': "Konum",
'health.issueShare': "Raporlardaki payı",
'health.issueVersions': "versionCode",
'health.issueBuild': "Derleme",
'health.issueOs': "Android API",
'health.issueLastSeen': "Son rapor",
'health.sample': "Örnek yığın izi",
'health.noSample': "Son 28 günde örnek rapor yok.",
'health.copyForAi': "Yapay zekâ için kopyala",
'health.openConsole': "Play Console'da aç",
'health.issuesUnread': "Öne çıkan hatalar okunamadı: {error}",
'set.autoRead': "Her şeyi otomatik oku",
'set.autoReadHint': "Her uygulamanın durumu, sürümleri, kataloğu, fiyatları, mağaza metinleri, TestFlight, sağlık, yorumlar ve kontrolleri arka planda, mağaza limitlerinin içinde okunur",
'set.everyMinutes': "Uygulama açıkken yeniden okuma aralığı",
'set.onlyOnStart': "Sadece açılışta",
'set.everyHint': "Yalnızca süresi dolan okumalar çalışır (durum 5 dk, yorumlar 15 dk, katalog ve fiyatlar 30 dk, sağlık ve analiz 6 sa). Pencere gizliyken durur.",
'action.refreshAll': "Hepsini yenile",
'action.refreshAllHint': "Her uygulamanın her şeyini şimdi yeniden oku (salt okunur, mağaza limitlerinin içinde)",
'read.ago': "{when} okundu",
'read.refreshing': "Yenileniyor…",
'read.couldNotRefresh': "yenilenemedi: {reason}",
'read.refresh': "Yenile",
'read.waiting': "Okunuyor…",
'reader.progress': "{total} okumadan {done} tamam",
'reader.upToDate': "Her şey okundu",
'reader.off': "Otomatik okuma kapalı",
'reader.appleQuota': "App Store limiti: bu saat {left}/{limit} kaldı",
'reader.backoff': "Mağaza yavaşlamamızı istedi: bekleniyor",
'set.off': 'Kapalı',
'set.min': '{n} dk',
'set.hour': '{n} sa',

View file

@ -0,0 +1,77 @@
// How long a saved read stays fresh, which store lanes a read uses, and the reads the background reader does for
// every app. Pure (tests/scheduler.test.ts).
import type { Lane } from './scheduler';
const MIN = 60_000;
/** Time to live of a saved read per method: status and submission state 5 min, reviews 15 min, catalog / prices /
* listings 30 min, health and analytics 6 h (the stores update them daily). */
export const TTL: Record<string, number> = {
'portfolio.status': 5 * MIN,
'apple.status': 5 * MIN,
'play.status': 5 * MIN,
'apple.testflight_status': 5 * MIN,
'reviews.list': 15 * MIN,
'catalog.diff': 30 * MIN,
'prices.current': 30 * MIN,
'project.listings': 30 * MIN,
'apple.ppo_status': 30 * MIN,
validate: 30 * MIN,
scan: 30 * MIN,
health: 6 * 60 * MIN,
'health.issue': 6 * 60 * MIN,
'analytics.compare': 6 * 60 * MIN,
'aso.audit': 6 * 60 * MIN,
};
export const DEFAULT_TTL = 30 * MIN;
export const ttlOf = (method: string): number => TTL[method] ?? DEFAULT_TTL;
/** True when a read saved at `at` (ISO) is older than its TTL, or there is none. */
export function isStale(method: string, at: string | null | undefined, now = Date.now()): boolean {
if (!at) return true;
const t = Date.parse(at);
return !Number.isFinite(t) || now - t >= ttlOf(method);
}
/** The store lanes a read takes a slot in. */
export function lanesOf(method: string, o: { ios: boolean; android: boolean }): Lane[] {
if (method.startsWith('apple.')) return ['apple'];
if (method.startsWith('play.')) return ['play'];
if (['project.listings', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
const l: Lane[] = [];
if (o.ios) l.push('apple');
if (o.android) l.push('play');
return l.length ? l : ['local'];
}
export interface PlannedRead { method: string; args: Record<string, unknown> }
/** The reads the background reader does for one app (the ones its tabs show), by what the app has. */
export function readsFor(o: { folder: boolean; ios: boolean; android: boolean }): PlannedRead[] {
const r: PlannedRead[] = [];
const add = (method: string, args: Record<string, unknown> = {}) => r.push({ method, args });
if (o.ios) { add('apple.status'); add('apple.testflight_status'); }
if (o.android) add('play.status');
if (o.ios || o.android) { add('reviews.list', { days: 30 }); add('health'); }
if (o.folder) {
add('scan'); add('project.listings');
if (o.ios || o.android) { add('catalog.diff'); add('prices.current'); add('validate'); }
add('analytics.compare'); add('aso.audit');
}
if (o.ios) add('apple.ppo_status');
return r;
}
/** Key of one read in the scheduler and the read bus. */
export const readKey = (target: string, method: string, args: Record<string, unknown> = {}): string => `${target}\u001f${method}\u001f${JSON.stringify(args)}`;
/** "429", "RESOURCE_EXHAUSTED", "rate limit": the store refused for its quota (not the read itself). */
export const looksThrottled = (text: string | undefined): boolean => !!text && /\b429\b|RESOURCE_EXHAUSTED|rate.?limit|quota exceeded/i.test(text);
/** The one-line reason of a failed read, for "couldn't refresh: <reason>". */
export function oneLine(text: string, max = 140): string {
const l = text.trim().split('\n').map(s => s.trim()).filter(Boolean).at(-1) ?? '';
const s = l.replace(/^\S+ \[[^\]]+\] /, '');
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
}

View file

@ -0,0 +1,60 @@
// One crash / ANR issue in detail (Health tab): the Play error issue or App Store diagnostic signature, its sample
// stack (`health.issue`), and the compact "Copy for AI" markdown. Pure (tests/model.test.ts).
import { type CrashHint, crashHint } from '../../../../cli/lib/crash-hints';
import type { IssueSample, PlayIssue, AppleSignature } from './model';
/** Likely cause of a known signature, from the issue text and its sample stack. */
export const hintFor = (v: IssueView, sample: IssueSample | null): CrashHint | null => crashHint(v.title, v.where, ...(sample?.stack.slice(0, 30) ?? []));
export interface IssueView {
store: 'play' | 'apple';
title: string;
/** Exception type / diagnostic type. */
kind: string;
where?: string;
reports?: number;
users?: number;
weight?: number;
versions?: { first?: string; last?: string };
os?: { first?: string; last?: string };
lastSeen?: string;
build?: string;
url?: string;
/** Args of the `health.issue` read for its sample stack; null when there is no id to ask for. */
sampleArgs: Record<string, string> | null;
}
export function playIssueView(i: PlayIssue): IssueView {
return {
store: 'play', title: i.cause || i.location || '?', kind: i.type === 'ANR' ? 'ANR' : i.type, where: i.location || undefined, reports: i.reports, users: i.users,
versions: { first: i.firstVersion, last: i.lastVersion }, os: i.firstOs || i.lastOs ? { first: i.firstOs, last: i.lastOs } : undefined, lastSeen: i.lastSeen, url: i.uri,
sampleArgs: i.id ? { issue: i.id } : null,
};
}
export function appleSignatureView(s: AppleSignature): IssueView {
return { store: 'apple', title: s.signature || s.type, kind: s.type, weight: s.weight, build: s.build, sampleArgs: s.id ? { signature: s.id } : null };
}
/** The top frames worth pasting (the full sample stays in the window). */
export const TOP_FRAMES = 15;
/** Compact markdown for an AI agent: title, type, where, counts, versions, top frames. */
export function issueMarkdown(app: string, v: IssueView, sample: IssueSample | null): string {
const out = [`## ${v.store === 'play' ? 'Google Play' : 'App Store'} ${v.kind}: ${v.title}`, '', `- App: ${app}`];
if (v.where && v.where !== v.title) out.push(`- Location: ${v.where}`);
if (v.reports !== undefined) out.push(`- Reports: ${v.reports}${v.users !== undefined ? `, users: ${v.users}` : ''}`);
if (v.weight !== undefined) out.push(`- Share of reports: ${Math.round(v.weight * 1000) / 10} %`);
if (v.versions?.first || v.versions?.last) out.push(`- versionCode: ${v.versions.first ?? '?'} to ${v.versions.last ?? '?'}`);
if (v.build) out.push(`- Build: ${v.build}`);
if (v.os) out.push(`- Android API: ${v.os.first ?? '?'} to ${v.os.last ?? '?'}`);
if (v.lastSeen) out.push(`- Last report: ${v.lastSeen}`);
const hint = hintFor(v, sample);
if (hint) out.push(`- Likely cause: ${hint.cause}`, `- Suggested fix: ${hint.fix}`);
if (sample) {
const meta = [sample.version && `version ${sample.version}`, sample.device, sample.os].filter(Boolean).join(', ');
out.push('', `Sample report${meta ? ` (${meta})` : ''}, top frames:`, '', '```', ...sample.stack.slice(0, TOP_FRAMES), ...(sample.stack.length > TOP_FRAMES ? [`... ${sample.stack.length - TOP_FRAMES} more frames`] : []), '```');
}
if (v.url) out.push('', `Console: ${v.url}`);
return out.join('\n') + '\n';
}

View file

@ -17,11 +17,17 @@ export interface PlayHealth {
crash: MetricRow[];
anr: MetricRow[];
devices?: { model: string; crash?: number; anr?: number; users?: number }[];
issues: { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string }[];
issues: PlayIssue[];
issuesError?: string;
}
/** One Play error issue (errorIssues:search; id / versions / API levels / last report when the API sent them). */
export interface PlayIssue { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string; id?: string; firstVersion?: string; firstOs?: string; lastOs?: string; lastSeen?: string }
export interface AppleSignature { build: string; type: string; signature: string; weight: number; id?: string }
/** `health --issue / --signature --json`: one sample report's stack, top frame first. */
export interface IssueSample { store: 'play' | 'apple'; time?: string; version?: string; os?: string; device?: string; type?: string; stack: string[] }
export interface AppleHealth {
metrics: { category: string; metric: string; unit: string; version: string; value: number; goal?: string }[];
signatures: { build: string; type: string; signature: string; weight: number }[];
signatures: AppleSignature[];
betaCrashes: { build: string; count: number }[];
}
export type StoreReadError = import('../../../../cli/lib/health').StoreReadError;

View file

@ -55,7 +55,8 @@ export function normalizeSettings(v: unknown): 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 : {};
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) },
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) },
channel: v.channel === 'beta' ? 'beta' : 'stable',

View file

@ -0,0 +1,243 @@
// The background read scheduler: every store read of the window goes through one queue, so reading "everything for
// every app" stays far inside the stores' limits. Pure logic with an injectable clock (tests/scheduler.test.ts).
//
// - Lanes: 'apple' (App Store Connect), 'play' (Google Play Developer + Reporting API), 'local' (offline project
// reads). A task takes one slot in each of its lanes; each lane has its own concurrency limit (default 4).
// - Credentials: starts on one key are spaced (default 250 ms apart). App Store Connect sends X-Rate-Limit
// (user-hour-lim / user-hour-rem, about 3600 per key per hour): below 20 % left, the spacing spreads the rest over the
// hour; at the reserve (default 100, kept for the owner's own commands) the key pauses for 10 minutes.
// - 429 / RESOURCE_EXHAUSTED: the task waits and runs again with exponential backoff and jitter (and its key waits too),
// at most `maxAttempts` times.
// - Priority: 0 the visible app and tab, 1 portfolio status, 2 the rest; FIFO within a priority.
// - Dedupe: one in-flight (or queued) read per key; enqueueing it again returns the same promise (and may raise its
// priority).
export type Lane = 'apple' | 'play' | 'local';
export type Priority = 0 | 1 | 2;
export interface Clock {
now(): number;
setTimeout(fn: () => void, ms: number): unknown;
clearTimeout(h: unknown): void;
random(): number;
}
export const realClock: Clock = {
now: () => Date.now(),
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: h => clearTimeout(h as ReturnType<typeof setTimeout>),
random: () => Math.random(),
};
export interface QuotaSeen { limit?: number; remaining?: number; throttled?: boolean }
/** What one finished run says about the store's limits. */
export interface Outcome {
/** 429 / RESOURCE_EXHAUSTED: run it again later. */
throttled?: boolean;
/** Quota per credential (App Store Connect X-Rate-Limit). */
quota?: { credential: string; limit: number; remaining: number };
}
export interface ReadTask<R> {
key: string;
lanes: Lane[];
/** Keys of the store credentials it uses ("apple:<account>", "play:<account>"). */
credentials: string[];
priority: Priority;
run: () => Promise<R>;
}
export interface SchedulerOptions {
limits?: Partial<Record<Lane, number>>;
/** Minimum gap between two starts on one credential. */
spacingMs?: number;
/** Requests per hour kept free on an App Store Connect key for the owner's own commands. */
reserve?: number;
/** Below this share of the hourly limit left, starts are spread over the rest of the hour. */
slowBelow?: number;
/** How long a key pauses once it is at the reserve. */
pauseMs?: number;
backoffBaseMs?: number;
backoffMaxMs?: number;
maxAttempts?: number;
clock?: Clock;
/** Reads a result (or the error) for throttling and quota. */
classify?: (result: unknown, error: unknown, task: ReadTask<unknown>) => Outcome;
}
export interface SchedulerStats {
queued: number;
running: number;
/** Finished in this round (since the queue was last empty). */
done: number;
/** done + running + queued of this round. */
total: number;
failed: number;
/** The lowest App Store Connect quota seen per credential this hour. */
quota: Record<string, { limit: number; remaining: number; at: number }>;
/** Credentials waiting on a backoff or quota pause, with the time they may start again. */
paused: Record<string, number>;
}
interface Item<R> {
task: ReadTask<R>;
seq: number;
attempt: number;
notBefore: number;
resolve: (r: R) => void;
reject: (e: unknown) => void;
promise: Promise<R>;
}
const HOUR = 3_600_000;
export class ReadScheduler {
private o: Required<Omit<SchedulerOptions, 'limits' | 'clock' | 'classify'>> & { limits: Record<Lane, number> };
private clock: Clock;
private classify: (result: unknown, error: unknown, task: ReadTask<unknown>) => Outcome;
private queue: Item<unknown>[] = [];
private running = new Map<string, Item<unknown>>();
private busy: Record<Lane, number> = { apple: 0, play: 0, local: 0 };
private nextAt = new Map<string, number>();
private quotas = new Map<string, { limit: number; remaining: number; at: number }>();
private seq = 0;
private done = 0;
private failed = 0;
private timer: unknown = null;
private timerAt = 0;
private listeners = new Set<() => void>();
constructor(o: SchedulerOptions = {}) {
this.o = {
limits: { apple: 4, play: 4, local: 2, ...o.limits },
spacingMs: o.spacingMs ?? 250,
reserve: o.reserve ?? 100,
slowBelow: o.slowBelow ?? 0.2,
pauseMs: o.pauseMs ?? 10 * 60_000,
backoffBaseMs: o.backoffBaseMs ?? 5_000,
backoffMaxMs: o.backoffMaxMs ?? 5 * 60_000,
maxAttempts: o.maxAttempts ?? 4,
};
this.clock = o.clock ?? realClock;
this.classify = o.classify ?? (() => ({}));
}
setLimits(limits: Partial<Record<Lane, number>>): void { this.o.limits = { ...this.o.limits, ...limits }; this.pump(); }
subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
has(key: string): boolean { return this.running.has(key) || this.queue.some(i => i.task.key === key); }
enqueue<R>(task: ReadTask<R>): Promise<R> {
const run = this.running.get(task.key);
if (run) return run.promise as Promise<R>;
const queued = this.queue.find(i => i.task.key === task.key);
if (queued) {
if (task.priority < queued.task.priority) { queued.task = { ...queued.task, priority: task.priority }; this.pump(); }
return queued.promise as Promise<R>;
}
let resolve!: (r: R) => void, reject!: (e: unknown) => void;
const promise = new Promise<R>((a, b) => { resolve = a; reject = b; });
this.queue.push({ task, seq: ++this.seq, attempt: 0, notBefore: 0, resolve, reject, promise } as unknown as Item<unknown>);
this.pump();
this.emit();
return promise;
}
/** Drops queued tasks (not the running ones); their promises reject with "cancelled". */
clear(filter: (key: string) => boolean = () => true): void {
const drop = this.queue.filter(i => filter(i.task.key));
this.queue = this.queue.filter(i => !filter(i.task.key));
for (const i of drop) i.reject(new Error('cancelled'));
this.pump();
this.emit();
}
stats(): SchedulerStats {
const now = this.clock.now();
const paused: Record<string, number> = {};
for (const [c, at] of this.nextAt) if (at - now > this.o.spacingMs) paused[c] = at;
const quota: SchedulerStats['quota'] = {};
for (const [c, q] of this.quotas) if (now - q.at < HOUR) quota[c] = q;
return { queued: this.queue.length, running: this.running.size, done: this.done, total: this.done + this.running.size + this.queue.length, failed: this.failed, quota, paused };
}
/** Earliest time a credential may start again. */
private gap(cred: string, now: number): number {
const q = this.quotas.get(cred);
if (!q || now - q.at >= HOUR) return this.o.spacingMs;
if (q.remaining <= this.o.reserve) return this.o.pauseMs;
if (q.remaining < q.limit * this.o.slowBelow) return Math.max(this.o.spacingMs, Math.ceil(HOUR / Math.max(1, q.remaining - this.o.reserve)));
return this.o.spacingMs;
}
private ready(i: Item<unknown>, now: number): number {
// 0 = can start now; otherwise the time it can (Infinity: waits on a lane slot)
let at = i.notBefore;
for (const c of i.task.credentials) at = Math.max(at, this.nextAt.get(c) ?? 0);
if (i.task.lanes.some(l => this.busy[l] >= Math.max(1, this.o.limits[l]))) return Infinity;
return at <= now ? 0 : at;
}
private pump(): void {
const now = this.clock.now();
let wake = Infinity;
for (;;) {
const order = [...this.queue].sort((a, b) => a.task.priority - b.task.priority || a.seq - b.seq);
let started = false;
for (const i of order) {
const r = this.ready(i, now);
if (r === 0) { this.start(i, now); started = true; break; }
if (r !== Infinity) wake = Math.min(wake, r);
}
if (!started) break;
}
if (this.timer !== null && (wake === Infinity || wake !== this.timerAt)) { this.clock.clearTimeout(this.timer); this.timer = null; }
if (wake !== Infinity && this.timer === null) {
this.timerAt = wake;
this.timer = this.clock.setTimeout(() => { this.timer = null; this.pump(); }, Math.max(0, wake - now));
}
}
private start(i: Item<unknown>, now: number): void {
this.queue = this.queue.filter(x => x !== i);
this.running.set(i.task.key, i);
for (const l of i.task.lanes) this.busy[l]++;
for (const c of i.task.credentials) this.nextAt.set(c, now + this.gap(c, now));
i.attempt++;
this.emit();
i.task.run().then(r => this.finish(i, r, undefined, true), e => this.finish(i, undefined, e, false));
}
private finish(i: Item<unknown>, result: unknown, error: unknown, ok: boolean): void {
const now = this.clock.now();
this.running.delete(i.task.key);
for (const l of i.task.lanes) this.busy[l]--;
let out: Outcome = {};
try { out = this.classify(result, error, i.task); } catch { /* a classifier bug never stalls the queue */ }
if (out.quota) {
const prev = this.quotas.get(out.quota.credential);
// the hour is the key's: keep the lowest count seen in it
const q = prev && now - prev.at < HOUR && prev.remaining < out.quota.remaining ? prev : { limit: out.quota.limit, remaining: out.quota.remaining, at: now };
this.quotas.set(out.quota.credential, q);
this.nextAt.set(out.quota.credential, Math.max(this.nextAt.get(out.quota.credential) ?? 0, now + this.gap(out.quota.credential, now)));
}
if (out.throttled && i.attempt < this.o.maxAttempts) {
const exp = Math.min(this.o.backoffMaxMs, this.o.backoffBaseMs * 2 ** (i.attempt - 1));
const delay = Math.round(exp / 2 + this.clock.random() * exp / 2); // "equal jitter"
i.notBefore = now + delay;
for (const c of i.task.credentials) this.nextAt.set(c, Math.max(this.nextAt.get(c) ?? 0, now + delay));
this.queue.push(i);
} else {
this.done++;
if (!ok || out.throttled) this.failed++;
if (ok) i.resolve(result); else i.reject(error);
}
if (!this.queue.length && !this.running.size) { this.done = 0; this.failed = 0; }
this.pump();
this.emit();
}
private emit(): void { for (const f of this.listeners) { try { f(); } catch { /* listeners never break the queue */ } } }
}

View file

@ -225,6 +225,7 @@ export function Portfolio() {
);
};
const notRead = d.portfolioBusy === 'live' ? t('read.waiting') : t('kpi.notRead');
return (
<div className="page">
<div className="ph">
@ -240,13 +241,13 @@ export function Portfolio() {
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={rechecking} />
{neverImported > 0 && <Notice tone="info" icon="download" action={<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy}>{t('action.importApps')}</button>}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
{!known && d.ready && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
<div className="kpis">
<Kpi icon="layers" label={t('kpi.apps')} value={rows.length} sub={t('kpi.appsSubStore', { ios: iosApps, android: androidApps, store: storeOnly })} />
<Kpi icon="check" label={t('kpi.live')} value={known ? liveCount : '–'} sub={known ? t('kpi.liveSub', { count: stores }) : t('kpi.notRead')} />
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? t('kpi.notRead') : rolling.length ? [...new Set(rolling.map(r => r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} />
<Kpi icon="clock" label={t('kpi.review')} value={known ? review.length : '–'} sub={!known ? t('kpi.notRead') : review.length ? review.map(r => `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
<Kpi icon="check" label={t('kpi.live')} value={known ? liveCount : '–'} sub={known ? t('kpi.liveSub', { count: stores }) : notRead} />
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? notRead : rolling.length ? [...new Set(rolling.map(r => r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} />
<Kpi icon="clock" label={t('kpi.review')} value={known ? review.length : '–'} sub={!known ? notRead : review.length ? review.map(r => `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
<Kpi icon="alert" label={t('kpi.attention')} value={att.length} sub={blockers ? t('kpi.blockers', { count: blockers }) : t('kpi.noBlockers')} tone={blockers ? 'bad' : att.length ? 'warn' : undefined} />
</div>

View file

@ -122,11 +122,11 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
<Card id="set-refresh" title={t('set.refresh')} icon="refresh" sub={t('set.refreshSub')} className="set-card">
<div className="form">
<div className="field"><span className="legend">{t('set.autoRefresh')}</span>
<Seg value={String(s.refresh.autoMinutes) as '0'} label={t('set.autoRefresh')} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoMinutes: Number(v) as S['refresh']['autoMinutes'] } }))}
options={[['0', t('set.off')], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })], ['180', t('set.hour', { n: 3 })], ['720', t('set.hour', { n: 12 })]]} />
<span className="hint">{t('set.autoHint')}</span></div>
<Toggle id="on-launch" label={t('set.onLaunch')} hint={t('set.onLaunchHint')} checked={s.refresh.onLaunch} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, onLaunch: v } }))} />
<Toggle id="auto-read" label={t('set.autoRead')} hint={t('set.autoReadHint')} checked={s.refresh.autoRead} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoRead: v } }))} />
<div className="field"><span className="legend">{t('set.everyMinutes')}</span>
<Seg value={String(s.refresh.everyMinutes) as '0'} label={t('set.everyMinutes')} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, everyMinutes: Number(v) as S['refresh']['everyMinutes'] } }))}
options={[['0', t('set.onlyOnStart')], ['5', t('set.min', { n: 5 })], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })]]} />
<span className="hint">{t('set.everyHint')}</span></div>
<Toggle id="icons" label={t('set.icons')} hint={t('set.iconsHint')} checked={s.refresh.icons} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, icons: v } }))} />
<div className="field"><label htmlFor="parallel">{t('set.parallel')}</label>
<select id="parallel" className="input" style={{ width: 120 }} value={s.refresh.parallel} onChange={e => set(x => ({ ...x, refresh: { ...x.refresh, parallel: Number(e.target.value) } }))}>
@ -134,7 +134,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
</select><span className="hint">{t('set.parallelHint')}</span></div>
<div className="actions">
<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" />{t('action.importApps')}</button>
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }}><Icon name="refresh" />{t('action.refreshAll')}</button>
<span className="small muted">{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}</span>
</div>
</div>

View file

@ -8,7 +8,7 @@ import { useData, useRead, useWrite } from '../../store';
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
import { OwnerAction } from '../../ui/OwnerAction';
import { WriteButton } from '../../ui/WriteButton';
import { Card, Chip, Empty, ErrorNotice, Kpi, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, Kpi, ReadControl, Skeleton } from '../../ui/kit';
import { catalogMd, exportName } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
import { useNav } from '../../nav';
@ -87,13 +87,13 @@ export function CatalogTab({ project }: { project: Project }) {
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
<Kpi icon="tag" label={t('cat.products')} value={s?.products ?? 0} sub={t('cat.productsSub')} />
<Kpi icon="refresh" label={t('cat.subscriptions')} value={s?.subscriptions ?? 0} sub={t('cat.subsSub')} />
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : t('kpi.notRead')} />
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => 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} />
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : diff.loading ? t('read.waiting') : t('kpi.notRead')} />
</div>
{diff.error && <ErrorNotice error={diff.error} keep />}
<CatalogActions project={project} products={s?.products ?? 0} subs={s?.subscriptions ?? 0} refresh={() => { diff.refresh(); }} />
<Card title={t('cat.matrix')} icon="layers" sub={read ? (diff.fromCache ? t('common.cachedFrom', { when: ago(diff.at) }) : t('common.readWhen', { when: ago(diff.at) })) : t('cat.configOnly')} pad={false}
actions={<button className="btn btn-sm" onClick={diff.refresh} disabled={diff.loading}><Icon name="refresh" className={diff.loading ? 'spin' : undefined} />{read ? t('action.refresh') : t('cat.compare')}</button>}>
actions={<ReadControl r={diff} label={t('cat.compare')} />}>
<div className="table-wrap">
<table className="table">
<thead><tr><th>{t('col.item')}</th><th>{t('col.type')}</th><th>{t('col.config')}</th><th>App Store</th><th>Google Play</th><th className="right">{t('col.priceGap')}</th></tr></thead>

View file

@ -6,7 +6,7 @@ import { ago } from '../../lib/format';
import type { Finding, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
import { useData, useRead } from '../../store';
import { Icon } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
import { exportName, validateMd } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
@ -22,7 +22,7 @@ function ScanCard({ project }: { project: Project }) {
const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length;
return (
<Card title={t('checks.scan')} icon="search" sub={s.data && sdks.length ? t('checks.scanSdks', { list: sdks.map(x => x.name).join(', ') }) : t('checks.scanSub')} pad={false}
actions={<button className="btn btn-sm" onClick={s.refresh} disabled={s.loading}><Icon name="refresh" className={s.loading ? 'spin' : undefined} />{t('checks.scanAgain')}</button>}>
actions={<ReadControl r={s} label={t('checks.scanAgain')} />}>
{s.error && <div className="card-body"><ErrorNotice error={s.error} keep={!!s.data} /></div>}
{!s.data ? (s.loading && !s.error ? <div className="card-body"><Skeleton lines={4} /></div> : null)
: !findings.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
@ -80,7 +80,7 @@ export function ChecksTab({ project }: { project: Project }) {
)}
</Card>
<Card title={t('checks.validate')} icon="layers" sub={v.data ? (v.fromCache ? t('common.cachedFrom', { when: ago(v.at) }) : t('common.readWhen', { when: ago(v.at) })) : t('checks.validateSub')} pad={false}
actions={<button className="btn btn-sm" onClick={v.refresh} disabled={v.loading}><Icon name="refresh" />{v.data ? t('action.refresh') : t('checks.run')}</button>}>
actions={<ReadControl r={v} label={t('checks.run')} />}>
{v.error && <div className="card-body"><ErrorNotice error={v.error} keep={!!v.data} /></div>}
{!v.data ? (
v.loading && !v.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('checks.validateEmpty')}>{t('checks.validateEmptyText')}</Empty>

View file

@ -14,7 +14,7 @@ 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, Seg, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
import { LineChart } from '../../ui/LineChart';
import { RankChart } from '../../ui/RankChart';
import { WriteButton } from '../../ui/WriteButton';
@ -107,7 +107,7 @@ function Aso({ projectId }: { projectId: string }) {
})) : [];
return (
<Card title={t('gr.aso')} icon="search" sub={a ? t('gr.asoSub', { date: date(a.generated) }) : t('gr.asoSubNone')} pad={false}
actions={<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{t('action.refresh')}</button>}>
actions={<ReadControl r={r} />}>
{r.error ? <div className="card-body"><ErrorNotice error={r.error} /></div> : !a ? <div className="card-body"><Skeleton lines={4} block /></div> : (
<div className="card-body stack" style={{ gap: 16 }}>
<div className="kpis" style={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}>
@ -167,7 +167,7 @@ function Experiments({ app }: { app: UnifiedApp }) {
<Card title={t('gr.ppo')} icon="flask" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}</span>} pad={false}
actions={<>
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(asc).catch(() => {})}><Icon name="external" />{t('gr.results')}</button>
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('gr.ppoRead')}</button>
<ReadControl r={r} label={t('gr.ppoRead')} />
</>}>
{r.error && <div className="card-body"><ErrorNotice error={r.error} keep={!!r.data} /></div>}
{!r.data ? (r.loading && !r.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('gr.ppoNotRead')}>{t('gr.ppoNotReadText')}</Empty>)

View file

@ -4,9 +4,13 @@ import type { UnifiedApp } from '../../lib/identity';
import { t } from '../../i18n';
import { ago, date, num, pct } from '../../lib/format';
import { type AppleHealth, healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type PlayHealth, type Series } from '../../lib/model';
import { useRead } from '../../store';
import { useState } from 'react';
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, Notice, Skeleton, Sparkline } from '../../ui/kit';
import { Bar, Card, Chip, Empty, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit';
import { exportName, healthMd } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
import { StoreReadNotice } from '../../ui/StoreError';
@ -33,7 +37,47 @@ function Metric({ title, s }: { title: string; s: Series }) {
);
}
function Play({ h }: { h: PlayHealth }) {
/** One issue's detail: full title and type, counts, versions, last report, a sample stack (read and cached like the
* rest), "Open in Play Console" and "Copy for AI" (compact markdown for an agent). */
function IssueDetail({ app, v }: { app: UnifiedApp; v: IssueView }) {
const d = useData();
const s = useRead<{ sample: IssueSample | null }>(app.target, 'health.issue', { network: true, args: v.sampleArgs ?? {}, skip: !v.sampleArgs });
const sample = s.data?.sample ?? null;
const copy = () => {
navigator.clipboard?.writeText(issueMarkdown(app.name, v, sample)).then(() => d.toast({ kind: 'ok', title: t('health.copied') }), () => d.toast({ kind: 'bad', title: t('health.copyFailed') }));
};
const range = (a?: string, b?: string) => (a && b && a !== b ? `${a} – ${b}` : a ?? b ?? '–');
return (
<div className="issue-detail" data-testid="issue-detail">
<div className="strong issue-title">{v.title}</div>
<dl className="kv">
<dt>{t('health.issueType')}</dt><dd>{v.kind}</dd>
{v.where && v.where !== v.title && <><dt>{t('health.issueWhere')}</dt><dd className="mono-id">{v.where}</dd></>}
{v.reports !== undefined && <><dt>{t('col.reports')}</dt><dd className="num">{num(v.reports)}{v.users !== undefined ? ` · ${t('col.users')} ${num(v.users)}` : ''}</dd></>}
{v.weight !== undefined && <><dt>{t('health.issueShare')}</dt><dd className="num">{num(v.weight * 100, 1)}%</dd></>}
{(v.versions?.first || v.versions?.last) && <><dt>{t('health.issueVersions')}</dt><dd className="num">{range(v.versions.first, v.versions.last)}</dd></>}
{v.build && <><dt>{t('health.issueBuild')}</dt><dd className="num">{v.build}</dd></>}
{v.os && <><dt>{t('health.issueOs')}</dt><dd className="num">{range(v.os.first, v.os.last)}</dd></>}
{v.lastSeen && <><dt>{t('health.issueLastSeen')}</dt><dd>{ago(v.lastSeen)}</dd></>}
</dl>
{(() => { const h = hintFor(v, sample); return h ? <Notice tone="info" icon="wand"><div className="stack" style={{ gap: 4 }} data-testid="issue-hint"><span><b>{t('health.likelyCause')}</b> {h.cause}</span><span><b>{t('health.suggestedFix')}</b> {h.fix}</span></div></Notice> : null; })()}
{v.sampleArgs && (
<div className="stack" style={{ gap: 6 }}>
<div className="row between"><span className="label">{t('health.sample')}{sample ? ` · ${[sample.version, sample.device, sample.os].filter(Boolean).join(' · ')}` : ''}</span><ReadControl r={s} /></div>
{sample ? <pre className="stack-trace" data-testid="issue-stack">{sample.stack.join('\n')}</pre>
: s.loading ? <Skeleton lines={4} /> : !s.error ? <p className="small muted">{t('health.noSample')}</p> : null}
</div>
)}
<div className="actions">
<button className="btn btn-sm" onClick={copy}><Icon name="clipboard" />{t('health.copyForAi')}</button>
{v.url && <button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(v.url!).catch(() => {})}><Icon name="external" />{t('health.openConsole')}</button>}
</div>
</div>
);
}
function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) {
const [open, setOpen] = useState<number | null>(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);
@ -67,11 +111,11 @@ function Play({ h }: { h: PlayHealth }) {
<thead><tr><th>{t('col.issue')}</th><th className="right">{t('col.reports')}</th><th className="right">{t('col.users')}</th></tr></thead>
<tbody>
{h.issues.slice(0, 6).map((i, n) => (
<tr key={n}>
<td><div className="row" style={{ gap: 8 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260 }}>{i.cause || '?'}</span></div>
<div className="mono-id muted" style={{ marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 360 }}>{i.location}</div></td>
<td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td>
</tr>
<IssueRows key={n} open={open === n} onToggle={() => setOpen(open === n ? null : n)} cols={3}
head={<><div className="row" style={{ gap: 8, minWidth: 0 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong ellipsis" title={i.cause || '?'}>{i.cause || '?'}</span></div>
<div className="mono-id muted ellipsis" style={{ marginTop: 4 }} title={i.location}>{i.location}</div></>}
cells={<><td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td></>}
detail={<IssueDetail app={app} v={playIssueView(i)} />} />
))}
</tbody>
</table>
@ -82,7 +126,21 @@ function Play({ h }: { h: PlayHealth }) {
);
}
function Apple({ h }: { h: AppleHealth }) {
/** 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 }) {
return (
<>
<tr className={`issue-row${open ? ' open' : ''}`} onClick={onToggle} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }} tabIndex={0} aria-expanded={open} role="button">
<td style={{ maxWidth: 380 }}><div className="row" style={{ gap: 6, minWidth: 0 }}><Icon name="chevronRight" className={`ic13 chev${open ? ' rot' : ''}`} /><div style={{ minWidth: 0 }}>{head}</div></div></td>
{cells}
</tr>
{open && <tr className="edit-row"><td colSpan={cols}>{detail}</td></tr>}
</>
);
}
function Apple({ h, app }: { h: AppleHealth; app: UnifiedApp }) {
const [open, setOpen] = useState<number | null>(null);
const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count));
return (
<div className="grid g2">
@ -102,15 +160,16 @@ function Apple({ h }: { h: AppleHealth }) {
<div className="stack" style={{ gap: 16 }}>
<Card title={t('health.signatures')} icon="layers" sub={t('health.signaturesSub')} pad={false}>
{h.signatures.length ? (
<div className="list">
{h.signatures.slice(0, 5).map((s, i) => (
<div className="li" key={i}>
<span className="li-icon tone-bg-neutral"><Icon name="alertCircle" /></span>
<div className="li-main"><span className="li-title" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.signature || s.type}</span><span className="li-sub">{s.type} · {t('health.build', { build: s.build })}</span></div>
<span className="num small strong">{num(s.weight * 100, 1)}%</span>
</div>
))}
</div>
<table className="table">
<tbody>
{h.signatures.slice(0, 5).map((s, i) => (
<IssueRows key={i} open={open === i} onToggle={() => setOpen(open === i ? null : i)} cols={2}
head={<><div className="strong ellipsis" title={s.signature || s.type}>{s.signature || s.type}</div><div className="small muted">{s.type} · {t('health.build', { build: s.build })}</div></>}
cells={<td className="right num small strong">{num(s.weight * 100, 1)}%</td>}
detail={<IssueDetail app={app} v={appleSignatureView(s)} />} />
))}
</tbody>
</table>
) : <Empty compact illo="shield" title={t('health.noSignatures')} />}
</Card>
<Card title={t('health.betaCrashes')} icon="plane">
@ -128,7 +187,6 @@ function Apple({ h }: { h: AppleHealth }) {
export function HealthTab({ app }: { app: UnifiedApp }) {
const r = useRead<HealthSnapshot>(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!) }]);
const refresh = <button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading && !r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('health.read')}</button>;
if (!r.data) {
if (r.loading && !r.error) return <Card><Skeleton lines={4} block /></Card>;
return (
@ -142,14 +200,13 @@ export function HealthTab({ app }: { app: UnifiedApp }) {
return (
<>
<div className="row between">
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
{refresh}
<span />
<ReadControl r={r} />
</div>
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
<h2 className="label">Google Play</h2>
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Play h={h.play} />}
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <>{h.play.issuesError && <ErrorNotice error={t('health.issuesUnread', { error: h.play.issuesError })} />}<Play h={h.play} app={app} /></>}
<h2 className="label">App Store</h2>
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} />}
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} app={app} />}
</>
);
}

View file

@ -10,7 +10,7 @@ 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, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Skeleton } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
const GAP = 10;
@ -158,7 +158,7 @@ export function PricesTab({ project }: { project: Project }) {
<Card title={t('prices.overview')} icon="grid" sub={cur.fromCache ? t('common.cachedFrom', { when: ago(cur.at) }) : t('common.readWhen', { when: ago(cur.at) })} pad={false}
actions={<>
<label className="check"><input type="checkbox" checked={withAuto} onChange={ev => setWithAuto(ev.target.checked)} />{t('prices.withAuto')}</label>
<button className="btn btn-sm" onClick={cur.refresh} disabled={cur.loading}><Icon name="refresh" className={cur.loading ? 'spin' : undefined} />{t('action.refresh')}</button>
<ReadControl r={cur} />
</>}>
<div className="table-wrap">
<table className="table px-matrix">

View file

@ -13,7 +13,7 @@ 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, storeName } from '../../ui/kit';
import { Card, Chip, Empty, Notice, PhaseChip, Skeleton, storeName } from '../../ui/kit';
import { AppleSubmitCard, PlayProductionCard } from './Submit';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
@ -139,6 +139,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
</section>
) : null;
if (!l && d.portfolioBusy === 'live') return <><Card><Skeleton lines={5} block /></Card>{submit}</>;
if (!l) {
return (
<>

View file

@ -5,12 +5,12 @@ import { useEffect, useMemo, useState } from 'react';
import type { DraftsResult, ReplyDraft } from '../../api';
import type { UnifiedApp } from '../../lib/identity';
import { t } from '../../i18n';
import { ago, date, num } from '../../lib/format';
import { date, num } from '../../lib/format';
import { draftName, filterReviews, type Review, type ReviewFilter, starStats } from '../../lib/model';
import { useRead, useWrite } from '../../store';
import { WriteButton } from '../../ui/WriteButton';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Seg, Skeleton, Stars } from '../../ui/kit';
import { Bar, Card, Chip, Empty, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit';
import { exportName, reviewsCsv } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
@ -111,13 +111,11 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
return (
<>
<div className="row between">
<span className="stamp"><Icon name="clock" />{list.fromCache ? t('common.cachedFrom', { when: ago(list.at) }) : t('common.readWhen', { when: ago(list.at) })}</span>
<ReadControl r={list} />
<div className="actions">
{project && <WriteButton run={w} icon="text" hint={t('reviews.makeDraftsHint')} w={{ method: 'reviews.draft', args: { days: 30 }, title: t('reviews.makeDrafts'), done: t('reviews.draftsMade'), after: changed }}>{t('reviews.makeDrafts')}</WriteButton>}
<button className="btn btn-sm" onClick={list.refresh} disabled={list.loading}><Icon name="refresh" />{t('action.refresh')}</button>
</div>
</div>
{list.error && <Notice tone="warn">{t('common.readFailedKeep', { error: list.error })}</Notice>}
<div className="rv">
<div className="stack" style={{ gap: 16 }}>
<Card title={t('reviews.ratings')} icon="star">

View file

@ -15,7 +15,7 @@ import type { UnifiedApp } from '../../lib/identity';
import { useData, useRead } from '../../store';
import { ConsolePlanButton } from '../../ui/ConsolePlan';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, Notice, Seg, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
import { OwnerAction } from '../../ui/OwnerAction';
import { ascUrl, PLAY_CONSOLE } from '../AppView';
@ -40,9 +40,9 @@ export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Pr
const args = { build, ...(version ? { version: version.version } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) };
const refresh = () => { r.refresh(); };
return (
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.appleSub')}</span>}
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.appleSub')}</span>}
actions={<>
<button className="btn btn-sm" onClick={refresh} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
<ReadControl r={{ ...r, refresh: async () => refresh() }} label={t('sub.read')} />
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(ascUrl(app.ios?.app.id ?? summary?.apple?.appId)).catch(() => {})}><Icon name="external" />App Store Connect</button>
</>}>
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
@ -142,9 +142,9 @@ export function PlayProductionCard({ app, project }: { app: UnifiedApp; project:
const picked = s?.codes.find(c => c.code === code) ?? null;
const args = { version_code: code, status, ...(status === 'inProgress' ? { percent: Number(share) } : {}) };
return (
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.playSub')}</span>}
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.playSub')}</span>}
actions={<>
<button className="btn btn-sm" onClick={() => { r.refresh(); }} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
<ReadControl r={r} label={t('sub.read')} />
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>
</>}>
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}

View file

@ -4,11 +4,11 @@ import { Fragment, useState } from 'react';
import type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton';
import { t } from '../../i18n';
import { ago, date, num } from '../../lib/format';
import { date, num } from '../../lib/format';
import type { TestFlightStatus, TfBuild, TfGroup, Tone } from '../../lib/model';
import { useData, useRead, useWrite } from '../../store';
import { Icon } from '../../ui/icons';
import { Card, Chip, Empty, Notice, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, Notice, ReadControl, Skeleton } from '../../ui/kit';
/** Beta state word -> tone and a short label. */
export function betaState(s: string | null): { tone: Tone; text: string } {
@ -96,10 +96,9 @@ export function TestFlightTab({ app }: { app: UnifiedApp }) {
return (
<>
<div className="row between">
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" />{t('action.refresh')}</button>
<span />
<ReadControl r={r} />
</div>
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
{s.missingGroups.length > 0 && (
<Notice tone="warn" action={writable ? <WriteButton run={w} icon="plus" w={{ method: 'apple.testflight_groups', args: {}, title: t('tf.groupsSync'), done: t('tf.groupsSynced'), after: r.refresh }} hint={t('tf.groupsSyncHint')}>{t('tf.groupsSync')}</WriteButton> : undefined}>
{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}

View file

@ -13,6 +13,8 @@ import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify';
import { setCrashContext } from './lib/crash';
import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize';
import { keepLines, progressOf } from './lib/progress';
import { 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 { failureText, usable } from './lib/results';
@ -32,6 +34,31 @@ export interface Toast {
export interface AccountHealth { check?: AccountCheck; error?: string; at: string }
/** What the read bus tells the cards showing one read (key: lib/freshness.ts readKey). */
export type ReadEvent = { kind: 'start' } | { kind: 'done'; result: ToolResult } | { kind: 'error'; error: string } | { kind: 'invalidate' };
/** The background reader's state for the status line. */
export interface ReaderState extends SchedulerStats { autoRead: boolean }
/** Throttling and App Store Connect quota of one finished read (the ToolResult's `quota`, else its text). */
export function classifyRead(result: unknown, error: unknown, task: ReadTask<unknown>): Outcome {
const r = result as ToolResult | undefined;
const q = r?.quota;
const out: Outcome = {};
if (q?.store === 'apple' && typeof q.limit === 'number' && typeof q.remaining === 'number')
out.quota = { credential: task.credentials.find(c => c.startsWith('apple:')) ?? 'apple:default', limit: q.limit, remaining: q.remaining };
if (r && !r.ok && (q?.throttled || looksThrottled(r.output))) out.throttled = true;
if (error !== undefined && looksThrottled(errorText(error))) out.throttled = true;
return out;
}
/** Which stores an app has (for the reads it gets and the lanes they take). */
export function platformsOf(a: UnifiedApp | undefined, status?: AppStatus): { ios: boolean; android: boolean } {
if (!a) return { ios: false, android: false };
const pl = status?.app?.platforms as { ios?: unknown; android?: unknown } | undefined;
return { ios: !!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl), android: !!a.android || !!pl?.android || (!!a.project?.playAccount && !pl) };
}
export interface Data {
ready: boolean;
info: AppInfo | null;
@ -56,7 +83,20 @@ export interface Data {
portfolioError: string | null;
importBusy: boolean;
icons: Record<string, string | null>;
refreshPortfolio: (live?: boolean) => Promise<void>;
/** Background reader: progress and the App Store Connect quota seen this hour. */
reader: ReaderState;
/** Runs one read through the scheduler (deduped, rate limited); the result also goes to the read bus. */
schedule: (target: string, method: string, args: Record<string, unknown>, priority: Priority) => Promise<ToolResult>;
/** Listens to one read's bus key. */
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<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<void>;
/** Reads the apps of one account (or all) from the stores. */
importApps: (account?: string) => Promise<void>;
checkAccount: (id: string) => Promise<void>;
@ -155,10 +195,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
const appsRef = useRef(apps);
appsRef.current = apps;
const refreshPortfolio = useCallback(async (liveRead = false) => {
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => {
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null);
const tid = liveRead ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
try {
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
const r = await api.portfolio(liveRead, job, stores);
@ -179,6 +219,78 @@ export function DataProvider({ children }: { children: ReactNode }) {
} finally { setBusy(null); }
}, [toast, updateToast]);
// ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ----
const schedRef = useRef<ReadScheduler | null>(null);
if (!schedRef.current) schedRef.current = new ReadScheduler({ classify: classifyRead, limits: { apple: settings.refresh.parallel, play: settings.refresh.parallel } });
const sched = schedRef.current;
const [reader, setReader] = useState<SchedulerStats>(() => sched.stats());
useEffect(() => {
let tm: ReturnType<typeof setTimeout> | null = null;
const un = sched.subscribe(() => { if (!tm) tm = setTimeout(() => { tm = null; setReader(sched.stats()); }, 150); });
return () => { un(); if (tm) clearTimeout(tm); };
}, [sched]);
useEffect(() => { sched.setLimits({ apple: settings.refresh.parallel, play: settings.refresh.parallel }); }, [sched, settings.refresh.parallel]);
const bus = useRef(new Map<string, Set<(e: ReadEvent) => void>>());
const emitRead = useCallback((key: string, e: ReadEvent) => { for (const f of bus.current.get(key) ?? []) { try { f(e); } catch { /* a card's listener never breaks the bus */ } } }, []);
const onRead = useCallback((key: string, fn: (e: ReadEvent) => void) => {
const set = bus.current.get(key) ?? new Set();
set.add(fn); bus.current.set(key, set);
return () => { set.delete(fn); if (!set.size) bus.current.delete(key); };
}, []);
const isReading = useCallback((key: string) => sched.has(key), [sched]);
const focusRef = useRef<string | null>(null);
const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []);
const credentialsOf = (a: UnifiedApp | undefined, lanes: string[]): string[] => {
const c: string[] = [];
if (lanes.includes('apple')) c.push(`apple:${a?.project?.appleAccount ?? a?.ios?.account ?? 'default'}`);
if (lanes.includes('play')) c.push(`play:${a?.project?.playAccount ?? a?.android?.account ?? 'default'}`);
return c;
};
const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined);
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority): Promise<ToolResult> => {
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<ToolResult>({ key, lanes, credentials: credentialsOf(app, lanes), priority, run: () => api.call<ToolResult>(method, target, { args }, newJob(method)) });
emitRead(key, { kind: 'start' });
p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) }));
return p;
}, [sched, emitRead]);
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'], credentials: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {});
}
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));
for (const a of list) {
const pf = platformsOf(a, statusRef.current(a));
for (const r of readsFor({ folder: !!a.project, ...pf })) {
const key = readKey(a.target, r.method, r.args);
if (sched.has(key)) continue;
if (!force) {
const c = await api.cached<ToolResult>(r.method, a.target, r.args).catch(() => null);
if (c && !isStale(r.method, c.at)) continue;
}
schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {});
}
}
}, [sched, schedule, refreshPortfolio]);
const invalidate = useCallback((target: string) => {
const pre = `${target}\u001f`;
for (const k of [...bus.current.keys()]) if (k.startsWith(pre)) emitRead(k, { kind: 'invalidate' });
const a = appsRef.current.find(x => x.target === target);
if (a) for (const r of readsFor({ folder: !!a.project, ...platformsOf(a, statusRef.current(a)) })) {
const key = readKey(target, r.method, r.args);
if (!bus.current.has(key)) schedule(target, r.method, r.args, 1).catch(() => {});
}
}, [emitRead, schedule]);
const importApps = useCallback(async (account?: string) => {
const ids = (account ? [account] : accounts.map(a => a.id)).filter(id => account || !overrides.ignored.includes(`account:${id}`));
if (!ids.length) return;
@ -236,21 +348,27 @@ export function DataProvider({ children }: { children: ReactNode }) {
useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]);
// Settings > Refresh: on launch, then every N minutes (store reads use the accounts' API quota: off by default)
// 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).
const launched = useRef(false);
useEffect(() => {
if (!ready || launched.current) return;
if (!ready || launched.current || !apps.length) return;
launched.current = true;
if (settings.refresh.onLaunch && (projects.length || accounts.length)) importApps().finally(() => refreshPortfolio(true));
}, [ready, settings.refresh.onLaunch, projects.length, accounts.length, importApps, refreshPortfolio]);
const busyRef = useRef(portfolioBusy);
busyRef.current = portfolioBusy;
if (settings.refresh.autoRead) readAll(false).catch(() => {});
}, [ready, apps.length, settings.refresh.autoRead, readAll]);
useEffect(() => {
const m = settings.refresh.autoMinutes;
if (!m) return;
const tm = setInterval(() => { if (!busyRef.current) refreshPortfolio(true); }, m * 60_000);
return () => clearInterval(tm);
}, [settings.refresh.autoMinutes, refreshPortfolio]);
if (!settings.refresh.autoRead) return;
let hiddenAt = 0;
const vis = () => {
if (document.hidden) { hiddenAt = Date.now(); return; }
if (hiddenAt && Date.now() - hiddenAt > 60_000) readAll(false).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); };
}, [settings.refresh.autoRead, settings.refresh.everyMinutes, readAll]);
// icons: cached first, then read (offline, cheap)
useEffect(() => {
@ -296,12 +414,15 @@ export function DataProvider({ children }: { children: ReactNode }) {
const liveFor = useCallback((a: UnifiedApp) => (a.project ? liveOf(a.project) : portfolio?.apps.find(x => x.project === a.target && x.release.source === 'live') ?? live?.value.apps.find(x => x.project === a.target)), [liveOf, portfolio, live]);
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;
const readerState = useMemo<ReaderState>(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]);
const value = useMemo<Data>(() => ({
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,
importBusy, icons, reader: readerState, schedule, onRead, isReading, readAll, invalidate, setFocus,
refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
toasts, toast, updateToast, dismiss, cancelJob,
}), [ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
}), [readerState, 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 <Ctx.Provider value={value}>{children}</Ctx.Provider>;
@ -309,6 +430,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
export interface ReadState<T> {
data: T | null;
/** Past its TTL (lib/freshness.ts): a refresh is due or running. */
stale?: boolean;
/** When `data` was read (from the store or the cache). */
at: string | null;
fromCache: boolean;
@ -339,7 +462,7 @@ export function useWrite(projectId: string | null) {
const o = await api.writeWithConfirm(w.method, projectId, w.args, job);
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(o.dryRun)), progress: undefined, job: undefined });
else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined });
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); d.invalidate(projectId); }
// exit 3: the command stopped for an owner decision it did not get (a price change of a live product: the
// Catalog tab's owner action applies it); everything else it could do was done
else if (o.result?.exitCode === 3) { d.updateToast(tid, { kind: 'info', title: t('write.declined'), detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
@ -369,52 +492,63 @@ function notifyAfterRead(method: string, prev: unknown, next: unknown, settings:
/**
* One read method of one app (a project id, or a store-only app's store target). The cached value shows at once with
* its time; offline reads (`network: false`) then refresh by themselves, store reads only on demand (they use the
* account's API quota). A failed read keeps the last good value and says so: never an empty "all fine".
* its time; then, past its TTL (lib/freshness.ts), it is read again through the background scheduler (offline reads
* always; store reads while Settings > Reading > auto-read is on). A read the background reader runs for this key
* lands here too (the read bus). A failed read keeps the last good value and says so: never an empty "all fine".
*/
export function useRead<T>(target: string, method: string, o: { network: boolean; args?: Record<string, unknown>; title?: string; appName?: string; skip?: boolean }): ReadState<T> {
const d = useData();
const argsKey = JSON.stringify(o.args ?? {});
const key = readKey(target, method, JSON.parse(argsKey) as Record<string, unknown>);
const [s, set] = useState<Omit<ReadState<T>, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
const seq = useRef(0);
const dataRef = useRef<T | null>(null);
dataRef.current = s.data;
const dRef = useRef(d);
dRef.current = d;
const skipRef = useRef(o.skip);
skipRef.current = o.skip;
const refresh = useCallback(async () => {
const my = ++seq.current;
const job = newJob(method);
set(x => ({ ...x, loading: true, error: null }));
const tid = o.network ? d.toast({ id: job, kind: 'job', title: o.title ?? t('toast.reading'), detail: t('toast.readingDetail'), progress: null, job }) : null;
try {
const r = await api.call<ToolResult>(method, target, { args: JSON.parse(argsKey) as Record<string, unknown> }, job);
if (my !== seq.current) return;
if (usable(method, r)) {
notifyAfterRead(method, dataRef.current, r.data, d.settings, o.appName);
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, error: null, command: r.command });
if (tid) d.updateToast(tid, { kind: 'ok', title: t('toast.readOk', { what: o.title ?? method }), detail: undefined, progress: undefined, job: undefined });
} else {
const msg = failureText(r);
set(x => ({ ...x, loading: false, error: msg, command: r.command }));
if (tid) d.updateToast(tid, { kind: 'bad', title: t('toast.readFailedWhat', { what: o.title ?? method }), detail: withHint(msg), progress: undefined, job: undefined });
if (skipRef.current) return;
set(x => ({ ...x, loading: true }));
await dRef.current.schedule(target, method, JSON.parse(argsKey) as Record<string, unknown>, 0).catch(() => {});
}, [target, method, argsKey]);
// results of this key, whoever asked for them (this card, the background reader, a write's invalidation)
useEffect(() => {
if (o.skip) return;
return d.onRead(key, e => {
if (e.kind === 'start') set(x => ({ ...x, loading: true }));
else if (e.kind === 'invalidate') { set(x => ({ ...x, stale: true })); refresh(); }
else if (e.kind === 'error') { if (/cancel/i.test(e.error)) set(x => ({ ...x, loading: false })); else set(x => ({ ...x, loading: false, error: oneLine(e.error) })); }
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 }));
}
} catch (e) {
if (my !== seq.current) return;
const msg = errorText(e);
set(x => ({ ...x, loading: false, error: msg }));
if (tid) d.updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailedWhat', { what: o.title ?? method }), detail: msg, progress: undefined, job: undefined });
}
}, [target, method, argsKey, o.network, o.title, o.appName, d.settings]); // eslint-disable-line react-hooks/exhaustive-deps
});
}, [key, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
let alive = true;
if (o.skip) { set({ data: null, at: null, fromCache: false, loading: false, error: null, command: null }); return; }
const auto = !o.network || dRef.current.settings.refresh.autoRead;
const running = dRef.current.isReading(key);
set({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
const go = (at: string | null) => {
if (!alive) return;
const due = !o.network || isStale(method, at);
if (running) set(x => ({ ...x, loading: true }));
else if (auto && due) refresh();
else set(x => ({ ...x, loading: false }));
};
api.cached<ToolResult>(method, target, JSON.parse(argsKey) as Record<string, unknown>).then(c => {
if (!alive) return;
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: !o.network, error: null, command: c.value.command });
else set(x => ({ ...x, loading: !o.network }));
if (!o.network) refresh();
}, () => { if (alive) { set(x => ({ ...x, loading: !o.network })); if (!o.network) refresh(); } });
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: true, stale: isStale(method, c.at), error: null, command: c.value.command });
go(c && c.value.data !== undefined ? c.at : null);
}, () => go(null));
return () => { alive = false; };
}, [target, method, argsKey, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps

View file

@ -169,6 +169,11 @@ svg { flex: none; }
.topbar .spacer { flex: 1; }
.stamp { display: inline-flex; align-items: center; gap: 6px; font-size: var(--t-meta); color: var(--muted); white-space: nowrap; }
.stamp svg { width: 13px; height: 13px; }
.read-ctl { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.read-stamp { font-size: var(--t-meta); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 360px; }
.read-stamp.read-err { color: var(--warn-ink); }
.reader-line { display: flex; align-items: center; gap: 6px; font-size: var(--t-meta); color: var(--muted); min-width: 0; }
.reader-line > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dev-pill { font: 700 10px/1 var(--mono); letter-spacing: .06em; color: var(--warn-ink); background: var(--warn-bg); border: 1px solid color-mix(in srgb, var(--warn-solid) 45%, transparent); padding: 5px 7px; border-radius: 5px; }
.content { flex: 1; overflow: auto; min-height: 0; scroll-behavior: smooth; }
.page { padding: 24px 28px 48px; max-width: 1360px; margin: 0 auto; display: grid; gap: 20px; }
@ -794,6 +799,14 @@ h2[tabindex="-1"]:focus, h2[tabindex="-1"]:focus-visible { outline: none; }
.roll-ctl > .small:first-child { min-width: 150px; }
.table tbody tr.edit-row td { background: var(--surface-2); }
.table tbody tr.edit-row:hover td { background: var(--surface-2); }
.issue-row { cursor: pointer; }
.issue-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.issue-row .chev { flex: none; color: var(--muted); transition: transform var(--speed) var(--ease); }
.issue-row .chev.rot { transform: rotate(90deg); }
.ellipsis { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.issue-detail { display: grid; gap: 12px; padding: 4px 0; min-width: 0; }
.issue-title { overflow-wrap: anywhere; }
.stack-trace { margin: 0; max-height: 280px; overflow: auto; padding: 10px 12px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line, var(--border)); font: 12px/1.5 var(--mono, ui-monospace, monospace); white-space: pre; }
.build-edit { display: grid; gap: 10px; padding: 4px 0; max-width: 760px; }
textarea.input { height: auto; padding: 8px 10px; line-height: 1.45; resize: vertical; font-family: var(--font); }
.reply-edit { min-height: 110px; }

View file

@ -4,7 +4,7 @@ import { StarIcon } from '@phosphor-icons/react/dist/csr/Star';
import type { ReactNode } from 'react';
import type { ReleasePhase, StoreRelease } from '../api';
import { t } from '../i18n';
import { share } from '../lib/format';
import { ago, share } from '../lib/format';
import { hueOf, monogram, type Tone } from '../lib/model';
import { fixHint } from '../lib/views';
import { Icon, type IconName, StoreGlyph } from './icons';
@ -173,3 +173,17 @@ export function StudioMark({ studio, size = 'md' }: { studio: { name: string; co
export function StoreOnlyTag() {
return <span className="chip outline so-tag" title={t('apps.storeOnlyHint')}><Icon name="cloud" />{t('apps.storeOnly')}</span>;
}
/** The state of one read beside its card's title: "read 3 min ago" with a small spinner while it refreshes, or
* "couldn't refresh: <reason>" over the last good value, and the per-card refresh icon (no "Read" button first). */
export function ReadControl({ r, label }: { r: { data: unknown; at: string | null; loading: boolean; error: string | null; refresh: () => Promise<void> }; label?: string }) {
const text = r.error ? t('read.couldNotRefresh', { reason: r.error }) : r.at ? t('read.ago', { when: ago(r.at) }) : r.loading ? t('read.waiting') : '';
return (
<span className="read-ctl" data-read-state={r.loading ? 'loading' : r.error ? 'error' : r.data !== null ? 'ok' : 'none'}>
{text && <span className={`read-stamp ${r.error ? 'read-err' : ''}`} title={r.error ?? undefined}>{text}</span>}
<button className="btn btn-sm btn-icon" onClick={() => { r.refresh(); }} disabled={r.loading} aria-label={label ?? t('read.refresh')} title={label ?? t('read.refresh')}>
<Icon name="refresh" className={r.loading ? 'spin' : undefined} />
</button>
</span>
);
}

View file

@ -39,6 +39,8 @@
},
"refresh": {
"autoMinutes": 0,
"autoRead": true,
"everyMinutes": 15,
"icons": true,
"onLaunch": false,
"parallel": 4

View file

@ -84,6 +84,8 @@
},
"refresh": {
"autoMinutes": 0,
"autoRead": true,
"everyMinutes": 15,
"icons": true,
"onLaunch": false,
"parallel": 4

View file

@ -22,6 +22,8 @@
},
"refresh": {
"autoMinutes": 0,
"autoRead": true,
"everyMinutes": 15,
"icons": true,
"onLaunch": false,
"parallel": 4

View file

@ -81,6 +81,8 @@
},
"refresh": {
"autoMinutes": 0,
"autoRead": true,
"everyMinutes": 15,
"icons": true,
"onLaunch": false,
"parallel": 4

View file

@ -0,0 +1,257 @@
// The background read scheduler (src/lib/scheduler.ts) with a fake clock: concurrency per lane, priority, dedupe,
// spacing per credential, backoff with jitter on 429, slowing down and pausing on App Store Connect's quota header;
// and the TTLs / plan of src/lib/freshness.ts, the "Copy for AI" markdown of src/lib/issues.ts.
import { describe, expect, test } from 'bun:test';
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor, ttlOf } from '../src/lib/freshness';
import { appleSignatureView, issueMarkdown, playIssueView } from '../src/lib/issues';
import { type Clock, type Outcome, ReadScheduler, type ReadTask } from '../src/lib/scheduler';
class FakeClock implements Clock {
t = 0;
private timers: { at: number; fn: () => void; id: number }[] = [];
private n = 0;
rand = 0.5;
now() { return this.t; }
setTimeout(fn: () => void, ms: number) { const id = ++this.n; this.timers.push({ at: this.t + ms, fn, id }); return id; }
clearTimeout(h: unknown) { this.timers = this.timers.filter(x => x.id !== h); }
random() { return this.rand; }
/** Moves time forward, firing due timers in order (and letting promise callbacks run in between). */
async advance(ms: number) {
const end = this.t + ms;
for (;;) {
await flush();
const next = this.timers.filter(x => x.at <= end).sort((a, b) => a.at - b.at)[0];
if (!next) break;
this.t = next.at;
this.timers = this.timers.filter(x => x !== next);
next.fn();
}
this.t = end;
await flush();
}
}
const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
/** A task whose run resolves when the test says so. */
function controlled() {
const runs: { key: string; done: (v: unknown) => void; fail: (e: unknown) => void }[] = [];
const task = (key: string, o: Partial<ReadTask<unknown>> = {}): ReadTask<unknown> => ({
key, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, ...o,
run: () => new Promise((done, fail) => { runs.push({ key, done, fail }); }),
});
return { runs, task };
}
describe('ReadScheduler', () => {
test('at most N at once per lane, the rest wait for a slot', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 2, play: 1 } });
const { runs, task } = controlled();
for (const k of ['a', 'b', 'c', 'd']) s.enqueue(task(k)).catch(() => {});
s.enqueue(task('p1', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {});
s.enqueue(task('p2', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {});
await clock.advance(0);
expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1']);
expect(s.stats()).toMatchObject({ running: 3, queued: 3, total: 6 });
runs[0]!.done(1);
await clock.advance(0);
expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1', 'c']);
// a task in both lanes needs a slot in each
const both = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1, play: 1 } });
const c2 = controlled();
both.enqueue(c2.task('apple-only')).catch(() => {});
both.enqueue(c2.task('both', { lanes: ['apple', 'play'], credentials: [] })).catch(() => {});
both.enqueue(c2.task('play-only', { lanes: ['play'], credentials: [] })).catch(() => {});
await clock.advance(0);
expect(c2.runs.map(r => r.key)).toEqual(['apple-only', 'play-only']);
});
test('priority first (visible app, portfolio, the rest), FIFO within one; re-enqueueing raises it', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1 } });
const { runs, task } = controlled();
s.enqueue(task('first')).catch(() => {});
s.enqueue(task('rest-1', { priority: 2 })).catch(() => {});
s.enqueue(task('rest-2', { priority: 2 })).catch(() => {});
s.enqueue(task('portfolio', { priority: 1 })).catch(() => {});
s.enqueue(task('visible', { priority: 0 })).catch(() => {});
s.enqueue(task('rest-2', { priority: 0 })).catch(() => {}); // the owner opened that tab
await clock.advance(0);
for (let i = 0; i < 5; i++) { runs.at(-1)!.done(i); await clock.advance(0); }
expect(runs.map(r => r.key)).toEqual(['first', 'rest-2', 'visible', 'portfolio', 'rest-1']);
});
test('one in-flight read per key: the same promise, one run', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 0 });
const { runs, task } = controlled();
const p1 = s.enqueue(task('k'));
const p2 = s.enqueue(task('k'));
await clock.advance(0);
const p3 = s.enqueue(task('k'));
expect(s.has('k')).toBe(true);
expect(runs).toHaveLength(1);
runs[0]!.done('v');
expect(await Promise.all([p1, p2, p3])).toEqual(['v', 'v', 'v']);
expect(s.has('k')).toBe(false);
// finished: a new enqueue runs again
s.enqueue(task('k')).catch(() => {});
await clock.advance(0);
expect(runs).toHaveLength(2);
});
test('starts on one credential are spaced; other credentials are not held up', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 400, limits: { apple: 8 } });
const { runs, task } = controlled();
for (const k of ['a', 'b', 'c']) s.enqueue(task(k)).catch(() => {});
s.enqueue(task('other', { credentials: ['apple:k2'] })).catch(() => {});
await clock.advance(0);
expect(runs.map(r => r.key)).toEqual(['a', 'other']);
await clock.advance(399);
expect(runs).toHaveLength(2);
await clock.advance(1);
expect(runs.map(r => r.key)).toEqual(['a', 'other', 'b']);
await clock.advance(400);
expect(runs.map(r => r.key)).toEqual(['a', 'other', 'b', 'c']);
});
test('429: runs again later with exponential backoff and jitter, the key waits too; gives up after maxAttempts', async () => {
const clock = new FakeClock();
let attempts = 0;
const classify = (r: unknown): Outcome => ({ throttled: (r as { status: number }).status === 429 });
const s = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 1000, backoffMaxMs: 60_000, maxAttempts: 4, classify });
const starts: number[] = [];
const p = s.enqueue({ key: 'x', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { starts.push(clock.now()); attempts++; return { status: attempts < 3 ? 429 : 200 }; } });
let other = false;
await clock.advance(0);
s.enqueue({ key: 'y', lanes: ['play'], credentials: ['play:k'], priority: 2, run: async () => { other = true; return { status: 200 }; } }).catch(() => {});
await clock.advance(0);
// first try at 0; jitter (rand 0.5) -> 0.75 x the exponential step: 750 ms, then 1500 ms
expect(starts).toEqual([0]);
expect(s.stats().paused['play:k']).toBe(750);
await clock.advance(749);
expect(starts).toEqual([0]);
expect(other).toBe(false); // the throttled key holds back its other reads as well
await clock.advance(1);
expect(starts).toEqual([0, 750]);
await clock.advance(1500);
expect(starts).toEqual([0, 750, 2250]);
expect(await p).toEqual({ status: 200 });
await clock.advance(0);
expect(other).toBe(true);
// always 429: resolved with the last answer after maxAttempts, counted as failed
const s2 = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 10, maxAttempts: 3, classify });
let n = 0;
const q = s2.enqueue({ key: 'z', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { n++; return { status: 429 }; } });
await clock.advance(1000);
expect(await q).toEqual({ status: 429 });
expect(n).toBe(3);
});
test('App Store Connect quota: spreads the rest of the hour when it runs low, pauses at the reserve', async () => {
const clock = new FakeClock();
let remaining = 3412;
const classify = (_r: unknown, _e: unknown, t: ReadTask<unknown>): Outcome => ({ quota: { credential: t.credentials[0]!, limit: 3600, remaining } });
const s = new ReadScheduler({ clock, spacingMs: 100, reserve: 100, slowBelow: 0.2, pauseMs: 600_000, limits: { apple: 4 }, classify });
const starts: number[] = [];
const run = async () => { starts.push(clock.now()); return {}; };
const add = (k: string) => s.enqueue({ key: k, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, run }).catch(() => {});
add('a'); add('b');
await clock.advance(0);
await clock.advance(100);
expect(starts).toEqual([0, 100]); // plenty left: the base spacing
expect(s.stats().quota['apple:k1']).toMatchObject({ limit: 3600, remaining: 3412 });
remaining = 500; // under 20 %: (500 - 100 reserve) left for the hour -> one start every 9 s
add('c'); add('d');
await clock.advance(100);
const c = starts.at(-1)!;
await clock.advance(8_000);
expect(starts.at(-1)).toBe(c);
await clock.advance(1_100);
expect(starts.at(-1)! - c).toBe(9_000);
remaining = 80; // at the reserve: the key pauses for 10 minutes
add('e'); add('f');
await clock.advance(9_000);
const e = starts.at(-1)!;
await clock.advance(599_000);
expect(starts.at(-1)).toBe(e);
expect(Object.keys(s.stats().paused)).toEqual(['apple:k1']);
await clock.advance(2_000);
expect(starts.at(-1)! - e).toBe(600_000);
// the quota a run reported stays the lowest of the hour, and is forgotten after it
remaining = 3000;
add('g');
await clock.advance(600_000);
expect(s.stats().quota['apple:k1']!.remaining).toBe(80);
await clock.advance(3_600_000);
expect(s.stats().quota['apple:k1']).toBeUndefined();
});
test('progress for the status line: done of total in one round, failures, clear()', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1 } });
const { runs, task } = controlled();
const ps = ['a', 'b', 'c'].map(k => s.enqueue(task(k)).catch(e => String(e)));
await clock.advance(0);
runs[0]!.done(1);
await clock.advance(0);
expect(s.stats()).toMatchObject({ done: 1, running: 1, queued: 1, total: 3 });
runs[1]!.fail(new Error('boom'));
await clock.advance(0);
expect(s.stats()).toMatchObject({ done: 2, failed: 1, total: 3 });
s.clear();
runs[2]?.done(1);
await clock.advance(0);
expect(await ps[1]).toBe('Error: boom');
expect(s.stats()).toMatchObject({ done: 0, total: 0, running: 0, queued: 0 });
});
});
describe('freshness', () => {
test('TTLs: status 5 min, reviews 15 min, catalog / prices / listings 30 min, health / analytics 6 h', () => {
expect([ttlOf('apple.status'), ttlOf('play.status'), ttlOf('reviews.list'), ttlOf('catalog.diff'), ttlOf('prices.current'), ttlOf('project.listings'), ttlOf('health'), ttlOf('analytics.compare')].map(x => x / 60_000))
.toEqual([5, 5, 15, 30, 30, 30, 360, 360]);
const now = Date.parse('2026-10-13T12:00:00Z');
expect(isStale('apple.status', '2026-10-13T11:56:00Z', now)).toBe(false);
expect(isStale('apple.status', '2026-10-13T11:55:00Z', now)).toBe(true);
expect(isStale('health', '2026-10-13T07:00:00Z', now)).toBe(false);
expect(isStale('health', null, now)).toBe(true);
expect(isStale('health', 'garbage', now)).toBe(true);
});
test('lanes, the reads of an app, keys, throttling text, one-line reasons', () => {
expect(lanesOf('apple.status', { ios: true, android: true })).toEqual(['apple']);
expect(lanesOf('health', { ios: true, android: true })).toEqual(['apple', 'play']);
expect(lanesOf('health', { ios: false, android: true })).toEqual(['play']);
expect(lanesOf('scan', { ios: true, android: true })).toEqual(['local']);
const all = readsFor({ folder: true, ios: true, android: true }).map(r => r.method);
expect(all).toEqual(expect.arrayContaining(['apple.status', 'play.status', 'apple.testflight_status', 'reviews.list', 'health', 'catalog.diff', 'prices.current', 'project.listings', 'validate', 'scan', 'analytics.compare', 'aso.audit', 'apple.ppo_status']));
expect(readsFor({ folder: false, ios: false, android: true }).map(r => r.method)).toEqual(['play.status', 'reviews.list', 'health']);
expect(readKey('p1', 'reviews.list', { days: 30 })).toBe(readKey('p1', 'reviews.list', { days: 30 }));
expect(readKey('p1', 'reviews.list', { days: 30 })).not.toBe(readKey('p1', 'reviews.list', {}));
expect(looksThrottled('GET /v1/apps -> 429 Too Many Requests')).toBe(true);
expect(looksThrottled('{"error":{"status":"RESOURCE_EXHAUSTED"}}')).toBe(true);
expect(looksThrottled('-> 403 forbidden')).toBe(false);
expect(oneLine('12:00:01 [health] reading\n12:00:02 [health] error: key not found\n')).toBe('error: key not found');
});
});
describe('issue detail', () => {
test('Copy for AI: title, type, counts, versions, top frames, console link', () => {
const v = playIssueView({ type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.Combo.update', reports: 412, users: 268, firstVersion: '224', lastVersion: '231', id: 'a1', uri: 'https://play.google.com/console/x' });
expect(v.sampleArgs).toEqual({ issue: 'a1' });
const stack = Array.from({ length: 20 }, (_, i) => `\tat com.example.F${i}(F.kt:${i})`);
const md = issueMarkdown('Prism Puddle', v, { store: 'play', version: '231', device: 'Galaxy A12', os: 'API 31', stack });
expect(md).toContain('## Google Play CRASH: java.lang.IllegalStateException');
expect(md).toContain('- Reports: 412, users: 268');
expect(md).toContain('- versionCode: 224 to 231');
expect(md).toContain('F14(F.kt:14)');
expect(md).not.toContain('F15(F.kt:15)');
expect(md).toContain('... 5 more frames');
expect(md).toContain('Console: https://play.google.com/console/x');
const a = appleSignatureView({ build: '48', type: 'HANGS', signature: 'ComboMeter.update + 212', weight: 0.31, id: 's1' });
expect(a.sampleArgs).toEqual({ signature: 's1' });
expect(issueMarkdown('X', a, null)).toContain('- Share of reports: 31 %');
expect(playIssueView({ type: 'CRASH', cause: '', location: '', reports: 1, users: 1 }).sampleArgs).toBeNull();
});
});

View file

@ -4,6 +4,7 @@ import { createHash, createPrivateKey, sign } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { ascKey } from '../lib/credentials';
import { errorBody } from '../lib/http';
import { noteAsc } from '../lib/quota';
import { AmbiguousWriteError, retrySafe } from '../lib/upsert';
import * as rt from '../lib/rt';
@ -77,6 +78,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
if (!safe) throw new AmbiguousWriteError(`${method} ${path} -> no answer (${(e as Error).message})`);
throw e;
}
noteAsc(r);
const t = await r.text();
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 5) { await rt.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${path} -> ${r.status} ${errorBody(t)}`, r.status);
@ -89,6 +91,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
async function getAs(path: string, accept: string): Promise<J> {
for (let attempt = 1; ; attempt++) {
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
noteAsc(r);
const t = await r.text();
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await rt.sleep(2000 * attempt); continue; }
if (!r.ok) throw new Error(`GET ${path} -> ${r.status} ${errorBody(t, 800)}`);
@ -101,6 +104,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
async function bytes(path: string, accept = 'application/a-gzip'): Promise<Uint8Array | null> {
for (let attempt = 1; ; attempt++) {
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
noteAsc(r);
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await r.body?.cancel(); await rt.sleep(2000 * attempt); continue; }
if (r.status === 404) { await r.body?.cancel(); return null; }
if (!r.ok) throw new Error(`GET ${path.replace(/\?.*$/, '')} -> ${r.status} ${errorBody(await r.text(), 600)}`);

View file

@ -14,7 +14,7 @@ import type { Ctx } from './lib/ctx';
import { REPORTING_SCOPE } from './lib/google-auth';
import {
type AppleHealth, ANR_METRICS, betaCrashCounts, CRASH_METRICS, deviceBreakdown, healthFindings, healthMarkdown, type HealthSnapshot, healthTable,
issuesQuery, latestDailyEnd, metricQuery, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, ymd, ymdString,
type IssueSample, issuesQuery, latestDailyEnd, metricQuery, parseDiagnosticLogs, parseErrorReports, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, reportsQuery, ymd, ymdString,
} from './lib/health';
import { done, log, printJson, warn } from './lib/log';
import { plainError, storeReadError } from './lib/google-errors';
@ -55,7 +55,7 @@ export async function collectAppleHealth(root: string, bundleId: string, appIdHi
for (const b of builds) {
log('health', `apple: diagnostic signatures of build ${b.attributes.version}`);
for (const s of ((await c.asc('GET', `/v1/builds/${b.id}/diagnosticSignatures?limit=10`).catch(none({ data: [] }))).data ?? []) as J[])
signatures.push({ build: b.attributes.version, type: s.attributes.diagnosticType, signature: s.attributes.signature ?? '', weight: Number(s.attributes.weight ?? 0) });
signatures.push({ build: b.attributes.version, type: s.attributes.diagnosticType, signature: s.attributes.signature ?? '', weight: Number(s.attributes.weight ?? 0), ...(s.id ? { id: String(s.id) } : {}) });
}
signatures.sort((a, b) => b.weight - a.weight);
log('health', 'apple: TestFlight crash reports');
@ -65,6 +65,24 @@ export async function collectAppleHealth(root: string, bundleId: string, appIdHi
return { metrics, signatures: signatures.slice(0, 15), betaCrashes: betaCrashCounts(subs, buildNo, since) };
}
/** One sample report of a Play error issue (`--issue <id>`, errorReports:search over the last `days`). */
export async function playIssueSample(root: string, pkg: string, issueId: string, days: number): Promise<IssueSample | null> {
const g = googleClient(root, REPORTING_SCOPE);
const app = `${REPORTING_API}/apps/${encodeURIComponent(pkg)}`;
const set = await g.json<Parameters<typeof latestDailyEnd>[0]>('GET', `${app}/crashRateMetricSet`);
const end = latestDailyEnd(set) ?? ymd(new Date(), 2);
log('health', `play: a sample report of issue ${issueId}`);
return parseErrorReports(await g.json('GET', `${app}/errorReports:search?${reportsQuery(end, days, issueId)}`));
}
/** The logs of one App Store diagnostic signature (`--signature <id>`). */
export async function appleSignatureSample(root: string, bundleId: string, appIdHint: string | undefined, id: string): Promise<IssueSample | null> {
if (!/^[A-Za-z0-9_-]{1,200}$/.test(id)) throw new Error('--signature: a diagnostic signature id');
const c = await ascClient(root, bundleId, appIdHint);
log('health', `apple: logs of diagnostic signature ${id}`);
return parseDiagnosticLogs(await c.getAs(`/v1/diagnosticSignatures/${encodeURIComponent(id)}/logs?limit=1`, 'application/vnd.apple.diagnostic-logs+json'));
}
/** Both stores (or the chosen one); a store that cannot be read becomes { error } instead of failing the run. */
export async function collectHealth(ctx: Ctx, o: { apple: boolean; play: boolean; days: number }): Promise<HealthSnapshot> {
const { config, root } = ctx.loaded;
@ -91,10 +109,23 @@ export async function run(ctx: Ctx): Promise<void> {
const weekly = a.has('weekly');
const days = a.num('days', 7);
if (!(days >= 1 && days <= 90)) throw new Error('--days: 1-90');
const issue = a.get('issue'), signature = a.get('signature');
if (issue || signature) {
// one issue's sample stack (the desktop's Health detail): --issue <Play error issue id> or --signature <App Store id>
const { config, root } = ctx.loaded;
if (issue && !config.play) throw new Error('--issue: the config has no play section');
if (signature && !config.apple) throw new Error('--signature: the config has no apple section');
const sample = issue ? await playIssueSample(root, config.play!.packageName, issue, Math.max(days, 28)) : await appleSignatureSample(root, config.apple!.bundleId, config.apple!.appId, signature!);
if (a.has('json')) { printJson({ sample }); return; }
console.log(sample ? [`${sample.store} ${sample.type ?? ''} ${sample.version ?? ''} ${sample.device ?? ''} ${sample.os ?? ''}`.replace(/\s+/g, ' ').trim(), ...sample.stack].join('\n') : 'no sample report in the window');
return;
}
const h = await collectHealth(ctx, { apple: both || a.has('apple'), play: both || a.has('play'), days });
if (a.has('json')) { printJson(h); return; }
console.log(healthTable(h));
for (const [s, x] of [['Google Play', h.play], ['App Store', h.apple]] as const) if (x && 'error' in x) console.log(`\n${s}: ${x.error}`);
const play = h.play && !('error' in h.play) ? h.play : null;
for (const i of play?.issues.filter(x => x.hint) ?? []) console.log(`\nlikely cause of ${i.cause.slice(0, 80)}: ${i.hint!.cause}\nfix: ${i.hint!.fix}`);
console.log('\n' + formatFindings(healthFindings(h)));
if (weekly) {
const dir = ctx.loaded.resolve(a.get('out') ?? '.storebound/health');

22
cli/lib/crash-hints.ts Normal file
View file

@ -0,0 +1,22 @@
// "Likely cause" hints for known crash signatures, shared by `storebound health` and the desktop Health tab.
// Only entries verified in a real app (or documented by the library itself) go here: a wrong hint costs more than none.
export interface CrashHint { id: string; cause: string; fix: string }
interface Rule extends CrashHint { match: (text: string) => boolean }
const RULES: Rule[] = [
{
id: 'capacitor-permission-states-npe',
match: x => /NullPointerException/.test(x) && /com\.getcapacitor\.Bridge\.getPermissionStates/.test(x),
cause: 'A Capacitor plugin\'s checkPermissions() (for example FirebaseMessaging) ran during an activity transition (focus / visibilitychange while the app resumes or rotates), when the bridge has no activity.',
fix: 'Ask for the permission state only when App.getState() says active, the page is visible and the app has been active for a short delay (e.g. 500 ms); do not call checkPermissions() from focus / visibilitychange handlers directly.',
},
];
/** The hint for an issue's text (cause, location, top frames), or null. */
export function crashHint(...texts: (string | undefined | null)[]): CrashHint | null {
const text = texts.filter(Boolean).join('\n');
const r = RULES.find(x => x.match(text));
return r ? { id: r.id, cause: r.cause, fix: r.fix } : null;
}

View file

@ -75,6 +75,8 @@ export interface ToolResult {
outcome?: Outcome;
/** failed only: the command's own error line (stderr's last "error: ..." line, else its last meaningful line). */
error?: string;
/** Store API quota the run saw (App Store Connect X-Rate-Limit, Google 429): the desktop's read scheduler slows down on it. */
quota?: import('./quota').QuotaInfo;
}
export type Outcome = 'ok' | 'findings' | 'declined' | 'failed';

View file

@ -16,6 +16,7 @@
// https://developer.apple.com/documentation/appstoreconnectapi/power-and-performance-metrics-and-logs
// GET /v1/apps/{id}/perfPowerMetrics (application/vnd.apple.xcode-metrics+json), GET /v1/builds/{id}/diagnosticSignatures
// (HANGS, DISK_WRITES, LAUNCHES), GET /v1/apps/{id}/betaFeedbackCrashSubmissions (TestFlight crash reports, API 4.0+).
import { type CrashHint, crashHint } from './crash-hints';
import type { Finding, Level } from './validate-rules';
import { mdTable, pct, textTable } from './table';
import { findingText, type GoogleFinding, parseGoogleError, plainError } from './google-errors';
@ -89,11 +90,79 @@ export function issuesQuery(end: Ymd, days: number, o: { type?: 'CRASH' | 'ANR';
return p.toString();
}
export interface Issue { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string }
export interface Issue {
/** Likely cause of a known signature (cli/lib/crash-hints.ts). */
hint?: CrashHint;
type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string;
/** The issue id (the last part of its resource name "apps/<pkg>/errorIssues/<id>"): `health --issue <id>` reads a sample report. */
id?: string;
/** versionCode of the first and last app version with the issue; Android API levels; the last report's time. */
firstVersion?: string; firstOs?: string; lastOs?: string; lastSeen?: string;
}
export function parseIssues(resp: { errorIssues?: { type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string; lastAppVersion?: { versionCode?: string }; issueUri?: string }[] }): Issue[] {
return (resp.errorIssues ?? []).map(i => ({ type: i.type === 'APPLICATION_NOT_RESPONDING' ? 'ANR' : i.type ?? '?', cause: i.cause ?? '', location: i.location ?? '',
reports: Number(i.errorReportCount ?? 0), users: Number(i.distinctUsers ?? 0), lastVersion: i.lastAppVersion?.versionCode, uri: i.issueUri }));
interface ApiIssue {
name?: string; type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string; issueUri?: string; lastErrorReportTime?: string;
firstAppVersion?: { versionCode?: string }; lastAppVersion?: { versionCode?: string }; firstOsVersion?: { apiLevel?: string }; lastOsVersion?: { apiLevel?: string };
}
export function parseIssues(resp: { errorIssues?: ApiIssue[] }): Issue[] {
return (resp.errorIssues ?? []).map(i => {
const extra: Partial<Issue> = {};
const id = i.name?.split('/').pop();
if (id) extra.id = id;
if (i.firstAppVersion?.versionCode) extra.firstVersion = i.firstAppVersion.versionCode;
if (i.firstOsVersion?.apiLevel) extra.firstOs = i.firstOsVersion.apiLevel;
if (i.lastOsVersion?.apiLevel) extra.lastOs = i.lastOsVersion.apiLevel;
if (i.lastErrorReportTime) extra.lastSeen = i.lastErrorReportTime;
const hint = crashHint(i.cause, i.location);
if (hint) extra.hint = hint;
return { type: i.type === 'APPLICATION_NOT_RESPONDING' ? 'ANR' : i.type ?? '?', cause: i.cause ?? '', location: i.location ?? '',
reports: Number(i.errorReportCount ?? 0), users: Number(i.distinctUsers ?? 0), lastVersion: i.lastAppVersion?.versionCode, uri: i.issueUri, ...extra };
});
}
/** errorReports:search query of one issue (the newest report first), over the same UTC-midnight window as the issues. */
export function reportsQuery(end: Ymd, days: number, issueId: string): string {
if (!/^[A-Za-z0-9_-]{1,200}$/.test(issueId)) throw new Error(`--issue: an error issue id (letters, digits, - and _), not "${issueId.slice(0, 40)}"`);
const p = new URLSearchParams(issuesQuery(end, days));
p.delete('orderBy');
p.set('filter', `errorIssueId = ${issueId}`);
p.set('pageSize', '1');
return p.toString();
}
/** One sample report of an issue (Play errorReports, App Store diagnostic logs): the stack as lines, top frame first. */
export interface IssueSample { store: 'play' | 'apple'; time?: string; version?: string; os?: string; device?: string; type?: string; stack: string[] }
const MAX_FRAMES = 60;
export function parseErrorReports(resp: { errorReports?: { reportText?: string; eventTime?: string; type?: string; appVersion?: { versionCode?: string }; osVersion?: { apiLevel?: string };
deviceModel?: { marketingName?: string; deviceId?: { buildBrand?: string; buildDevice?: string } } }[] }): IssueSample | null {
const r = resp.errorReports?.[0];
if (!r) return null;
const stack = (r.reportText ?? '').split('\n').map(l => l.replace(/\s+$/, '')).filter(l => l.trim()).slice(0, MAX_FRAMES);
const dev = r.deviceModel?.marketingName ?? [r.deviceModel?.deviceId?.buildBrand, r.deviceModel?.deviceId?.buildDevice].filter(Boolean).join('/');
return { store: 'play', stack, ...(r.eventTime ? { time: r.eventTime } : {}), ...(r.appVersion?.versionCode ? { version: r.appVersion.versionCode } : {}),
...(r.osVersion?.apiLevel ? { os: `API ${r.osVersion.apiLevel}` } : {}), ...(dev ? { device: dev } : {}), ...(r.type ? { type: r.type } : {}) };
}
interface ApiFrame { symbolName?: string; binaryName?: string; offsetIntoSymbol?: number; subFrames?: ApiFrame[] }
/** GET /v1/diagnosticSignatures/{id}/logs (application/vnd.apple.diagnostic-logs+json): the first log's heaviest call
* stack, following the first sub frame down, as "binary symbol + offset" lines. */
export function parseDiagnosticLogs(resp: { productData?: { diagnosticLogs?: { callStackTree?: { callStacks?: { callStackRootFrames?: ApiFrame[] }[] }[];
diagnosticMetaData?: { appVersion?: string; buildVersion?: string; osVersion?: string; deviceType?: string; eventTime?: string } }[] }[] }): IssueSample | null {
const log = resp.productData?.flatMap(p => p.diagnosticLogs ?? [])[0];
if (!log) return null;
const stack: string[] = [];
let f: ApiFrame | undefined = log.callStackTree?.[0]?.callStacks?.[0]?.callStackRootFrames?.[0];
while (f && stack.length < MAX_FRAMES) {
stack.push(`${f.binaryName ?? '?'} ${f.symbolName ?? '???'}${f.offsetIntoSymbol !== undefined ? ` + ${f.offsetIntoSymbol}` : ''}`);
f = f.subFrames?.[0];
}
const m = log.diagnosticMetaData ?? {};
const version = [m.appVersion, m.buildVersion ? `(${m.buildVersion})` : ''].filter(Boolean).join(' ');
return { store: 'apple', stack, ...(version ? { version } : {}), ...(m.osVersion ? { os: m.osVersion } : {}), ...(m.deviceType ? { device: m.deviceType } : {}), ...(m.eventTime ? { time: m.eventTime } : {}) };
}
export interface PlayHealth {
@ -111,7 +180,7 @@ export interface AppleHealth {
/** Latest values per metric identifier (e.g. hangRate, launchTime) from perfPowerMetrics, with the app version. */
metrics: { category: string; metric: string; unit: string; version: string; value: number; goal?: string }[];
/** Top diagnostic signatures of the latest builds. */
signatures: { build: string; type: string; signature: string; weight: number }[];
signatures: { build: string; type: string; signature: string; weight: number; /** `health --signature <id>` reads its logs. */ id?: string }[];
/** TestFlight crash submissions in the window (count per build). */
betaCrashes: { build: string; count: number }[];
}
@ -262,7 +331,8 @@ export function healthMarkdown(h: HealthSnapshot, o: { appName: string; previous
['user-perceived ANR rate', pct(a), pct(prev ? weightedAverage(prev.anr, 'userPerceivedAnrRate') : null), delta(a, prev ? weightedAverage(prev.anr, 'userPerceivedAnrRate') : null), pct(PLAY_THRESHOLDS.anr)],
]), '');
if (p.issues.length) out.push('### Top issues', '', mdTable(['type', 'cause', 'location', 'reports', 'users', 'last versionCode'],
p.issues.map(i => [i.type, i.cause.slice(0, 80), i.location.slice(0, 80), i.reports, i.users, i.lastVersion ?? ''])), '');
p.issues.map(i => [i.type, i.cause.slice(0, 80), i.location.slice(0, 80), i.reports, i.users, i.lastVersion ?? ''])), '',
...p.issues.filter(i => i.hint).map(i => `- ${i.cause.slice(0, 80)}: likely cause: ${i.hint!.cause} Fix: ${i.hint!.fix}`), ...(p.issues.some(i => i.hint) ? [''] : []));
}
const ap = h.apple && !('error' in h.apple) ? h.apple : null;
if (ap) {

51
cli/lib/quota.ts Normal file
View file

@ -0,0 +1,51 @@
// Store API quota seen by a command, passed to `storebound serve` (the desktop's read scheduler) as one marked stderr
// line per change: App Store Connect's X-Rate-Limit header ("user-hour-lim:3600;user-hour-rem:3412;") and 429 /
// RESOURCE_EXHAUSTED answers of the Google APIs. Outside serve (STOREBOUND_MACHINE unset) nothing is printed.
/** Prefix of the quota line on stderr; runner.ts takes it out of the output. */
export const QUOTA_MARK = '\u001estorebound-quota ';
export interface QuotaInfo {
store: 'apple' | 'play';
/** Requests per hour of this key (App Store Connect). */
limit?: number;
/** Requests left in the current hour. */
remaining?: number;
/** The store answered 429 / RESOURCE_EXHAUSTED at least once in this run. */
throttled?: boolean;
}
/** "user-hour-lim:3600;user-hour-rem:3412;" -> { limit: 3600, remaining: 3412 }; null when it is not there. */
export function parseAscRateLimit(header: string | null | undefined): { limit: number; remaining: number } | null {
if (!header) return null;
const lim = /user-hour-lim:(\d+)/i.exec(header), rem = /user-hour-rem:(\d+)/i.exec(header);
return lim && rem ? { limit: Number(lim[1]), remaining: Number(rem[1]) } : null;
}
let last = '';
function emit(q: QuotaInfo): void {
if (process.env.STOREBOUND_MACHINE !== '1') return;
const line = JSON.stringify(q);
if (line === last) return;
last = line;
process.stderr.write(QUOTA_MARK + line + '\n');
}
/** After every App Store Connect answer. */
export function noteAsc(r: { status: number; headers: { get(n: string): string | null } }): void {
const rl = parseAscRateLimit(r.headers.get('x-rate-limit'));
if (rl) emit({ store: 'apple', ...rl, ...(r.status === 429 ? { throttled: true } : {}) });
else if (r.status === 429) emit({ store: 'apple', throttled: true });
}
/** After every Google answer (Play Developer API, Reporting API). */
export function noteGoogle(status: number): void {
if (status === 429) emit({ store: 'play', throttled: true });
}
/** Folds the marked lines of one run into one value (the latest counts; throttled if any line said so). */
export function mergeQuota(a: QuotaInfo | undefined, b: QuotaInfo): QuotaInfo {
if (!a || a.store !== b.store) return b;
const remaining = b.remaining ?? a.remaining, limit = b.limit ?? a.limit;
return { store: b.store, ...(limit === undefined ? {} : { limit }), ...(remaining === undefined ? {} : { remaining }), ...(a.throttled || b.throttled ? { throttled: true } : {}) };
}

View file

@ -4,6 +4,7 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { RESULT_MARK } from './log';
import { mergeQuota, QUOTA_MARK, type QuotaInfo } from './quota';
import { toolPath } from './tools';
import * as rt from './rt';
@ -29,7 +30,7 @@ export interface RunOptions {
}
/** `err`: the stderr lines only (for the command's own error line). */
export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean }
export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean; /** Store quota the run saw (cli/lib/quota.ts). */ quota?: QuotaInfo }
export interface Running { result: Promise<RunResult>; cancel(): void }
@ -73,11 +74,16 @@ export function runCommand(argv: string[], o: RunOptions): Running {
const lines: string[] = [];
const errs: string[] = [];
let data: unknown;
let quota: QuotaInfo | undefined;
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
if (kind === 'stdout' && line.startsWith(RESULT_MARK)) {
try { data = JSON.parse(line.slice(RESULT_MARK.length)); } catch { lines.push(line); }
return;
}
if (kind === 'stderr' && line.startsWith(QUOTA_MARK)) {
try { quota = mergeQuota(quota, JSON.parse(line.slice(QUOTA_MARK.length)) as QuotaInfo); } catch { /* a broken quota line is dropped */ }
return;
}
lines.push(line);
if (kind === 'stderr') errs.push(line);
o.onLine?.(line, kind);
@ -85,7 +91,7 @@ export function runCommand(argv: string[], o: RunOptions): Running {
const result = (async (): Promise<RunResult> => {
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
const code = await p.exited;
return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled };
return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), ...(quota ? { quota } : {}), cancelled };
})();
return { result, cancel: () => { cancelled = true; p.kill(); } };
}

View file

@ -58,6 +58,10 @@ export const TOOLS: ToolDef[] = [
description: 'Read-only crash / ANR vitals from the Play Developer Reporting API (user-perceived rates vs the bad behavior thresholds 1.09 % / 0.47 %, worst device models, top issues) and App Store power and performance metrics, diagnostic signatures and TestFlight crash reports. Exit 1 when a metric is over a store threshold.',
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 7 } },
args: i => [...(i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []), ...opt('days', i.days)] },
{ name: 'health_issue', title: 'Crash issue sample', command: ['health'], write: false, network: true,
description: 'Read-only sample report of one crash / ANR issue: a Play error issue (issue: its id from health --json) via errorReports:search, or the logs of an App Store diagnostic signature (signature: its id). The stack trace as lines, with version, device and OS.',
props: { issue: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,200}$', description: 'Play error issue id' }, signature: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,200}$', description: 'App Store diagnostic signature id' } },
args: i => [...opt('issue', i.issue), ...opt('signature', i.signature)] },
{ name: 'health_report', title: 'Weekly health report', command: ['health', '--weekly'], write: true, network: true, idempotent: true,
description: 'Weekly health summary as Markdown in .storebound/health/<date>.md (plus <date>.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' } },

View file

@ -3,6 +3,7 @@ import { playServiceAccount } from '../lib/credentials';
import { tokenSource } from '../lib/google-auth';
import { log, warn } from '../lib/log';
import { errorBody } from '../lib/http';
import { noteGoogle } from '../lib/quota';
import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert';
import * as rt from '../lib/rt';
@ -35,6 +36,7 @@ export async function playClient(root: string, packageName: string) {
if (!safe) throw new AmbiguousWriteError(`${method} ${short()} -> no answer (${(e as Error).message})`);
throw e;
}
noteGoogle(r.status);
const text = await r.text();
if (r.ok) return (text ? JSON.parse(text) : {}) as T;
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 4) { await rt.sleep(2000 * (attempt + 1)); continue; }

View file

@ -3,6 +3,7 @@
import { playServiceAccount } from '../lib/credentials';
import { tokenSource } from '../lib/google-auth';
import { errorBody } from '../lib/http';
import { noteGoogle } from '../lib/quota';
import * as rt from '../lib/rt';
export const REPORTING_API = 'https://playdeveloperreporting.googleapis.com/v1beta1';
@ -13,6 +14,7 @@ export function googleClient(root: string, scope: string) {
for (let attempt = 0; ; attempt++) {
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body) });
noteGoogle(r.status);
if ((r.status === 429 || r.status >= 500) && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; }
return r;
}

View file

@ -49,7 +49,7 @@ export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_r
export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i;
/** Read tools whose command prints a structured result with --json. */
export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status',
export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'health_issue', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status',
'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status', 'catalog_diff',
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current']);
@ -233,7 +233,7 @@ const SYSTEM: MethodDescriptor[] = [
];
/** Read methods a store-only app (params.virtual, no app folder) may use: store reads that need only the store ids. */
export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status']);
export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status']);
const PKG_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$/;
const BUNDLE_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,154}$/;
@ -445,7 +445,7 @@ export class Session {
const error = outcome === 'failed' ? errorLine(res.err ?? '', res.out) : '';
return {
method, ok: res.code === 0, exitCode: res.code, dryRun: t.write ? dryRun : false, command: `storebound ${argv.join(' ')}`, output: cut(res.out),
...(res.data === undefined ? {} : { data: res.data }), outcome, ...(error ? { error } : {}),
...(res.data === undefined ? {} : { data: res.data }), outcome, ...(error ? { error } : {}), ...(res.quota ? { quota: res.quota } : {}),
};
}

View file

@ -69,10 +69,33 @@ Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli
tools, first read.
- **Command palette** (Cmd/Ctrl+K): apps, tabs, screens and actions. Cmd/Ctrl+1 Portfolio, +2-5 workspace screens,
Cmd/Ctrl+Shift+[ ] app tabs, arrow keys in the tab list. Toasts show background jobs with progress and Cancel.
- **Unknown, never false.** Store reads run on demand (they use the account's API quota) and are saved in app data
(`cache.sqlite`, results only, never keys); a screen opens with the saved read and its time ("Last read 3 h ago,
saved copy"), a failed read keeps the previous one and says so, and nothing that was not read is shown as fine.
Settings clears the saved reads.
- **Unknown, never false.** Every read is saved in app data (`cache.sqlite`, keyed by app + method + arguments,
results only, never keys); a card opens with the saved read at once and "read 3 min ago", a small spinner while it
refreshes, and a failed read keeps the previous one with "couldn't refresh: <one-line reason>". Nothing that was not
read is shown as fine. Settings clears the saved reads.
- **Automatic reading** (`src/lib/scheduler.ts`, `src/lib/freshness.ts`, `store.tsx`). On start, when the window shows
again after a minute hidden, and every N minutes while it is visible (Settings > Store reads: auto-read on by
default, every 15 min; 0 = only on start; paused while hidden), the window reads everything of every app that is past
its TTL: status and submission state 5 min, TestFlight 5 min, reviews 15 min, catalog / prices / listings / checks
30 min, health / analytics / ASO 6 h. "Refresh all" in the toolbar reads everything now; each card has a refresh
icon. Writes invalidate the app's reads. All reads go through one scheduler:
- lanes `apple`, `play`, `local`, each with at most Settings > parallel (default 4) at once; a read that needs both
stores takes a slot in each;
- starts on one credential (account) at least 250 ms apart;
- App Store Connect's `X-Rate-Limit` header (user-hour-lim / user-hour-rem, about 3600 per key per hour) reaches the
window as the ToolResult's `quota` (`cli/lib/quota.ts`: a marked stderr line under `serve`, taken out of the
output by `runner.ts`). Below 20 % left, starts are spread over the rest of the hour; at 100 left (kept for the
owner's own commands) the key pauses 10 minutes;
- 429 / RESOURCE_EXHAUSTED (Play Developer API, Reporting API; the CLI already retries inside one command): the read
runs again with exponential backoff and equal jitter (5 s base, 5 min cap, 4 attempts), and its key waits too;
- priorities: the app on screen first, then the live portfolio status, then the rest; one in-flight read per key.
The sidebar's status line says "Reading 12 of 48 · App Store limit 3,412/3,600 left this hour". The boot check's
auto-read case (13 apps, cold cache) loads everything without a click and finds no "not read yet" on any tab.
- **Health issue detail.** A top issue (Play) or diagnostic signature (App Store) opens in place: full title and type,
reports / users, first and last versionCode, Android API range, last report, a sample stack (`health.issue` =
`health --issue <id>` via errorReports:search, or `health --signature <id>` via the signature's logs; cached like
every read), "Open in Play Console" (issueUri), "Copy for AI" (compact markdown with the top 15 frames) and a likely
cause with its fix for known signatures (`cli/lib/crash-hints.ts`, shared with `storebound health`).
- **Jobs.** The window tags a call with a job id; the host maps sidecar request ids to it, so progress lines reach the
right toast, and `job_cancel` sends `$/cancelRequest` (or stops a download). Live portfolio reads run up to four
apps at a time with their own accounts and report n / total.

View file

@ -0,0 +1,61 @@
// Store quota lines for the desktop's read scheduler (cli/lib/quota.ts), crash "likely cause" hints
// (cli/lib/crash-hints.ts) and the sample report parsers of `health --issue / --signature` (cli/lib/health.ts).
import { describe, expect, test } from 'bun:test';
import { crashHint } from '../cli/lib/crash-hints';
import { parseDiagnosticLogs, parseErrorReports, parseIssues, reportsQuery } from '../cli/lib/health';
import { mergeQuota, parseAscRateLimit } from '../cli/lib/quota';
describe('quota', () => {
test('X-Rate-Limit header', () => {
expect(parseAscRateLimit('user-hour-lim:3600;user-hour-rem:3412;')).toEqual({ limit: 3600, remaining: 3412 });
expect(parseAscRateLimit('user-hour-rem:12;user-hour-lim:3500')).toEqual({ limit: 3500, remaining: 12 });
expect(parseAscRateLimit(null)).toBeNull();
expect(parseAscRateLimit('something else')).toBeNull();
});
test('merging the lines of one run keeps the latest count and any throttling', () => {
const a = mergeQuota(undefined, { store: 'apple', limit: 3600, remaining: 3500 });
const b = mergeQuota(a, { store: 'apple', throttled: true });
expect(b).toEqual({ store: 'apple', limit: 3600, remaining: 3500, throttled: true });
expect(mergeQuota(b, { store: 'apple', limit: 3600, remaining: 3490 })).toEqual({ store: 'apple', limit: 3600, remaining: 3490, throttled: true });
});
});
describe('crash hints', () => {
test('Capacitor getPermissionStates NPE', () => {
const h = crashHint('java.lang.NullPointerException', 'com.getcapacitor.Bridge.getPermissionStates');
expect(h?.id).toBe('capacitor-permission-states-npe');
expect(h?.fix).toContain('App.getState()');
expect(crashHint('java.lang.NullPointerException: Attempt to invoke virtual method', null, '\tat com.getcapacitor.Bridge.getPermissionStates(Bridge.java:1234)')?.id).toBe('capacitor-permission-states-npe');
});
test('no hint for other issues', () => {
expect(crashHint('java.lang.NullPointerException', 'com.example.Main.onCreate')).toBeNull();
expect(crashHint('java.lang.IllegalStateException', 'com.getcapacitor.Bridge.getPermissionStates')).toBeNull();
expect(crashHint(undefined, null)).toBeNull();
});
test('the issue list carries the hint and the detail fields', () => {
const [i] = parseIssues({ errorIssues: [{ name: 'apps/com.example/errorIssues/abc123', type: 'CRASH', cause: 'java.lang.NullPointerException', location: 'com.getcapacitor.Bridge.getPermissionStates',
errorReportCount: '9', distinctUsers: '7', firstAppVersion: { versionCode: '20' }, lastAppVersion: { versionCode: '24' }, firstOsVersion: { apiLevel: '29' }, lastOsVersion: { apiLevel: '35' }, lastErrorReportTime: '2026-10-12T10:00:00Z', issueUri: 'https://play.google.com/console/x' }] });
expect(i).toMatchObject({ id: 'abc123', firstVersion: '20', lastVersion: '24', firstOs: '29', lastOs: '35', lastSeen: '2026-10-12T10:00:00Z', uri: 'https://play.google.com/console/x' });
expect(i!.hint?.id).toBe('capacitor-permission-states-npe');
});
});
describe('sample reports', () => {
test('errorReports:search query and answer', () => {
const q = new URLSearchParams(reportsQuery({ year: 2026, month: 10, day: 12 }, 28, 'abc123'));
expect(q.get('filter')).toBe('errorIssueId = abc123');
expect(q.get('pageSize')).toBe('1');
expect(q.get('orderBy')).toBeNull();
expect(() => reportsQuery({ year: 2026, month: 10, day: 12 }, 28, 'a b')).toThrow();
const s = parseErrorReports({ errorReports: [{ reportText: 'java.lang.NullPointerException\n\tat com.getcapacitor.Bridge.getPermissionStates(Bridge.java:1)\n\n', eventTime: '2026-10-12T10:00:00Z', type: 'CRASH',
appVersion: { versionCode: '24' }, osVersion: { apiLevel: '34' }, deviceModel: { marketingName: 'Galaxy A12' } }] });
expect(s).toEqual({ store: 'play', stack: ['java.lang.NullPointerException', '\tat com.getcapacitor.Bridge.getPermissionStates(Bridge.java:1)'], time: '2026-10-12T10:00:00Z', version: '24', os: 'API 34', device: 'Galaxy A12', type: 'CRASH' });
expect(parseErrorReports({})).toBeNull();
});
test('App Store diagnostic logs: the first call stack, top down', () => {
const s = parseDiagnosticLogs({ productData: [{ diagnosticLogs: [{ diagnosticMetaData: { appVersion: '2.3.0', buildVersion: '48', osVersion: 'iOS 18.6', deviceType: 'iPhone15,2' },
callStackTree: [{ callStacks: [{ callStackRootFrames: [{ binaryName: 'App', symbolName: 'main', offsetIntoSymbol: 4, subFrames: [{ binaryName: 'App', symbolName: 'Combo.update' }] }] }] }] }] }] });
expect(s).toEqual({ store: 'apple', stack: ['App main + 4', 'App Combo.update'], version: '2.3.0 (48)', os: 'iOS 18.6', device: 'iPhone15,2' });
expect(parseDiagnosticLogs({ productData: [] })).toBeNull();
});
});

View file

@ -118,7 +118,8 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
case 'app_info': return { version: conf.version, identifier: conf.identifier, platform: 'macos', debug: false, home: HOME };
case 'projects_list': return projects();
case 'accounts_list': return fx.accounts_list;
case 'workspace_get': return fx.workspace_get;
// background store reads are the auto-read case's subject (runAutoReadCase); the saved-state cases check drawing
case 'workspace_get': return withAutoRead(fx.workspace_get, false);
case 'store_apps_cached': return fx.store_apps_cached ?? fx.accounts_list.map(x => ({ account: x.id, entry: null }));
case 'cache_get': {
const st = live && STORE_READS[String(a.method)];
@ -169,6 +170,12 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
};
}
/** The saved workspace with Settings > Reading > auto-read set (older states have no such field: on by default). */
function withAutoRead(w: HostFixture['workspace_get'], on: boolean): HostFixture['workspace_get'] {
const settings = (w.settings ?? {}) as { refresh?: Record<string, unknown> };
return { ...w, settings: { ...settings, refresh: { ...settings.refresh, autoRead: on } } };
}
/** Before any page script, as Tauri: the frozen prototype (tauri.conf.json freezePrototype), then the IPC object. */
const initScript = (freeze: boolean) => `(() => {
${freeze ? 'Object.freeze(Object.prototype);' : ''}
@ -582,6 +589,106 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
/** Longest wait for the background reader to read everything of every app (a loaded machine). */
const READ_ALL_MS = 120_000;
/**
* Auto-read: the dev fixture state (src/dev/fixtures.ts: 2 app folders and the store-only apps of 5 accounts, at least 8
* apps) with an empty cache. Without any click the background reader must read everything: the status line shows
* "Reading n of m" and the App Store quota, then "Everything read"; then no tab of any app says "not read yet". The
* Health tab's first top issue opens and shows its sample stack. Zero uncaught errors.
*/
async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
const name = 'auto-read (fixture state, cold cache)';
const fx = await import('../apps/desktop/src/dev/fixtures');
const freeze = conf.app.security.freezePrototype === true;
const server = serve(DIST, cspHeader(conf.app.security.csp), false);
const ctx = await browser.newContext({ viewport: { width: 1360, height: 900 }, reducedMotion: 'reduce' });
const page = await ctx.newPage();
const problems: string[] = [];
const notes: string[] = [];
const logged: string[] = [];
let reads = 0;
// fixture art is file: URLs (blocked by the CSP): icons are left out
const clean = (v: unknown): unknown => (v === undefined ? null : JSON.parse(JSON.stringify(v), (_k, x) => (typeof x === 'string' && x.startsWith('file:') ? null : x)));
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
switch (cmd) {
case 'plugin:event|listen': return Math.floor(Math.random() * 1e9);
case 'plugin:event|unlisten': return null;
case 'log_webview': logged.push(`${String(a.level)}: ${String(a.message).slice(0, 300)}`); return null;
case 'cache_get': return null;
case 'sidecar_call': {
const method = String(a.method);
if (method === 'system.tools') return clean(await fx.mockInvoke(cmd, a));
reads++;
return clean(fx.fixtureRead(method, String(a.projectId), ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {}));
}
default: return clean(await fx.mockInvoke(cmd, a));
}
};
await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record<string, unknown>) => {
try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; }
});
page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | ')));
page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); });
await page.addInitScript(initScript(freeze));
let visited = 0;
const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); };
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)'));
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); }
const apps = await page.locator('nav.side .nav-app').count();
if (apps < 8) problems.push(`want at least 8 apps in the sidebar, got ${apps}`);
// no click: the status line shows the reader's progress, then that everything is read
const line = '[data-testid="reader-line"]';
if (!(await page.waitForFunction(s => /Reading \d+ of \d+/.test(document.querySelector(s)?.textContent ?? ''), line, { timeout: WAIT_MS, polling: 20 }).then(() => true, () => false)))
problems.push('status line: no "Reading n of m" while reading');
else notes.push(`status line while reading: ${await text(line)}`);
if (!(await shows(page, line, 'Everything read', READ_ALL_MS))) problems.push(`status line: not "Everything read" after ${READ_ALL_MS / 1000} s: ${await text(line)}`);
const done = await text(line);
notes.push(`status line after: ${done}; ${reads} reads, ${apps} apps`);
if (!/App Store limit 3,412\/3,600 left this hour/.test(done)) problems.push(`status line: no App Store quota (${done})`);
await settle(page);
// every tab of every app: nothing says "not read yet" (the reads were done in the background)
const shown = await page.locator('nav.side .nav-app:visible').count();
for (let i = 0; i < shown; i++) {
await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`));
await settle(page);
const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim();
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
for (const id of tabs) {
await page.locator(`[role="tab"]#${id}`).click({ timeout: WAIT_MS }).catch(e => problems.push(`${appName} ${id}: ${String(e).split('\n')[0]}`));
await settle(page);
await crashCheck(`${appName} ${id}`);
const tx = await text();
if (/not read yet|Read the stores to see this/i.test(tx)) problems.push(`${appName} ${id}: says "${(/not read yet|Read the stores to see this/i.exec(tx) ?? [''])[0]}" after the background read`);
visited++;
}
}
// Health: the first top issue opens with its full title, the sample stack and Copy for AI
await page.locator('nav.side .nav-app', { hasText: 'Prism Puddle' }).first().click({ timeout: WAIT_MS });
await settle(page);
await page.locator('[role="tab"]#tab-health').click({ timeout: WAIT_MS });
await settle(page);
await page.locator('.issue-row').first().click({ timeout: WAIT_MS }).catch(() => problems.push('Health: no issue row to open'));
if (!(await shows(page, '[data-testid="issue-stack"]', 'ComboMeter.update(ComboMeter.kt:88)'))) problems.push('Health: the opened issue shows no sample stack');
const det = await text('[data-testid="issue-detail"]');
for (const w of ['ComboMeter.update called after the level ended', 'Copy for AI', 'Open in Play Console', '224 – 231']) if (!det.includes(w)) problems.push(`Health issue detail: missing "${w}"`);
notes.push(`issue detail: ${det.slice(0, 160)}`);
await crashCheck('Health');
} catch (e) {
problems.push(`case stopped: ${String(e).split('\n')[0]}`);
} finally {
await ctx.close();
server.stop(true);
}
for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`);
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
/**
* Errors the window must say in words (pitfall 57). `api-disabled`: the last health read of the app is Google's live
* 403 SERVICE_DISABLED (Reporting API off in Cloud project 123456789000): the portfolio shows it once for the project,
@ -724,6 +831,7 @@ export async function bootCheck(): Promise<CaseResult[]> {
if (!ONLY.length || ONLY.includes('submit') || ONLY.includes(`submit-${v}`)) cases.push(() => runSubmitCase(browser, sidecar, base, v));
for (const p404 of [true, false]) if (!ONLY.length || ONLY.includes('release-state')) cases.push(() => runReleaseStateCase(browser, sidecar, p404));
for (const v of ['api-disabled', 'scan-findings', 'scan-failed'] as const) if (!ONLY.length || ONLY.includes('errors') || ONLY.includes(`errors-${v}`)) cases.push(() => runErrorCase(browser, sidecar, v));
if (!ONLY.length || ONLY.includes('auto-read')) cases.unshift(() => runAutoReadCase(browser));
const results: CaseResult[] = new Array(cases.length);
let next = 0;
try {