diff --git a/CHANGELOG.md b/CHANGELOG.md index 327617c..d82704e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: ". 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 ` / `--signature `, 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, diff --git a/apps/desktop/src-tauri/src/workspace.rs b/apps/desktop/src-tauri/src/workspace.rs index 5be38c0..e8e7ab3 100644 --- a/apps/desktop/src-tauri/src/workspace.rs +++ b/apps/desktop/src-tauri/src/workspace.rs @@ -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()); } diff --git a/apps/desktop/src-tauri/tests/workspace.rs b/apps/desktop/src-tauri/tests/workspace.rs index 143331c..26c49b3 100644 --- a/apps/desktop/src-tauri/tests/workspace.rs +++ b/apps/desktop/src-tauri/tests/workspace.rs @@ -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()); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 12c9780..4c77cad 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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 }) {
{d.sidecar?.running ? t('foot.cli', { version: d.sidecar.version }) : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}
{lastRead ? t('foot.lastRead', { when: ago(lastRead) }) : t('foot.neverRead')}
+
@@ -217,8 +221,8 @@ export function App({ onTheme }: { onTheme: () => void }) { {FIXTURES && {t('dev.fixtures')}} {lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')} - @@ -242,3 +246,18 @@ export function App({ onTheme }: { onTheme: () => void }) { ); } + +/** 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
{parts.join(' · ')}
; +} diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 68c32f8..31a5b1f 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -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 | null } export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number } diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 496024b..5e1a63a 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -216,8 +216,8 @@ const HEALTH: Record = { 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 = { { 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) => ({ 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 = {}): 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 = {}): 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 = { }; const CACHED_AGO: Record = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 }; -const NETWORK = new Set(['health', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', '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): Promise 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 } | null)?.args) ?? {}); + return fixtureRead(method, id, ((a.params as { args?: Record } | null)?.args) ?? {}); } case 'job_cancel': cancelled.add(a.job as string); return true; case 'write_with_confirm': { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index b400165..9013ee9 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 617b88a..703ba4a 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -755,6 +755,39 @@ export const tr: Record = { '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', diff --git a/apps/desktop/src/lib/freshness.ts b/apps/desktop/src/lib/freshness.ts new file mode 100644 index 0000000..1866caa --- /dev/null +++ b/apps/desktop/src/lib/freshness.ts @@ -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 = { + '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 } + +/** 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 = {}) => 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 => `${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: ". */ +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; +} diff --git a/apps/desktop/src/lib/issues.ts b/apps/desktop/src/lib/issues.ts new file mode 100644 index 0000000..5c8660d --- /dev/null +++ b/apps/desktop/src/lib/issues.ts @@ -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 | 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'; +} diff --git a/apps/desktop/src/lib/model.ts b/apps/desktop/src/lib/model.ts index 7ee1677..6eed9e5 100644 --- a/apps/desktop/src/lib/model.ts +++ b/apps/desktop/src/lib/model.ts @@ -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; diff --git a/apps/desktop/src/lib/normalize.ts b/apps/desktop/src/lib/normalize.ts index c26f5eb..36e7844 100644 --- a/apps/desktop/src/lib/normalize.ts +++ b/apps/desktop/src/lib/normalize.ts @@ -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', diff --git a/apps/desktop/src/lib/scheduler.ts b/apps/desktop/src/lib/scheduler.ts new file mode 100644 index 0000000..14586de --- /dev/null +++ b/apps/desktop/src/lib/scheduler.ts @@ -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), + 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 { + key: string; + lanes: Lane[]; + /** Keys of the store credentials it uses ("apple:", "play:"). */ + credentials: string[]; + priority: Priority; + run: () => Promise; +} + +export interface SchedulerOptions { + limits?: Partial>; + /** 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) => 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; + /** Credentials waiting on a backoff or quota pause, with the time they may start again. */ + paused: Record; +} + +interface Item { + task: ReadTask; + seq: number; + attempt: number; + notBefore: number; + resolve: (r: R) => void; + reject: (e: unknown) => void; + promise: Promise; +} + +const HOUR = 3_600_000; + +export class ReadScheduler { + private o: Required> & { limits: Record }; + private clock: Clock; + private classify: (result: unknown, error: unknown, task: ReadTask) => Outcome; + private queue: Item[] = []; + private running = new Map>(); + private busy: Record = { apple: 0, play: 0, local: 0 }; + private nextAt = new Map(); + private quotas = new Map(); + 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>): 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(task: ReadTask): Promise { + const run = this.running.get(task.key); + if (run) return run.promise as Promise; + 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; + } + let resolve!: (r: R) => void, reject!: (e: unknown) => void; + const promise = new Promise((a, b) => { resolve = a; reject = b; }); + this.queue.push({ task, seq: ++this.seq, attempt: 0, notBefore: 0, resolve, reject, promise } as unknown as Item); + 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 = {}; + 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, 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, 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, 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 */ } } } +} diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index 1048b5c..91dc8d1 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -225,6 +225,7 @@ export function Portfolio() { ); }; + const notRead = d.portfolioBusy === 'live' ? t('read.waiting') : t('kpi.notRead'); return (
@@ -240,13 +241,13 @@ export function Portfolio() { {d.portfolioError && {d.portfolioError}} {neverImported > 0 && d.importApps()} disabled={d.importBusy}>{t('action.importApps')}}>{t('portfolio.notImported', { count: neverImported })}} - {!known && d.ready && d.refreshPortfolio(true)}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}} + {!known && d.ready && d.portfolioBusy !== 'live' && d.refreshPortfolio(true)}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}}
- - 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')} /> - `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} /> + + 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')} /> + `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index a93177b..96ded11 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -122,11 +122,11 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
-
{t('set.autoRefresh')} - 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 })]]} /> - {t('set.autoHint')}
- set(x => ({ ...x, refresh: { ...x.refresh, onLaunch: v } }))} /> + set(x => ({ ...x, refresh: { ...x.refresh, autoRead: v } }))} /> +
{t('set.everyMinutes')} + 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 })]]} /> + {t('set.everyHint')}
set(x => ({ ...x, refresh: { ...x.refresh, icons: v } }))} />
{t('set.parallelHint')}
- + {d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}
diff --git a/apps/desktop/src/screens/tabs/Catalog.tsx b/apps/desktop/src/screens/tabs/Catalog.tsx index 1eb33cc..7dedd9d 100644 --- a/apps/desktop/src/screens/tabs/Catalog.tsx +++ b/apps/desktop/src/screens/tabs/Catalog.tsx @@ -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 }) {
- r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} /> - + r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} /> +
{diff.error && } { diff.refresh(); }} /> {read ? t('action.refresh') : t('cat.compare')}}> + actions={}>
diff --git a/apps/desktop/src/screens/tabs/Checks.tsx b/apps/desktop/src/screens/tabs/Checks.tsx index 5e0dde6..32dc2a4 100644 --- a/apps/desktop/src/screens/tabs/Checks.tsx +++ b/apps/desktop/src/screens/tabs/Checks.tsx @@ -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 ( x.name).join(', ') }) : t('checks.scanSub')} pad={false} - actions={}> + actions={}> {s.error &&
} {!s.data ? (s.loading && !s.error ?
: null) : !findings.length ? {t('checks.scanCleanText')} : ( @@ -80,7 +80,7 @@ export function ChecksTab({ project }: { project: Project }) { )}
{v.data ? t('action.refresh') : t('checks.run')}}> + actions={}> {v.error &&
} {!v.data ? ( v.loading && !v.error ?
: {t('checks.validateEmptyText')} diff --git a/apps/desktop/src/screens/tabs/Growth.tsx b/apps/desktop/src/screens/tabs/Growth.tsx index cf40c5d..7849f96 100644 --- a/apps/desktop/src/screens/tabs/Growth.tsx +++ b/apps/desktop/src/screens/tabs/Growth.tsx @@ -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 ( {t('action.refresh')}}> + actions={}> {r.error ?
: !a ?
: (
@@ -167,7 +167,7 @@ function Experiments({ app }: { app: UnifiedApp }) { {r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}} pad={false} actions={<> - + }> {r.error &&
} {!r.data ? (r.loading && !r.error ?
: {t('gr.ppoNotReadText')}) diff --git a/apps/desktop/src/screens/tabs/Health.tsx b/apps/desktop/src/screens/tabs/Health.tsx index d89aa2b..9986790 100644 --- a/apps/desktop/src/screens/tabs/Health.tsx +++ b/apps/desktop/src/screens/tabs/Health.tsx @@ -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 ( +
+
{v.title}
+
+
{t('health.issueType')}
{v.kind}
+ {v.where && v.where !== v.title && <>
{t('health.issueWhere')}
{v.where}
} + {v.reports !== undefined && <>
{t('col.reports')}
{num(v.reports)}{v.users !== undefined ? ` · ${t('col.users')} ${num(v.users)}` : ''}
} + {v.weight !== undefined && <>
{t('health.issueShare')}
{num(v.weight * 100, 1)}%
} + {(v.versions?.first || v.versions?.last) && <>
{t('health.issueVersions')}
{range(v.versions.first, v.versions.last)}
} + {v.build && <>
{t('health.issueBuild')}
{v.build}
} + {v.os && <>
{t('health.issueOs')}
{range(v.os.first, v.os.last)}
} + {v.lastSeen && <>
{t('health.issueLastSeen')}
{ago(v.lastSeen)}
} +
+ {(() => { const h = hintFor(v, sample); return h ?
{t('health.likelyCause')} {h.cause}{t('health.suggestedFix')} {h.fix}
: null; })()} + {v.sampleArgs && ( +
+
{t('health.sample')}{sample ? ` · ${[sample.version, sample.device, sample.os].filter(Boolean).join(' · ')}` : ''}
+ {sample ?
{sample.stack.join('\n')}
+ : s.loading ? : !s.error ?

{t('health.noSample')}

: null} +
+ )} +
+ + {v.url && } +
+
+ ); +} + +function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) { + const [open, setOpen] = useState(null); const crash = healthSeries(h.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); const anr = healthSeries(h.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr); const devices = (h.devices ?? []).slice(0, 6); @@ -67,11 +111,11 @@ function Play({ h }: { h: PlayHealth }) {
{h.issues.slice(0, 6).map((i, n) => ( - - - - + setOpen(open === n ? null : n)} cols={3} + head={<>
{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}{i.cause || '?'}
+
{i.location}
} + cells={<>
} + detail={} /> ))}
{t('col.item')}{t('col.type')}{t('col.config')}App StoreGoogle Play{t('col.priceGap')}
{t('col.issue')}{t('col.reports')}{t('col.users')}
{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}{i.cause || '?'}
-
{i.location}
{num(i.reports)}{num(i.users)}
{num(i.reports)}{num(i.users)}
@@ -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 ( + <> + { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }} tabIndex={0} aria-expanded={open} role="button"> +
{head}
+ {cells} + + {open && {detail}} + + ); +} + +function Apple({ h, app }: { h: AppleHealth; app: UnifiedApp }) { + const [open, setOpen] = useState(null); const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count)); return (
@@ -102,15 +160,16 @@ function Apple({ h }: { h: AppleHealth }) {
{h.signatures.length ? ( -
- {h.signatures.slice(0, 5).map((s, i) => ( -
- -
{s.signature || s.type}{s.type} · {t('health.build', { build: s.build })}
- {num(s.weight * 100, 1)}% -
- ))} -
+ + + {h.signatures.slice(0, 5).map((s, i) => ( + setOpen(open === i ? null : i)} cols={2} + head={<>
{s.signature || s.type}
{s.type} · {t('health.build', { build: s.build })}
} + cells={
} + detail={} /> + ))} + +
{num(s.weight * 100, 1)}%
) : }
@@ -128,7 +187,6 @@ function Apple({ h }: { h: AppleHealth }) { export function HealthTab({ app }: { app: UnifiedApp }) { const r = useRead(app.target, 'health', { network: true, title: t('tab.health'), appName: app.name }); useExports('health', [{ id: 'health-md', label: t('export.health'), icon: 'activity', kind: 'md', file: exportName(app.name, 'health'), disabled: r.data ? false : t('export.readFirst'), build: () => healthMd(app.name, r.data!) }]); - const refresh = ; if (!r.data) { if (r.loading && !r.error) return ; return ( @@ -142,14 +200,13 @@ export function HealthTab({ app }: { app: UnifiedApp }) { return ( <>
- {r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })} - {refresh} + +
- {r.error && {t('common.readFailedKeep', { error: r.error })}}

Google Play

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

App Store

- {h.apple === undefined ? {t('health.noApple')} : isError(h.apple) ? : } + {h.apple === undefined ? {t('health.noApple')} : isError(h.apple) ? : } ); } diff --git a/apps/desktop/src/screens/tabs/Prices.tsx b/apps/desktop/src/screens/tabs/Prices.tsx index aa0496e..4f51cb1 100644 --- a/apps/desktop/src/screens/tabs/Prices.tsx +++ b/apps/desktop/src/screens/tabs/Prices.tsx @@ -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 }) { - + }>
diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index 0fcc87a..a480227 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -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 }) { ) : null; + if (!l && d.portfolioBusy === 'live') return <>{submit}; if (!l) { return ( <> diff --git a/apps/desktop/src/screens/tabs/Reviews.tsx b/apps/desktop/src/screens/tabs/Reviews.tsx index 0998440..95b5348 100644 --- a/apps/desktop/src/screens/tabs/Reviews.tsx +++ b/apps/desktop/src/screens/tabs/Reviews.tsx @@ -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 ( <>
- {list.fromCache ? t('common.cachedFrom', { when: ago(list.at) }) : t('common.readWhen', { when: ago(list.at) })} +
{project && {t('reviews.makeDrafts')}} -
- {list.error && {t('common.readFailedKeep', { error: list.error })}}
diff --git a/apps/desktop/src/screens/tabs/Submit.tsx b/apps/desktop/src/screens/tabs/Submit.tsx index e0243da..3486eb1 100644 --- a/apps/desktop/src/screens/tabs/Submit.tsx +++ b/apps/desktop/src/screens/tabs/Submit.tsx @@ -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 ( - {r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.appleSub')}} + {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')}} actions={<> - + refresh() }} label={t('sub.read')} /> }> {r.error && } @@ -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 ( - {r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.playSub')}} + {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')}} actions={<> - + }> {r.error && } diff --git a/apps/desktop/src/screens/tabs/TestFlight.tsx b/apps/desktop/src/screens/tabs/TestFlight.tsx index 2e28e90..52403de 100644 --- a/apps/desktop/src/screens/tabs/TestFlight.tsx +++ b/apps/desktop/src/screens/tabs/TestFlight.tsx @@ -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 ( <>
- {r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })} - + +
- {r.error && {t('common.readFailedKeep', { error: r.error })}} {s.missingGroups.length > 0 && ( {t('tf.groupsSync')} : undefined}> {t('tf.missingGroups', { groups: s.missingGroups.join(', ') })} diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index c1935a4..c51239f 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -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): 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; - refreshPortfolio: (live?: boolean) => Promise; + /** 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, priority: Priority) => Promise; + /** 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; + /** 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; /** Reads the apps of one account (or all) from the stores. */ importApps: (account?: string) => Promise; checkAccount: (id: string) => Promise; @@ -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(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(() => sched.stats()); + useEffect(() => { + let tm: ReturnType | 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 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(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, priority: Priority): Promise => { + const key = readKey(target, method, args); + const app = appsRef.current.find(a => a.target === target); + const pf = platformsOf(app, app ? statusRef.current(app) : undefined); + const lanes = lanesOf(method, pf); + const p = sched.enqueue({ key, lanes, credentials: credentialsOf(app, lanes), priority, run: () => api.call(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(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(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]); const value = useMemo(() => ({ ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError, - importBusy, icons, 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 {children}; @@ -309,6 +430,8 @@ export function DataProvider({ children }: { children: ReactNode }) { export interface ReadState { 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(target: string, method: string, o: { network: boolean; args?: Record; title?: string; appName?: string; skip?: boolean }): ReadState { const d = useData(); const argsKey = JSON.stringify(o.args ?? {}); + const key = readKey(target, method, JSON.parse(argsKey) as Record); const [s, set] = useState, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null }); - const seq = useRef(0); const dataRef = useRef(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(method, target, { args: JSON.parse(argsKey) as Record }, 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, 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(method, target, JSON.parse(argsKey) as Record).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 diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 5a83d17..8685ddd 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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; } diff --git a/apps/desktop/src/ui/kit.tsx b/apps/desktop/src/ui/kit.tsx index 0e3283d..f65ea4e 100644 --- a/apps/desktop/src/ui/kit.tsx +++ b/apps/desktop/src/ui/kit.tsx @@ -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 {t('apps.storeOnly')}; } + +/** 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: " 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 }; 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 ( + + {text && {text}} + + + ); +} diff --git a/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json b/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json index 798b8e9..fc2b8b4 100644 --- a/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json +++ b/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json @@ -39,6 +39,8 @@ }, "refresh": { "autoMinutes": 0, + "autoRead": true, + "everyMinutes": 15, "icons": true, "onLaunch": false, "parallel": 4 diff --git a/apps/desktop/tests/fixtures/state/d3-full.host.json b/apps/desktop/tests/fixtures/state/d3-full.host.json index 65fe250..42fd2c9 100644 --- a/apps/desktop/tests/fixtures/state/d3-full.host.json +++ b/apps/desktop/tests/fixtures/state/d3-full.host.json @@ -84,6 +84,8 @@ }, "refresh": { "autoMinutes": 0, + "autoRead": true, + "everyMinutes": 15, "icons": true, "onLaunch": false, "parallel": 4 diff --git a/apps/desktop/tests/fixtures/state/empty.host.json b/apps/desktop/tests/fixtures/state/empty.host.json index f1c3a55..cfb10be 100644 --- a/apps/desktop/tests/fixtures/state/empty.host.json +++ b/apps/desktop/tests/fixtures/state/empty.host.json @@ -22,6 +22,8 @@ }, "refresh": { "autoMinutes": 0, + "autoRead": true, + "everyMinutes": 15, "icons": true, "onLaunch": false, "parallel": 4 diff --git a/apps/desktop/tests/fixtures/state/shared-profiles.host.json b/apps/desktop/tests/fixtures/state/shared-profiles.host.json index 23b62fe..38ac38f 100644 --- a/apps/desktop/tests/fixtures/state/shared-profiles.host.json +++ b/apps/desktop/tests/fixtures/state/shared-profiles.host.json @@ -81,6 +81,8 @@ }, "refresh": { "autoMinutes": 0, + "autoRead": true, + "everyMinutes": 15, "icons": true, "onLaunch": false, "parallel": 4 diff --git a/apps/desktop/tests/scheduler.test.ts b/apps/desktop/tests/scheduler.test.ts new file mode 100644 index 0000000..b2ba8d1 --- /dev/null +++ b/apps/desktop/tests/scheduler.test.ts @@ -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 => ({ + 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): 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(); + }); +}); diff --git a/cli/apple/asc.ts b/cli/apple/asc.ts index e9202ae..0af1831 100644 --- a/cli/apple/asc.ts +++ b/cli/apple/asc.ts @@ -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 { 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 { 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)}`); diff --git a/cli/health.ts b/cli/health.ts index 3547d20..1b19376 100644 --- a/cli/health.ts +++ b/cli/health.ts @@ -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 `, errorReports:search over the last `days`). */ +export async function playIssueSample(root: string, pkg: string, issueId: string, days: number): Promise { + const g = googleClient(root, REPORTING_SCOPE); + const app = `${REPORTING_API}/apps/${encodeURIComponent(pkg)}`; + const set = await g.json[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 `). */ +export async function appleSignatureSample(root: string, bundleId: string, appIdHint: string | undefined, id: string): Promise { + 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 { const { config, root } = ctx.loaded; @@ -91,10 +109,23 @@ export async function run(ctx: Ctx): Promise { 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 or --signature + 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'); diff --git a/cli/lib/crash-hints.ts b/cli/lib/crash-hints.ts new file mode 100644 index 0000000..61420be --- /dev/null +++ b/cli/lib/crash-hints.ts @@ -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; +} diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 9c3f70d..edda93f 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -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'; diff --git a/cli/lib/health.ts b/cli/lib/health.ts index c14fe6c..c4fe084 100644 --- a/cli/lib/health.ts +++ b/cli/lib/health.ts @@ -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//errorIssues/"): `health --issue ` 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 = {}; + 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 ` 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) { diff --git a/cli/lib/quota.ts b/cli/lib/quota.ts new file mode 100644 index 0000000..f61999b --- /dev/null +++ b/cli/lib/quota.ts @@ -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 } : {}) }; +} diff --git a/cli/lib/runner.ts b/cli/lib/runner.ts index 51246f7..30dad10 100644 --- a/cli/lib/runner.ts +++ b/cli/lib/runner.ts @@ -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; 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 => { 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(); } }; } diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 64fdcb5..f726dda 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -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/.md (plus .json for the next week-over-week comparison). Local files only; dry_run prints the report.', props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' } }, diff --git a/cli/play/client.ts b/cli/play/client.ts index bb5e5a9..219060f 100644 --- a/cli/play/client.ts +++ b/cli/play/client.ts @@ -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; } diff --git a/cli/play/google.ts b/cli/play/google.ts index 2201858..9691763 100644 --- a/cli/play/google.ts +++ b/cli/play/google.ts @@ -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; } diff --git a/cli/serve.ts b/cli/serve.ts index fdf8acb..2aa1803 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -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 } : {}), }; } diff --git a/docs/desktop.md b/docs/desktop.md index bc1e63c..427a60e 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -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: ". 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 ` via errorReports:search, or `health --signature ` 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. diff --git a/tests/quota-crash-hints.test.ts b/tests/quota-crash-hints.test.ts new file mode 100644 index 0000000..a437ea9 --- /dev/null +++ b/tests/quota-crash-hints.test.ts @@ -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(); + }); +}); diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index c55fc94..617db52 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -118,7 +118,8 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set, 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, 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 }; + 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 { + 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): Promise => { + 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 } | null)?.args) ?? {})); + } + default: return clean(await fx.mockInvoke(cmd, a)); + } + }; + await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record) => { + 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 { 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 {