desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case
This commit is contained in:
parent
89256f1e80
commit
0fb4ce0001
46 changed files with 1531 additions and 141 deletions
14
CHANGELOG.md
14
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: <reason>". One scheduler keeps it inside
|
||||
the stores' limits: 4 at a time per store, starts spaced per account, slower below 20 % of App Store Connect's
|
||||
hourly limit (read from `X-Rate-Limit`) and a pause at 100 left, exponential backoff with jitter on 429 /
|
||||
RESOURCE_EXHAUSTED, the visible app first. "Refresh all" in the toolbar, a refresh icon per card, writes refresh
|
||||
what they changed, and a status line: "Reading 12 of 48 · App Store limit 3,412/3,600 left this hour".
|
||||
- **Health: open a top issue.** Full title, type, counts, versionCode range, Android API range, last report, a sample
|
||||
stack trace (`storebound health --issue <id>` / `--signature <id>`, new read `health_issue`), Open in Play Console,
|
||||
Copy for AI, and a likely cause for known signatures (`cli/lib/crash-hints.ts`; first entry: the Capacitor
|
||||
`Bridge.getPermissionStates` NullPointerException from `checkPermissions()` during an activity transition).
|
||||
|
||||
- **Google API errors in words, not JSON.** Google's error bodies are parsed centrally (`cli/lib/google-errors.ts`)
|
||||
into typed findings: `api-disabled` (403 SERVICE_DISABLED: service, Cloud project id, the console page to turn it
|
||||
on), `no-app-access` (403 PERMISSION_DENIED: which app, the Users and permissions step), `auth-revoked` (401,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import { FIXTURES, onDeepLink, onDeepLinkFolder } from './api';
|
||||
import { devSwitch } from './dev/switches';
|
||||
import { type Key, t } from './i18n';
|
||||
import { ago } from './lib/format';
|
||||
import { ago, num } from './lib/format';
|
||||
import { groupByStudio, type UnifiedApp } from './lib/identity';
|
||||
import { NavCtx, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
|
||||
import { usePrefs } from './prefs-ctx';
|
||||
|
|
@ -133,6 +133,9 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }]
|
||||
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
|
||||
const lastRead = d.live?.at ?? null;
|
||||
// the app on screen is read first by the background reader
|
||||
const { setFocus } = d;
|
||||
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
|
||||
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
|
||||
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
|
||||
const groups = d.studios.length ? groupByStudio(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }];
|
||||
|
|
@ -202,6 +205,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
|
||||
<span>{d.sidecar?.running ? t('foot.cli', { version: d.sidecar.version }) : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}</span></div>
|
||||
<div className="row"><Icon name="clock" className="sr-icon" /><span>{lastRead ? t('foot.lastRead', { when: ago(lastRead) }) : t('foot.neverRead')}</span></div>
|
||||
<ReaderLine />
|
||||
</div>
|
||||
</nav>
|
||||
<div className="main">
|
||||
|
|
@ -217,8 +221,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
<span className="spacer" />
|
||||
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
|
||||
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
|
||||
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy || !visible.length} title={t('action.readStoresHint')}>
|
||||
<Icon name="refresh" className={d.portfolioBusy === 'live' ? 'spin' : undefined} />{d.portfolioBusy === 'live' ? t('action.reading') : t('action.readStores')}
|
||||
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length} title={t('action.refreshAllHint')} data-testid="refresh-all">
|
||||
<Icon name="refresh" className={d.reader.running ? 'spin' : undefined} />{t('action.refreshAll')}
|
||||
</button>
|
||||
<ExportMenu />
|
||||
</header>
|
||||
|
|
@ -242,3 +246,18 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
</NavCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The background reader in one line: "Reading 12 of 48 · App Store limit 3 412/3 600 left this hour". */
|
||||
function ReaderLine() {
|
||||
const d = useData();
|
||||
const r = d.reader;
|
||||
const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0];
|
||||
const parts: string[] = [];
|
||||
if (r.total > 0 && (r.running || r.queued)) parts.push(t('reader.progress', { done: r.done, total: r.total }));
|
||||
else if (!d.reader.autoRead) parts.push(t('reader.off'));
|
||||
else if (d.ready && d.apps.length) parts.push(t('reader.upToDate'));
|
||||
if (Object.keys(r.paused).length) parts.push(t('reader.backoff'));
|
||||
if (q) parts.push(t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) }));
|
||||
if (!parts.length) return null;
|
||||
return <div className="row reader-line" data-testid="reader-line" aria-live="polite"><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span title={parts.join(' · ')}>{parts.join(' · ')}</span></div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,13 @@ export type StudioColor = (typeof STUDIO_COLORS)[number];
|
|||
/** A user-named group of store accounts (and so of apps). */
|
||||
export interface Studio { id: string; name: string; color: StudioColor; logo: string | null; created: string }
|
||||
export interface Settings {
|
||||
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean };
|
||||
/** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */
|
||||
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 };
|
||||
cache: { keepDays: 0 | 7 | 30 | 90 | 365 };
|
||||
notify: { reviewState: boolean; lowReviews: boolean; crash: boolean };
|
||||
channel: 'stable' | 'beta';
|
||||
}
|
||||
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' };
|
||||
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' };
|
||||
export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings }
|
||||
export interface AccountApps { account: string; entry: CacheEntry<AppsListResult> | null }
|
||||
export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number }
|
||||
|
|
|
|||
|
|
@ -216,8 +216,8 @@ const HEALTH: Record<string, HealthSnapshot> = {
|
|||
anr: rows('Anr', series(28, 0.0024, 0.0004, 2), 0.0027),
|
||||
devices: [{ model: 'Galaxy A12', crash: 0.061, users: 1900 }, { model: 'Redmi 9A', crash: 0.042, users: 1400 }, { model: 'Moto G Power (2021)', crash: 0.019, users: 900 }, { model: 'Pixel 6a', crash: 0.008, users: 2100 }, { model: 'Galaxy S23', crash: 0.004, users: 2600 }],
|
||||
issues: [
|
||||
{ type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.prismpuddle.combo.ComboMeter.update', reports: 412, users: 268, lastVersion: '231' },
|
||||
{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.prismpuddle.MainActivity', reports: 133, users: 120, lastVersion: '231' },
|
||||
{ type: 'CRASH', cause: 'java.lang.IllegalStateException: ComboMeter.update called after the level ended (com.example.prismpuddle.combo.ComboMeter)', location: 'com.example.prismpuddle.combo.ComboMeter.update', reports: 412, users: 268, lastVersion: '231', firstVersion: '224', firstOs: '26', lastOs: '35', lastSeen: iso(95), id: 'a1b2c3', uri: 'https://play.google.com/console/developers/app/vitals/crashes/a1b2c3/details' },
|
||||
{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.prismpuddle.MainActivity', reports: 133, users: 120, lastVersion: '231', firstVersion: '229', lastSeen: iso(300), id: 'd4e5f6', uri: 'https://play.google.com/console/developers/app/vitals/crashes/d4e5f6/details' },
|
||||
{ type: 'CRASH', cause: 'java.lang.OutOfMemoryError', location: 'android.graphics.Bitmap.createBitmap', reports: 74, users: 61, lastVersion: '221' },
|
||||
],
|
||||
},
|
||||
|
|
@ -230,7 +230,7 @@ const HEALTH: Record<string, HealthSnapshot> = {
|
|||
{ category: 'Battery', metric: 'Foreground energy', unit: '%/h', version: '2.3.0', value: 9.4, goal: 'poor' },
|
||||
],
|
||||
signatures: [
|
||||
{ build: '48', type: 'HANGS', signature: 'ComboMeter.update(delta:) + 212', weight: 0.31 },
|
||||
{ build: '48', type: 'HANGS', signature: 'ComboMeter.update(delta:) + 212', weight: 0.31, id: 'sig-hang-1' },
|
||||
{ build: '48', type: 'DISK_WRITES', signature: 'SaveStore.flush() + 88', weight: 0.12 },
|
||||
{ build: '47', type: 'LAUNCHES', signature: 'AssetCache.warmUp() + 64', weight: 0.07 },
|
||||
],
|
||||
|
|
@ -411,7 +411,23 @@ const STORE_REVIEWS: Review[] = [
|
|||
R('play', 's-p2', 2, 'Too many ads', 'An ad after every round is too much.', 'en', 5),
|
||||
R('apple', 's-a3', 5, 'Harika', 'Her gün kullanıyorum, çok sade.', 'TUR', 7),
|
||||
];
|
||||
/** A sample report of a Health issue (`health --issue / --signature --json`). */
|
||||
const ISSUE_SAMPLE = (args: Record<string, unknown>) => ({ sample: args.signature ? {
|
||||
store: 'apple', version: '2.3.0 (48)', device: 'iPhone15,2', os: 'iOS 18.6', stack: ['PrismPuddle ComboMeter.update(delta:) + 212', 'PrismPuddle GameLoop.tick() + 88', 'QuartzCore CA::Display::DisplayLink::dispatch_items + 1012', 'UIKitCore -[UIApplication _run] + 888', 'PrismPuddle main + 64'],
|
||||
} : {
|
||||
store: 'play', type: 'CRASH', version: '231', device: 'Galaxy A12', os: 'API 31', time: iso(95),
|
||||
stack: ['java.lang.IllegalStateException: ComboMeter.update called after the level ended', '\tat com.example.prismpuddle.combo.ComboMeter.update(ComboMeter.kt:88)', '\tat com.example.prismpuddle.level.Level.tick(Level.kt:214)', '\tat com.example.prismpuddle.GameLoop.run(GameLoop.kt:51)', '\tat android.os.Handler.handleCallback(Handler.java:958)', '\tat android.os.Looper.loop(Looper.java:294)'],
|
||||
} });
|
||||
|
||||
/** One read as the sidecar would answer it (exported for the desktop boot check's auto-read case). */
|
||||
export function fixtureRead(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
||||
const r = read(method, id, args);
|
||||
// App Store Connect reads carry the key's hourly quota (X-Rate-Limit), so the status line has something to show
|
||||
return method.startsWith('apple.') && r.ok ? { ...r, quota: { store: 'apple', limit: 3600, remaining: 3412 } } : r;
|
||||
}
|
||||
|
||||
function read(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
||||
if (method === 'health.issue') return ok(method, ISSUE_SAMPLE(args));
|
||||
if (id.startsWith('store:')) {
|
||||
const play = /android:/.test(id);
|
||||
switch (method) {
|
||||
|
|
@ -475,7 +491,7 @@ const CONSOLE_FILL: Record<ConsoleForm, ConsoleFillResult> = {
|
|||
};
|
||||
|
||||
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 };
|
||||
const NETWORK = new Set(['health', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
|
||||
const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
|
||||
|
||||
async function progressLines(job: string | null, lines: string[]) {
|
||||
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
|
||||
|
|
@ -549,7 +565,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]);
|
||||
else await sleep(150);
|
||||
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
|
||||
return read(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
|
||||
return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
|
||||
}
|
||||
case 'job_cancel': cancelled.add(a.job as string); return true;
|
||||
case 'write_with_confirm': {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -755,6 +755,39 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'set.refreshSub': 'Okumalar her hesabın API kotasını kullanır: siz istediğinizde ya da buradaki zamanlamayla çalışır',
|
||||
'set.autoRefresh': 'Mağazaları otomatik oku',
|
||||
'set.autoHint': 'Uygulama açıkken. Varsayılan olarak kapalı.',
|
||||
'health.likelyCause': 'Olası neden:',
|
||||
'health.suggestedFix': 'Çözüm:',
|
||||
'health.copied': "Yapay zekâ için kopyalandı: ajanınıza yapıştırın",
|
||||
'health.copyFailed': "Panoya kopyalanamadı",
|
||||
'health.issueType': "Tür",
|
||||
'health.issueWhere': "Konum",
|
||||
'health.issueShare': "Raporlardaki payı",
|
||||
'health.issueVersions': "versionCode",
|
||||
'health.issueBuild': "Derleme",
|
||||
'health.issueOs': "Android API",
|
||||
'health.issueLastSeen': "Son rapor",
|
||||
'health.sample': "Örnek yığın izi",
|
||||
'health.noSample': "Son 28 günde örnek rapor yok.",
|
||||
'health.copyForAi': "Yapay zekâ için kopyala",
|
||||
'health.openConsole': "Play Console'da aç",
|
||||
'health.issuesUnread': "Öne çıkan hatalar okunamadı: {error}",
|
||||
'set.autoRead': "Her şeyi otomatik oku",
|
||||
'set.autoReadHint': "Her uygulamanın durumu, sürümleri, kataloğu, fiyatları, mağaza metinleri, TestFlight, sağlık, yorumlar ve kontrolleri arka planda, mağaza limitlerinin içinde okunur",
|
||||
'set.everyMinutes': "Uygulama açıkken yeniden okuma aralığı",
|
||||
'set.onlyOnStart': "Sadece açılışta",
|
||||
'set.everyHint': "Yalnızca süresi dolan okumalar çalışır (durum 5 dk, yorumlar 15 dk, katalog ve fiyatlar 30 dk, sağlık ve analiz 6 sa). Pencere gizliyken durur.",
|
||||
'action.refreshAll': "Hepsini yenile",
|
||||
'action.refreshAllHint': "Her uygulamanın her şeyini şimdi yeniden oku (salt okunur, mağaza limitlerinin içinde)",
|
||||
'read.ago': "{when} okundu",
|
||||
'read.refreshing': "Yenileniyor…",
|
||||
'read.couldNotRefresh': "yenilenemedi: {reason}",
|
||||
'read.refresh': "Yenile",
|
||||
'read.waiting': "Okunuyor…",
|
||||
'reader.progress': "{total} okumadan {done} tamam",
|
||||
'reader.upToDate': "Her şey okundu",
|
||||
'reader.off': "Otomatik okuma kapalı",
|
||||
'reader.appleQuota': "App Store limiti: bu saat {left}/{limit} kaldı",
|
||||
'reader.backoff': "Mağaza yavaşlamamızı istedi: bekleniyor",
|
||||
'set.off': 'Kapalı',
|
||||
'set.min': '{n} dk',
|
||||
'set.hour': '{n} sa',
|
||||
|
|
|
|||
77
apps/desktop/src/lib/freshness.ts
Normal file
77
apps/desktop/src/lib/freshness.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// How long a saved read stays fresh, which store lanes a read uses, and the reads the background reader does for
|
||||
// every app. Pure (tests/scheduler.test.ts).
|
||||
import type { Lane } from './scheduler';
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
/** Time to live of a saved read per method: status and submission state 5 min, reviews 15 min, catalog / prices /
|
||||
* listings 30 min, health and analytics 6 h (the stores update them daily). */
|
||||
export const TTL: Record<string, number> = {
|
||||
'portfolio.status': 5 * MIN,
|
||||
'apple.status': 5 * MIN,
|
||||
'play.status': 5 * MIN,
|
||||
'apple.testflight_status': 5 * MIN,
|
||||
'reviews.list': 15 * MIN,
|
||||
'catalog.diff': 30 * MIN,
|
||||
'prices.current': 30 * MIN,
|
||||
'project.listings': 30 * MIN,
|
||||
'apple.ppo_status': 30 * MIN,
|
||||
validate: 30 * MIN,
|
||||
scan: 30 * MIN,
|
||||
health: 6 * 60 * MIN,
|
||||
'health.issue': 6 * 60 * MIN,
|
||||
'analytics.compare': 6 * 60 * MIN,
|
||||
'aso.audit': 6 * 60 * MIN,
|
||||
};
|
||||
export const DEFAULT_TTL = 30 * MIN;
|
||||
|
||||
export const ttlOf = (method: string): number => TTL[method] ?? DEFAULT_TTL;
|
||||
|
||||
/** True when a read saved at `at` (ISO) is older than its TTL, or there is none. */
|
||||
export function isStale(method: string, at: string | null | undefined, now = Date.now()): boolean {
|
||||
if (!at) return true;
|
||||
const t = Date.parse(at);
|
||||
return !Number.isFinite(t) || now - t >= ttlOf(method);
|
||||
}
|
||||
|
||||
/** The store lanes a read takes a slot in. */
|
||||
export function lanesOf(method: string, o: { ios: boolean; android: boolean }): Lane[] {
|
||||
if (method.startsWith('apple.')) return ['apple'];
|
||||
if (method.startsWith('play.')) return ['play'];
|
||||
if (['project.listings', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
|
||||
const l: Lane[] = [];
|
||||
if (o.ios) l.push('apple');
|
||||
if (o.android) l.push('play');
|
||||
return l.length ? l : ['local'];
|
||||
}
|
||||
|
||||
export interface PlannedRead { method: string; args: Record<string, unknown> }
|
||||
|
||||
/** The reads the background reader does for one app (the ones its tabs show), by what the app has. */
|
||||
export function readsFor(o: { folder: boolean; ios: boolean; android: boolean }): PlannedRead[] {
|
||||
const r: PlannedRead[] = [];
|
||||
const add = (method: string, args: Record<string, unknown> = {}) => r.push({ method, args });
|
||||
if (o.ios) { add('apple.status'); add('apple.testflight_status'); }
|
||||
if (o.android) add('play.status');
|
||||
if (o.ios || o.android) { add('reviews.list', { days: 30 }); add('health'); }
|
||||
if (o.folder) {
|
||||
add('scan'); add('project.listings');
|
||||
if (o.ios || o.android) { add('catalog.diff'); add('prices.current'); add('validate'); }
|
||||
add('analytics.compare'); add('aso.audit');
|
||||
}
|
||||
if (o.ios) add('apple.ppo_status');
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Key of one read in the scheduler and the read bus. */
|
||||
export const readKey = (target: string, method: string, args: Record<string, unknown> = {}): string => `${target}\u001f${method}\u001f${JSON.stringify(args)}`;
|
||||
|
||||
/** "429", "RESOURCE_EXHAUSTED", "rate limit": the store refused for its quota (not the read itself). */
|
||||
export const looksThrottled = (text: string | undefined): boolean => !!text && /\b429\b|RESOURCE_EXHAUSTED|rate.?limit|quota exceeded/i.test(text);
|
||||
|
||||
/** The one-line reason of a failed read, for "couldn't refresh: <reason>". */
|
||||
export function oneLine(text: string, max = 140): string {
|
||||
const l = text.trim().split('\n').map(s => s.trim()).filter(Boolean).at(-1) ?? '';
|
||||
const s = l.replace(/^\S+ \[[^\]]+\] /, '');
|
||||
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
||||
}
|
||||
60
apps/desktop/src/lib/issues.ts
Normal file
60
apps/desktop/src/lib/issues.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// One crash / ANR issue in detail (Health tab): the Play error issue or App Store diagnostic signature, its sample
|
||||
// stack (`health.issue`), and the compact "Copy for AI" markdown. Pure (tests/model.test.ts).
|
||||
import { type CrashHint, crashHint } from '../../../../cli/lib/crash-hints';
|
||||
import type { IssueSample, PlayIssue, AppleSignature } from './model';
|
||||
|
||||
/** Likely cause of a known signature, from the issue text and its sample stack. */
|
||||
export const hintFor = (v: IssueView, sample: IssueSample | null): CrashHint | null => crashHint(v.title, v.where, ...(sample?.stack.slice(0, 30) ?? []));
|
||||
|
||||
export interface IssueView {
|
||||
store: 'play' | 'apple';
|
||||
title: string;
|
||||
/** Exception type / diagnostic type. */
|
||||
kind: string;
|
||||
where?: string;
|
||||
reports?: number;
|
||||
users?: number;
|
||||
weight?: number;
|
||||
versions?: { first?: string; last?: string };
|
||||
os?: { first?: string; last?: string };
|
||||
lastSeen?: string;
|
||||
build?: string;
|
||||
url?: string;
|
||||
/** Args of the `health.issue` read for its sample stack; null when there is no id to ask for. */
|
||||
sampleArgs: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export function playIssueView(i: PlayIssue): IssueView {
|
||||
return {
|
||||
store: 'play', title: i.cause || i.location || '?', kind: i.type === 'ANR' ? 'ANR' : i.type, where: i.location || undefined, reports: i.reports, users: i.users,
|
||||
versions: { first: i.firstVersion, last: i.lastVersion }, os: i.firstOs || i.lastOs ? { first: i.firstOs, last: i.lastOs } : undefined, lastSeen: i.lastSeen, url: i.uri,
|
||||
sampleArgs: i.id ? { issue: i.id } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appleSignatureView(s: AppleSignature): IssueView {
|
||||
return { store: 'apple', title: s.signature || s.type, kind: s.type, weight: s.weight, build: s.build, sampleArgs: s.id ? { signature: s.id } : null };
|
||||
}
|
||||
|
||||
/** The top frames worth pasting (the full sample stays in the window). */
|
||||
export const TOP_FRAMES = 15;
|
||||
|
||||
/** Compact markdown for an AI agent: title, type, where, counts, versions, top frames. */
|
||||
export function issueMarkdown(app: string, v: IssueView, sample: IssueSample | null): string {
|
||||
const out = [`## ${v.store === 'play' ? 'Google Play' : 'App Store'} ${v.kind}: ${v.title}`, '', `- App: ${app}`];
|
||||
if (v.where && v.where !== v.title) out.push(`- Location: ${v.where}`);
|
||||
if (v.reports !== undefined) out.push(`- Reports: ${v.reports}${v.users !== undefined ? `, users: ${v.users}` : ''}`);
|
||||
if (v.weight !== undefined) out.push(`- Share of reports: ${Math.round(v.weight * 1000) / 10} %`);
|
||||
if (v.versions?.first || v.versions?.last) out.push(`- versionCode: ${v.versions.first ?? '?'} to ${v.versions.last ?? '?'}`);
|
||||
if (v.build) out.push(`- Build: ${v.build}`);
|
||||
if (v.os) out.push(`- Android API: ${v.os.first ?? '?'} to ${v.os.last ?? '?'}`);
|
||||
if (v.lastSeen) out.push(`- Last report: ${v.lastSeen}`);
|
||||
const hint = hintFor(v, sample);
|
||||
if (hint) out.push(`- Likely cause: ${hint.cause}`, `- Suggested fix: ${hint.fix}`);
|
||||
if (sample) {
|
||||
const meta = [sample.version && `version ${sample.version}`, sample.device, sample.os].filter(Boolean).join(', ');
|
||||
out.push('', `Sample report${meta ? ` (${meta})` : ''}, top frames:`, '', '```', ...sample.stack.slice(0, TOP_FRAMES), ...(sample.stack.length > TOP_FRAMES ? [`... ${sample.stack.length - TOP_FRAMES} more frames`] : []), '```');
|
||||
}
|
||||
if (v.url) out.push('', `Console: ${v.url}`);
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
243
apps/desktop/src/lib/scheduler.ts
Normal file
243
apps/desktop/src/lib/scheduler.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// The background read scheduler: every store read of the window goes through one queue, so reading "everything for
|
||||
// every app" stays far inside the stores' limits. Pure logic with an injectable clock (tests/scheduler.test.ts).
|
||||
//
|
||||
// - Lanes: 'apple' (App Store Connect), 'play' (Google Play Developer + Reporting API), 'local' (offline project
|
||||
// reads). A task takes one slot in each of its lanes; each lane has its own concurrency limit (default 4).
|
||||
// - Credentials: starts on one key are spaced (default 250 ms apart). App Store Connect sends X-Rate-Limit
|
||||
// (user-hour-lim / user-hour-rem, about 3600 per key per hour): below 20 % left, the spacing spreads the rest over the
|
||||
// hour; at the reserve (default 100, kept for the owner's own commands) the key pauses for 10 minutes.
|
||||
// - 429 / RESOURCE_EXHAUSTED: the task waits and runs again with exponential backoff and jitter (and its key waits too),
|
||||
// at most `maxAttempts` times.
|
||||
// - Priority: 0 the visible app and tab, 1 portfolio status, 2 the rest; FIFO within a priority.
|
||||
// - Dedupe: one in-flight (or queued) read per key; enqueueing it again returns the same promise (and may raise its
|
||||
// priority).
|
||||
|
||||
export type Lane = 'apple' | 'play' | 'local';
|
||||
export type Priority = 0 | 1 | 2;
|
||||
|
||||
export interface Clock {
|
||||
now(): number;
|
||||
setTimeout(fn: () => void, ms: number): unknown;
|
||||
clearTimeout(h: unknown): void;
|
||||
random(): number;
|
||||
}
|
||||
|
||||
export const realClock: Clock = {
|
||||
now: () => Date.now(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||||
clearTimeout: h => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
random: () => Math.random(),
|
||||
};
|
||||
|
||||
export interface QuotaSeen { limit?: number; remaining?: number; throttled?: boolean }
|
||||
|
||||
/** What one finished run says about the store's limits. */
|
||||
export interface Outcome {
|
||||
/** 429 / RESOURCE_EXHAUSTED: run it again later. */
|
||||
throttled?: boolean;
|
||||
/** Quota per credential (App Store Connect X-Rate-Limit). */
|
||||
quota?: { credential: string; limit: number; remaining: number };
|
||||
}
|
||||
|
||||
export interface ReadTask<R> {
|
||||
key: string;
|
||||
lanes: Lane[];
|
||||
/** Keys of the store credentials it uses ("apple:<account>", "play:<account>"). */
|
||||
credentials: string[];
|
||||
priority: Priority;
|
||||
run: () => Promise<R>;
|
||||
}
|
||||
|
||||
export interface SchedulerOptions {
|
||||
limits?: Partial<Record<Lane, number>>;
|
||||
/** Minimum gap between two starts on one credential. */
|
||||
spacingMs?: number;
|
||||
/** Requests per hour kept free on an App Store Connect key for the owner's own commands. */
|
||||
reserve?: number;
|
||||
/** Below this share of the hourly limit left, starts are spread over the rest of the hour. */
|
||||
slowBelow?: number;
|
||||
/** How long a key pauses once it is at the reserve. */
|
||||
pauseMs?: number;
|
||||
backoffBaseMs?: number;
|
||||
backoffMaxMs?: number;
|
||||
maxAttempts?: number;
|
||||
clock?: Clock;
|
||||
/** Reads a result (or the error) for throttling and quota. */
|
||||
classify?: (result: unknown, error: unknown, task: ReadTask<unknown>) => Outcome;
|
||||
}
|
||||
|
||||
export interface SchedulerStats {
|
||||
queued: number;
|
||||
running: number;
|
||||
/** Finished in this round (since the queue was last empty). */
|
||||
done: number;
|
||||
/** done + running + queued of this round. */
|
||||
total: number;
|
||||
failed: number;
|
||||
/** The lowest App Store Connect quota seen per credential this hour. */
|
||||
quota: Record<string, { limit: number; remaining: number; at: number }>;
|
||||
/** Credentials waiting on a backoff or quota pause, with the time they may start again. */
|
||||
paused: Record<string, number>;
|
||||
}
|
||||
|
||||
interface Item<R> {
|
||||
task: ReadTask<R>;
|
||||
seq: number;
|
||||
attempt: number;
|
||||
notBefore: number;
|
||||
resolve: (r: R) => void;
|
||||
reject: (e: unknown) => void;
|
||||
promise: Promise<R>;
|
||||
}
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
export class ReadScheduler {
|
||||
private o: Required<Omit<SchedulerOptions, 'limits' | 'clock' | 'classify'>> & { limits: Record<Lane, number> };
|
||||
private clock: Clock;
|
||||
private classify: (result: unknown, error: unknown, task: ReadTask<unknown>) => Outcome;
|
||||
private queue: Item<unknown>[] = [];
|
||||
private running = new Map<string, Item<unknown>>();
|
||||
private busy: Record<Lane, number> = { apple: 0, play: 0, local: 0 };
|
||||
private nextAt = new Map<string, number>();
|
||||
private quotas = new Map<string, { limit: number; remaining: number; at: number }>();
|
||||
private seq = 0;
|
||||
private done = 0;
|
||||
private failed = 0;
|
||||
private timer: unknown = null;
|
||||
private timerAt = 0;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(o: SchedulerOptions = {}) {
|
||||
this.o = {
|
||||
limits: { apple: 4, play: 4, local: 2, ...o.limits },
|
||||
spacingMs: o.spacingMs ?? 250,
|
||||
reserve: o.reserve ?? 100,
|
||||
slowBelow: o.slowBelow ?? 0.2,
|
||||
pauseMs: o.pauseMs ?? 10 * 60_000,
|
||||
backoffBaseMs: o.backoffBaseMs ?? 5_000,
|
||||
backoffMaxMs: o.backoffMaxMs ?? 5 * 60_000,
|
||||
maxAttempts: o.maxAttempts ?? 4,
|
||||
};
|
||||
this.clock = o.clock ?? realClock;
|
||||
this.classify = o.classify ?? (() => ({}));
|
||||
}
|
||||
|
||||
setLimits(limits: Partial<Record<Lane, number>>): void { this.o.limits = { ...this.o.limits, ...limits }; this.pump(); }
|
||||
|
||||
subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
|
||||
|
||||
has(key: string): boolean { return this.running.has(key) || this.queue.some(i => i.task.key === key); }
|
||||
|
||||
enqueue<R>(task: ReadTask<R>): Promise<R> {
|
||||
const run = this.running.get(task.key);
|
||||
if (run) return run.promise as Promise<R>;
|
||||
const queued = this.queue.find(i => i.task.key === task.key);
|
||||
if (queued) {
|
||||
if (task.priority < queued.task.priority) { queued.task = { ...queued.task, priority: task.priority }; this.pump(); }
|
||||
return queued.promise as Promise<R>;
|
||||
}
|
||||
let resolve!: (r: R) => void, reject!: (e: unknown) => void;
|
||||
const promise = new Promise<R>((a, b) => { resolve = a; reject = b; });
|
||||
this.queue.push({ task, seq: ++this.seq, attempt: 0, notBefore: 0, resolve, reject, promise } as unknown as Item<unknown>);
|
||||
this.pump();
|
||||
this.emit();
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Drops queued tasks (not the running ones); their promises reject with "cancelled". */
|
||||
clear(filter: (key: string) => boolean = () => true): void {
|
||||
const drop = this.queue.filter(i => filter(i.task.key));
|
||||
this.queue = this.queue.filter(i => !filter(i.task.key));
|
||||
for (const i of drop) i.reject(new Error('cancelled'));
|
||||
this.pump();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
stats(): SchedulerStats {
|
||||
const now = this.clock.now();
|
||||
const paused: Record<string, number> = {};
|
||||
for (const [c, at] of this.nextAt) if (at - now > this.o.spacingMs) paused[c] = at;
|
||||
const quota: SchedulerStats['quota'] = {};
|
||||
for (const [c, q] of this.quotas) if (now - q.at < HOUR) quota[c] = q;
|
||||
return { queued: this.queue.length, running: this.running.size, done: this.done, total: this.done + this.running.size + this.queue.length, failed: this.failed, quota, paused };
|
||||
}
|
||||
|
||||
/** Earliest time a credential may start again. */
|
||||
private gap(cred: string, now: number): number {
|
||||
const q = this.quotas.get(cred);
|
||||
if (!q || now - q.at >= HOUR) return this.o.spacingMs;
|
||||
if (q.remaining <= this.o.reserve) return this.o.pauseMs;
|
||||
if (q.remaining < q.limit * this.o.slowBelow) return Math.max(this.o.spacingMs, Math.ceil(HOUR / Math.max(1, q.remaining - this.o.reserve)));
|
||||
return this.o.spacingMs;
|
||||
}
|
||||
|
||||
private ready(i: Item<unknown>, now: number): number {
|
||||
// 0 = can start now; otherwise the time it can (Infinity: waits on a lane slot)
|
||||
let at = i.notBefore;
|
||||
for (const c of i.task.credentials) at = Math.max(at, this.nextAt.get(c) ?? 0);
|
||||
if (i.task.lanes.some(l => this.busy[l] >= Math.max(1, this.o.limits[l]))) return Infinity;
|
||||
return at <= now ? 0 : at;
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
const now = this.clock.now();
|
||||
let wake = Infinity;
|
||||
for (;;) {
|
||||
const order = [...this.queue].sort((a, b) => a.task.priority - b.task.priority || a.seq - b.seq);
|
||||
let started = false;
|
||||
for (const i of order) {
|
||||
const r = this.ready(i, now);
|
||||
if (r === 0) { this.start(i, now); started = true; break; }
|
||||
if (r !== Infinity) wake = Math.min(wake, r);
|
||||
}
|
||||
if (!started) break;
|
||||
}
|
||||
if (this.timer !== null && (wake === Infinity || wake !== this.timerAt)) { this.clock.clearTimeout(this.timer); this.timer = null; }
|
||||
if (wake !== Infinity && this.timer === null) {
|
||||
this.timerAt = wake;
|
||||
this.timer = this.clock.setTimeout(() => { this.timer = null; this.pump(); }, Math.max(0, wake - now));
|
||||
}
|
||||
}
|
||||
|
||||
private start(i: Item<unknown>, now: number): void {
|
||||
this.queue = this.queue.filter(x => x !== i);
|
||||
this.running.set(i.task.key, i);
|
||||
for (const l of i.task.lanes) this.busy[l]++;
|
||||
for (const c of i.task.credentials) this.nextAt.set(c, now + this.gap(c, now));
|
||||
i.attempt++;
|
||||
this.emit();
|
||||
i.task.run().then(r => this.finish(i, r, undefined, true), e => this.finish(i, undefined, e, false));
|
||||
}
|
||||
|
||||
private finish(i: Item<unknown>, result: unknown, error: unknown, ok: boolean): void {
|
||||
const now = this.clock.now();
|
||||
this.running.delete(i.task.key);
|
||||
for (const l of i.task.lanes) this.busy[l]--;
|
||||
let out: Outcome = {};
|
||||
try { out = this.classify(result, error, i.task); } catch { /* a classifier bug never stalls the queue */ }
|
||||
if (out.quota) {
|
||||
const prev = this.quotas.get(out.quota.credential);
|
||||
// the hour is the key's: keep the lowest count seen in it
|
||||
const q = prev && now - prev.at < HOUR && prev.remaining < out.quota.remaining ? prev : { limit: out.quota.limit, remaining: out.quota.remaining, at: now };
|
||||
this.quotas.set(out.quota.credential, q);
|
||||
this.nextAt.set(out.quota.credential, Math.max(this.nextAt.get(out.quota.credential) ?? 0, now + this.gap(out.quota.credential, now)));
|
||||
}
|
||||
if (out.throttled && i.attempt < this.o.maxAttempts) {
|
||||
const exp = Math.min(this.o.backoffMaxMs, this.o.backoffBaseMs * 2 ** (i.attempt - 1));
|
||||
const delay = Math.round(exp / 2 + this.clock.random() * exp / 2); // "equal jitter"
|
||||
i.notBefore = now + delay;
|
||||
for (const c of i.task.credentials) this.nextAt.set(c, Math.max(this.nextAt.get(c) ?? 0, now + delay));
|
||||
this.queue.push(i);
|
||||
} else {
|
||||
this.done++;
|
||||
if (!ok || out.throttled) this.failed++;
|
||||
if (ok) i.resolve(result); else i.reject(error);
|
||||
}
|
||||
if (!this.queue.length && !this.running.size) { this.done = 0; this.failed = 0; }
|
||||
this.pump();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit(): void { for (const f of this.listeners) { try { f(); } catch { /* listeners never break the queue */ } } }
|
||||
}
|
||||
|
|
@ -225,6 +225,7 @@ export function Portfolio() {
|
|||
);
|
||||
};
|
||||
|
||||
const notRead = d.portfolioBusy === 'live' ? t('read.waiting') : t('kpi.notRead');
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
|
|
@ -240,13 +241,13 @@ export function Portfolio() {
|
|||
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
|
||||
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={rechecking} />
|
||||
{neverImported > 0 && <Notice tone="info" icon="download" action={<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy}>{t('action.importApps')}</button>}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
|
||||
{!known && d.ready && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
|
||||
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
|
||||
|
||||
<div className="kpis">
|
||||
<Kpi icon="layers" label={t('kpi.apps')} value={rows.length} sub={t('kpi.appsSubStore', { ios: iosApps, android: androidApps, store: storeOnly })} />
|
||||
<Kpi icon="check" label={t('kpi.live')} value={known ? liveCount : '–'} sub={known ? t('kpi.liveSub', { count: stores }) : t('kpi.notRead')} />
|
||||
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? t('kpi.notRead') : rolling.length ? [...new Set(rolling.map(r => r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} />
|
||||
<Kpi icon="clock" label={t('kpi.review')} value={known ? review.length : '–'} sub={!known ? t('kpi.notRead') : review.length ? review.map(r => `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
|
||||
<Kpi icon="check" label={t('kpi.live')} value={known ? liveCount : '–'} sub={known ? t('kpi.liveSub', { count: stores }) : notRead} />
|
||||
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? notRead : rolling.length ? [...new Set(rolling.map(r => r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} />
|
||||
<Kpi icon="clock" label={t('kpi.review')} value={known ? review.length : '–'} sub={!known ? notRead : review.length ? review.map(r => `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
|
||||
<Kpi icon="alert" label={t('kpi.attention')} value={att.length} sub={blockers ? t('kpi.blockers', { count: blockers }) : t('kpi.noBlockers')} tone={blockers ? 'bad' : att.length ? 'warn' : undefined} />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -122,11 +122,11 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
|
||||
<Card id="set-refresh" title={t('set.refresh')} icon="refresh" sub={t('set.refreshSub')} className="set-card">
|
||||
<div className="form">
|
||||
<div className="field"><span className="legend">{t('set.autoRefresh')}</span>
|
||||
<Seg value={String(s.refresh.autoMinutes) as '0'} label={t('set.autoRefresh')} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoMinutes: Number(v) as S['refresh']['autoMinutes'] } }))}
|
||||
options={[['0', t('set.off')], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })], ['180', t('set.hour', { n: 3 })], ['720', t('set.hour', { n: 12 })]]} />
|
||||
<span className="hint">{t('set.autoHint')}</span></div>
|
||||
<Toggle id="on-launch" label={t('set.onLaunch')} hint={t('set.onLaunchHint')} checked={s.refresh.onLaunch} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, onLaunch: v } }))} />
|
||||
<Toggle id="auto-read" label={t('set.autoRead')} hint={t('set.autoReadHint')} checked={s.refresh.autoRead} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoRead: v } }))} />
|
||||
<div className="field"><span className="legend">{t('set.everyMinutes')}</span>
|
||||
<Seg value={String(s.refresh.everyMinutes) as '0'} label={t('set.everyMinutes')} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, everyMinutes: Number(v) as S['refresh']['everyMinutes'] } }))}
|
||||
options={[['0', t('set.onlyOnStart')], ['5', t('set.min', { n: 5 })], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })]]} />
|
||||
<span className="hint">{t('set.everyHint')}</span></div>
|
||||
<Toggle id="icons" label={t('set.icons')} hint={t('set.iconsHint')} checked={s.refresh.icons} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, icons: v } }))} />
|
||||
<div className="field"><label htmlFor="parallel">{t('set.parallel')}</label>
|
||||
<select id="parallel" className="input" style={{ width: 120 }} value={s.refresh.parallel} onChange={e => set(x => ({ ...x, refresh: { ...x.refresh, parallel: Number(e.target.value) } }))}>
|
||||
|
|
@ -134,7 +134,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
</select><span className="hint">{t('set.parallelHint')}</span></div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" />{t('action.importApps')}</button>
|
||||
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
|
||||
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }}><Icon name="refresh" />{t('action.refreshAll')}</button>
|
||||
<span className="small muted">{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useData, useRead, useWrite } from '../../store';
|
|||
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
|
||||
import { OwnerAction } from '../../ui/OwnerAction';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { catalogMd, exportName } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
import { useNav } from '../../nav';
|
||||
|
|
@ -87,13 +87,13 @@ export function CatalogTab({ project }: { project: Project }) {
|
|||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="tag" label={t('cat.products')} value={s?.products ?? 0} sub={t('cat.productsSub')} />
|
||||
<Kpi icon="refresh" label={t('cat.subscriptions')} value={s?.subscriptions ?? 0} sub={t('cat.subsSub')} />
|
||||
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
|
||||
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : t('kpi.notRead')} />
|
||||
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
|
||||
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : diff.loading ? t('read.waiting') : t('kpi.notRead')} />
|
||||
</div>
|
||||
{diff.error && <ErrorNotice error={diff.error} keep />}
|
||||
<CatalogActions project={project} products={s?.products ?? 0} subs={s?.subscriptions ?? 0} refresh={() => { diff.refresh(); }} />
|
||||
<Card title={t('cat.matrix')} icon="layers" sub={read ? (diff.fromCache ? t('common.cachedFrom', { when: ago(diff.at) }) : t('common.readWhen', { when: ago(diff.at) })) : t('cat.configOnly')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={diff.refresh} disabled={diff.loading}><Icon name="refresh" className={diff.loading ? 'spin' : undefined} />{read ? t('action.refresh') : t('cat.compare')}</button>}>
|
||||
actions={<ReadControl r={diff} label={t('cat.compare')} />}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.item')}</th><th>{t('col.type')}</th><th>{t('col.config')}</th><th>App Store</th><th>Google Play</th><th className="right">{t('col.priceGap')}</th></tr></thead>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { ago } from '../../lib/format';
|
|||
import type { Finding, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { exportName, validateMd } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ function ScanCard({ project }: { project: Project }) {
|
|||
const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length;
|
||||
return (
|
||||
<Card title={t('checks.scan')} icon="search" sub={s.data && sdks.length ? t('checks.scanSdks', { list: sdks.map(x => x.name).join(', ') }) : t('checks.scanSub')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={s.refresh} disabled={s.loading}><Icon name="refresh" className={s.loading ? 'spin' : undefined} />{t('checks.scanAgain')}</button>}>
|
||||
actions={<ReadControl r={s} label={t('checks.scanAgain')} />}>
|
||||
{s.error && <div className="card-body"><ErrorNotice error={s.error} keep={!!s.data} /></div>}
|
||||
{!s.data ? (s.loading && !s.error ? <div className="card-body"><Skeleton lines={4} /></div> : null)
|
||||
: !findings.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
|
||||
|
|
@ -80,7 +80,7 @@ export function ChecksTab({ project }: { project: Project }) {
|
|||
)}
|
||||
</Card>
|
||||
<Card title={t('checks.validate')} icon="layers" sub={v.data ? (v.fromCache ? t('common.cachedFrom', { when: ago(v.at) }) : t('common.readWhen', { when: ago(v.at) })) : t('checks.validateSub')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={v.refresh} disabled={v.loading}><Icon name="refresh" />{v.data ? t('action.refresh') : t('checks.run')}</button>}>
|
||||
actions={<ReadControl r={v} label={t('checks.run')} />}>
|
||||
{v.error && <div className="card-body"><ErrorNotice error={v.error} keep={!!v.data} /></div>}
|
||||
{!v.data ? (
|
||||
v.loading && !v.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('checks.validateEmpty')}>{t('checks.validateEmptyText')}</Empty>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useNav } from '../../nav';
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, Seg, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
|
||||
import { LineChart } from '../../ui/LineChart';
|
||||
import { RankChart } from '../../ui/RankChart';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
|
@ -107,7 +107,7 @@ function Aso({ projectId }: { projectId: string }) {
|
|||
})) : [];
|
||||
return (
|
||||
<Card title={t('gr.aso')} icon="search" sub={a ? t('gr.asoSub', { date: date(a.generated) }) : t('gr.asoSubNone')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{t('action.refresh')}</button>}>
|
||||
actions={<ReadControl r={r} />}>
|
||||
{r.error ? <div className="card-body"><ErrorNotice error={r.error} /></div> : !a ? <div className="card-body"><Skeleton lines={4} block /></div> : (
|
||||
<div className="card-body stack" style={{ gap: 16 }}>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}>
|
||||
|
|
@ -167,7 +167,7 @@ function Experiments({ app }: { app: UnifiedApp }) {
|
|||
<Card title={t('gr.ppo')} icon="flask" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}</span>} pad={false}
|
||||
actions={<>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(asc).catch(() => {})}><Icon name="external" />{t('gr.results')}</button>
|
||||
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('gr.ppoRead')}</button>
|
||||
<ReadControl r={r} label={t('gr.ppoRead')} />
|
||||
</>}>
|
||||
{r.error && <div className="card-body"><ErrorNotice error={r.error} keep={!!r.data} /></div>}
|
||||
{!r.data ? (r.loading && !r.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('gr.ppoNotRead')}>{t('gr.ppoNotReadText')}</Empty>)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,13 @@ import type { UnifiedApp } from '../../lib/identity';
|
|||
import { t } from '../../i18n';
|
||||
import { ago, date, num, pct } from '../../lib/format';
|
||||
import { type AppleHealth, healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type PlayHealth, type Series } from '../../lib/model';
|
||||
import { useRead } from '../../store';
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api';
|
||||
import { appleSignatureView, hintFor, type IssueView, issueMarkdown, playIssueView } from '../../lib/issues';
|
||||
import type { IssueSample } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Skeleton, Sparkline } from '../../ui/kit';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit';
|
||||
import { exportName, healthMd } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
import { StoreReadNotice } from '../../ui/StoreError';
|
||||
|
|
@ -33,7 +37,47 @@ function Metric({ title, s }: { title: string; s: Series }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Play({ h }: { h: PlayHealth }) {
|
||||
/** One issue's detail: full title and type, counts, versions, last report, a sample stack (read and cached like the
|
||||
* rest), "Open in Play Console" and "Copy for AI" (compact markdown for an agent). */
|
||||
function IssueDetail({ app, v }: { app: UnifiedApp; v: IssueView }) {
|
||||
const d = useData();
|
||||
const s = useRead<{ sample: IssueSample | null }>(app.target, 'health.issue', { network: true, args: v.sampleArgs ?? {}, skip: !v.sampleArgs });
|
||||
const sample = s.data?.sample ?? null;
|
||||
const copy = () => {
|
||||
navigator.clipboard?.writeText(issueMarkdown(app.name, v, sample)).then(() => d.toast({ kind: 'ok', title: t('health.copied') }), () => d.toast({ kind: 'bad', title: t('health.copyFailed') }));
|
||||
};
|
||||
const range = (a?: string, b?: string) => (a && b && a !== b ? `${a} – ${b}` : a ?? b ?? '–');
|
||||
return (
|
||||
<div className="issue-detail" data-testid="issue-detail">
|
||||
<div className="strong issue-title">{v.title}</div>
|
||||
<dl className="kv">
|
||||
<dt>{t('health.issueType')}</dt><dd>{v.kind}</dd>
|
||||
{v.where && v.where !== v.title && <><dt>{t('health.issueWhere')}</dt><dd className="mono-id">{v.where}</dd></>}
|
||||
{v.reports !== undefined && <><dt>{t('col.reports')}</dt><dd className="num">{num(v.reports)}{v.users !== undefined ? ` · ${t('col.users')} ${num(v.users)}` : ''}</dd></>}
|
||||
{v.weight !== undefined && <><dt>{t('health.issueShare')}</dt><dd className="num">{num(v.weight * 100, 1)}%</dd></>}
|
||||
{(v.versions?.first || v.versions?.last) && <><dt>{t('health.issueVersions')}</dt><dd className="num">{range(v.versions.first, v.versions.last)}</dd></>}
|
||||
{v.build && <><dt>{t('health.issueBuild')}</dt><dd className="num">{v.build}</dd></>}
|
||||
{v.os && <><dt>{t('health.issueOs')}</dt><dd className="num">{range(v.os.first, v.os.last)}</dd></>}
|
||||
{v.lastSeen && <><dt>{t('health.issueLastSeen')}</dt><dd>{ago(v.lastSeen)}</dd></>}
|
||||
</dl>
|
||||
{(() => { const h = hintFor(v, sample); return h ? <Notice tone="info" icon="wand"><div className="stack" style={{ gap: 4 }} data-testid="issue-hint"><span><b>{t('health.likelyCause')}</b> {h.cause}</span><span><b>{t('health.suggestedFix')}</b> {h.fix}</span></div></Notice> : null; })()}
|
||||
{v.sampleArgs && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="row between"><span className="label">{t('health.sample')}{sample ? ` · ${[sample.version, sample.device, sample.os].filter(Boolean).join(' · ')}` : ''}</span><ReadControl r={s} /></div>
|
||||
{sample ? <pre className="stack-trace" data-testid="issue-stack">{sample.stack.join('\n')}</pre>
|
||||
: s.loading ? <Skeleton lines={4} /> : !s.error ? <p className="small muted">{t('health.noSample')}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={copy}><Icon name="clipboard" />{t('health.copyForAi')}</button>
|
||||
{v.url && <button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(v.url!).catch(() => {})}><Icon name="external" />{t('health.openConsole')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const crash = healthSeries(h.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
|
||||
const anr = healthSeries(h.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr);
|
||||
const devices = (h.devices ?? []).slice(0, 6);
|
||||
|
|
@ -67,11 +111,11 @@ function Play({ h }: { h: PlayHealth }) {
|
|||
<thead><tr><th>{t('col.issue')}</th><th className="right">{t('col.reports')}</th><th className="right">{t('col.users')}</th></tr></thead>
|
||||
<tbody>
|
||||
{h.issues.slice(0, 6).map((i, n) => (
|
||||
<tr key={n}>
|
||||
<td><div className="row" style={{ gap: 8 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260 }}>{i.cause || '?'}</span></div>
|
||||
<div className="mono-id muted" style={{ marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 360 }}>{i.location}</div></td>
|
||||
<td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td>
|
||||
</tr>
|
||||
<IssueRows key={n} open={open === n} onToggle={() => setOpen(open === n ? null : n)} cols={3}
|
||||
head={<><div className="row" style={{ gap: 8, minWidth: 0 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong ellipsis" title={i.cause || '?'}>{i.cause || '?'}</span></div>
|
||||
<div className="mono-id muted ellipsis" style={{ marginTop: 4 }} title={i.location}>{i.location}</div></>}
|
||||
cells={<><td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td></>}
|
||||
detail={<IssueDetail app={app} v={playIssueView(i)} />} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -82,7 +126,21 @@ function Play({ h }: { h: PlayHealth }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Apple({ h }: { h: AppleHealth }) {
|
||||
/** A table row that opens its detail row below it (click or Enter / Space). */
|
||||
function IssueRows({ open, onToggle, head, cells, detail, cols }: { open: boolean; onToggle: () => void; head: React.ReactNode; cells: React.ReactNode; detail: React.ReactNode; cols: number }) {
|
||||
return (
|
||||
<>
|
||||
<tr className={`issue-row${open ? ' open' : ''}`} onClick={onToggle} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }} tabIndex={0} aria-expanded={open} role="button">
|
||||
<td style={{ maxWidth: 380 }}><div className="row" style={{ gap: 6, minWidth: 0 }}><Icon name="chevronRight" className={`ic13 chev${open ? ' rot' : ''}`} /><div style={{ minWidth: 0 }}>{head}</div></div></td>
|
||||
{cells}
|
||||
</tr>
|
||||
{open && <tr className="edit-row"><td colSpan={cols}>{detail}</td></tr>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Apple({ h, app }: { h: AppleHealth; app: UnifiedApp }) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count));
|
||||
return (
|
||||
<div className="grid g2">
|
||||
|
|
@ -102,15 +160,16 @@ function Apple({ h }: { h: AppleHealth }) {
|
|||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Card title={t('health.signatures')} icon="layers" sub={t('health.signaturesSub')} pad={false}>
|
||||
{h.signatures.length ? (
|
||||
<div className="list">
|
||||
{h.signatures.slice(0, 5).map((s, i) => (
|
||||
<div className="li" key={i}>
|
||||
<span className="li-icon tone-bg-neutral"><Icon name="alertCircle" /></span>
|
||||
<div className="li-main"><span className="li-title" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.signature || s.type}</span><span className="li-sub">{s.type} · {t('health.build', { build: s.build })}</span></div>
|
||||
<span className="num small strong">{num(s.weight * 100, 1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<table className="table">
|
||||
<tbody>
|
||||
{h.signatures.slice(0, 5).map((s, i) => (
|
||||
<IssueRows key={i} open={open === i} onToggle={() => setOpen(open === i ? null : i)} cols={2}
|
||||
head={<><div className="strong ellipsis" title={s.signature || s.type}>{s.signature || s.type}</div><div className="small muted">{s.type} · {t('health.build', { build: s.build })}</div></>}
|
||||
cells={<td className="right num small strong">{num(s.weight * 100, 1)}%</td>}
|
||||
detail={<IssueDetail app={app} v={appleSignatureView(s)} />} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <Empty compact illo="shield" title={t('health.noSignatures')} />}
|
||||
</Card>
|
||||
<Card title={t('health.betaCrashes')} icon="plane">
|
||||
|
|
@ -128,7 +187,6 @@ function Apple({ h }: { h: AppleHealth }) {
|
|||
export function HealthTab({ app }: { app: UnifiedApp }) {
|
||||
const r = useRead<HealthSnapshot>(app.target, 'health', { network: true, title: t('tab.health'), appName: app.name });
|
||||
useExports('health', [{ id: 'health-md', label: t('export.health'), icon: 'activity', kind: 'md', file: exportName(app.name, 'health'), disabled: r.data ? false : t('export.readFirst'), build: () => healthMd(app.name, r.data!) }]);
|
||||
const refresh = <button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading && !r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('health.read')}</button>;
|
||||
if (!r.data) {
|
||||
if (r.loading && !r.error) return <Card><Skeleton lines={4} block /></Card>;
|
||||
return (
|
||||
|
|
@ -142,14 +200,13 @@ export function HealthTab({ app }: { app: UnifiedApp }) {
|
|||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
|
||||
{refresh}
|
||||
<span />
|
||||
<ReadControl r={r} />
|
||||
</div>
|
||||
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
|
||||
<h2 className="label">Google Play</h2>
|
||||
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Play h={h.play} />}
|
||||
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <>{h.play.issuesError && <ErrorNotice error={t('health.issuesUnread', { error: h.play.issuesError })} />}<Play h={h.play} app={app} /></>}
|
||||
<h2 className="label">App Store</h2>
|
||||
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} />}
|
||||
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} app={app} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { changePct, diffSummary, priceMatrix, priceStats, type RegionRow, region
|
|||
import { type ReadState, useData, useRead, useWrite } from '../../store';
|
||||
import { type ExportItem, useExports, useRunExport } from '../../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
||||
const GAP = 10;
|
||||
|
|
@ -158,7 +158,7 @@ export function PricesTab({ project }: { project: Project }) {
|
|||
<Card title={t('prices.overview')} icon="grid" sub={cur.fromCache ? t('common.cachedFrom', { when: ago(cur.at) }) : t('common.readWhen', { when: ago(cur.at) })} pad={false}
|
||||
actions={<>
|
||||
<label className="check"><input type="checkbox" checked={withAuto} onChange={ev => setWithAuto(ev.target.checked)} />{t('prices.withAuto')}</label>
|
||||
<button className="btn btn-sm" onClick={cur.refresh} disabled={cur.loading}><Icon name="refresh" className={cur.loading ? 'spin' : undefined} />{t('action.refresh')}</button>
|
||||
<ReadControl r={cur} />
|
||||
</>}>
|
||||
<div className="table-wrap">
|
||||
<table className="table px-matrix">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory }
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { RolloutBar } from '../../ui/charts';
|
||||
import { Card, Chip, Empty, Notice, PhaseChip, storeName } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, Notice, PhaseChip, Skeleton, storeName } from '../../ui/kit';
|
||||
import { AppleSubmitCard, PlayProductionCard } from './Submit';
|
||||
|
||||
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
|
||||
|
|
@ -139,6 +139,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
</section>
|
||||
) : null;
|
||||
|
||||
if (!l && d.portfolioBusy === 'live') return <><Card><Skeleton lines={5} block /></Card>{submit}</>;
|
||||
if (!l) {
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import { useEffect, useMemo, useState } from 'react';
|
|||
import type { DraftsResult, ReplyDraft } from '../../api';
|
||||
import type { UnifiedApp } from '../../lib/identity';
|
||||
import { t } from '../../i18n';
|
||||
import { ago, date, num } from '../../lib/format';
|
||||
import { date, num } from '../../lib/format';
|
||||
import { draftName, filterReviews, type Review, type ReviewFilter, starStats } from '../../lib/model';
|
||||
import { useRead, useWrite } from '../../store';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Seg, Skeleton, Stars } from '../../ui/kit';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit';
|
||||
import { exportName, reviewsCsv } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
|
||||
|
|
@ -111,13 +111,11 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
|
|||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span className="stamp"><Icon name="clock" />{list.fromCache ? t('common.cachedFrom', { when: ago(list.at) }) : t('common.readWhen', { when: ago(list.at) })}</span>
|
||||
<ReadControl r={list} />
|
||||
<div className="actions">
|
||||
{project && <WriteButton run={w} icon="text" hint={t('reviews.makeDraftsHint')} w={{ method: 'reviews.draft', args: { days: 30 }, title: t('reviews.makeDrafts'), done: t('reviews.draftsMade'), after: changed }}>{t('reviews.makeDrafts')}</WriteButton>}
|
||||
<button className="btn btn-sm" onClick={list.refresh} disabled={list.loading}><Icon name="refresh" />{t('action.refresh')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{list.error && <Notice tone="warn">{t('common.readFailedKeep', { error: list.error })}</Notice>}
|
||||
<div className="rv">
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Card title={t('reviews.ratings')} icon="star">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import type { UnifiedApp } from '../../lib/identity';
|
|||
import { useData, useRead } from '../../store';
|
||||
import { ConsolePlanButton } from '../../ui/ConsolePlan';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Notice, Seg, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
|
||||
import { OwnerAction } from '../../ui/OwnerAction';
|
||||
import { ascUrl, PLAY_CONSOLE } from '../AppView';
|
||||
|
||||
|
|
@ -40,9 +40,9 @@ export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Pr
|
|||
const args = { build, ...(version ? { version: version.version } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) };
|
||||
const refresh = () => { r.refresh(); };
|
||||
return (
|
||||
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.appleSub')}</span>}
|
||||
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.appleSub')}</span>}
|
||||
actions={<>
|
||||
<button className="btn btn-sm" onClick={refresh} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
|
||||
<ReadControl r={{ ...r, refresh: async () => refresh() }} label={t('sub.read')} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(ascUrl(app.ios?.app.id ?? summary?.apple?.appId)).catch(() => {})}><Icon name="external" />App Store Connect</button>
|
||||
</>}>
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
|
|
@ -142,9 +142,9 @@ export function PlayProductionCard({ app, project }: { app: UnifiedApp; project:
|
|||
const picked = s?.codes.find(c => c.code === code) ?? null;
|
||||
const args = { version_code: code, status, ...(status === 'inProgress' ? { percent: Number(share) } : {}) };
|
||||
return (
|
||||
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.playSub')}</span>}
|
||||
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.playSub')}</span>}
|
||||
actions={<>
|
||||
<button className="btn btn-sm" onClick={() => { r.refresh(); }} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
|
||||
<ReadControl r={r} label={t('sub.read')} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>
|
||||
</>}>
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { Fragment, useState } from 'react';
|
|||
import type { UnifiedApp } from '../../lib/identity';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { t } from '../../i18n';
|
||||
import { ago, date, num } from '../../lib/format';
|
||||
import { date, num } from '../../lib/format';
|
||||
import type { TestFlightStatus, TfBuild, TfGroup, Tone } from '../../lib/model';
|
||||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, Notice, Skeleton } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
|
||||
/** Beta state word -> tone and a short label. */
|
||||
export function betaState(s: string | null): { tone: Tone; text: string } {
|
||||
|
|
@ -96,10 +96,9 @@ export function TestFlightTab({ app }: { app: UnifiedApp }) {
|
|||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
|
||||
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" />{t('action.refresh')}</button>
|
||||
<span />
|
||||
<ReadControl r={r} />
|
||||
</div>
|
||||
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
|
||||
{s.missingGroups.length > 0 && (
|
||||
<Notice tone="warn" action={writable ? <WriteButton run={w} icon="plus" w={{ method: 'apple.testflight_groups', args: {}, title: t('tf.groupsSync'), done: t('tf.groupsSynced'), after: r.refresh }} hint={t('tf.groupsSyncHint')}>{t('tf.groupsSync')}</WriteButton> : undefined}>
|
||||
{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify';
|
|||
import { setCrashContext } from './lib/crash';
|
||||
import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize';
|
||||
import { keepLines, progressOf } from './lib/progress';
|
||||
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
|
||||
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
|
||||
import { fixHint } from './lib/views';
|
||||
import { failureText, usable } from './lib/results';
|
||||
|
||||
|
|
@ -32,6 +34,31 @@ export interface Toast {
|
|||
|
||||
export interface AccountHealth { check?: AccountCheck; error?: string; at: string }
|
||||
|
||||
/** What the read bus tells the cards showing one read (key: lib/freshness.ts readKey). */
|
||||
export type ReadEvent = { kind: 'start' } | { kind: 'done'; result: ToolResult } | { kind: 'error'; error: string } | { kind: 'invalidate' };
|
||||
|
||||
/** The background reader's state for the status line. */
|
||||
export interface ReaderState extends SchedulerStats { autoRead: boolean }
|
||||
|
||||
/** Throttling and App Store Connect quota of one finished read (the ToolResult's `quota`, else its text). */
|
||||
export function classifyRead(result: unknown, error: unknown, task: ReadTask<unknown>): Outcome {
|
||||
const r = result as ToolResult | undefined;
|
||||
const q = r?.quota;
|
||||
const out: Outcome = {};
|
||||
if (q?.store === 'apple' && typeof q.limit === 'number' && typeof q.remaining === 'number')
|
||||
out.quota = { credential: task.credentials.find(c => c.startsWith('apple:')) ?? 'apple:default', limit: q.limit, remaining: q.remaining };
|
||||
if (r && !r.ok && (q?.throttled || looksThrottled(r.output))) out.throttled = true;
|
||||
if (error !== undefined && looksThrottled(errorText(error))) out.throttled = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Which stores an app has (for the reads it gets and the lanes they take). */
|
||||
export function platformsOf(a: UnifiedApp | undefined, status?: AppStatus): { ios: boolean; android: boolean } {
|
||||
if (!a) return { ios: false, android: false };
|
||||
const pl = status?.app?.platforms as { ios?: unknown; android?: unknown } | undefined;
|
||||
return { ios: !!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl), android: !!a.android || !!pl?.android || (!!a.project?.playAccount && !pl) };
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
ready: boolean;
|
||||
info: AppInfo | null;
|
||||
|
|
@ -56,7 +83,20 @@ export interface Data {
|
|||
portfolioError: string | null;
|
||||
importBusy: boolean;
|
||||
icons: Record<string, string | null>;
|
||||
refreshPortfolio: (live?: boolean) => Promise<void>;
|
||||
/** Background reader: progress and the App Store Connect quota seen this hour. */
|
||||
reader: ReaderState;
|
||||
/** Runs one read through the scheduler (deduped, rate limited); the result also goes to the read bus. */
|
||||
schedule: (target: string, method: string, args: Record<string, unknown>, priority: Priority) => Promise<ToolResult>;
|
||||
/** Listens to one read's bus key. */
|
||||
onRead: (key: string, fn: (e: ReadEvent) => void) => () => void;
|
||||
isReading: (key: string) => boolean;
|
||||
/** Reads everything of every app that is past its TTL (force: everything). */
|
||||
readAll: (force?: boolean) => Promise<void>;
|
||||
/** After a write: the app's reads are read again. */
|
||||
invalidate: (target: string) => void;
|
||||
/** The app on screen (its reads go first). */
|
||||
setFocus: (key: string | null) => void;
|
||||
refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise<void>;
|
||||
/** Reads the apps of one account (or all) from the stores. */
|
||||
importApps: (account?: string) => Promise<void>;
|
||||
checkAccount: (id: string) => Promise<void>;
|
||||
|
|
@ -155,10 +195,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const appsRef = useRef(apps);
|
||||
appsRef.current = apps;
|
||||
|
||||
const refreshPortfolio = useCallback(async (liveRead = false) => {
|
||||
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => {
|
||||
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
|
||||
setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null);
|
||||
const tid = liveRead ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
|
||||
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
|
||||
try {
|
||||
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
|
||||
const r = await api.portfolio(liveRead, job, stores);
|
||||
|
|
@ -179,6 +219,78 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
} finally { setBusy(null); }
|
||||
}, [toast, updateToast]);
|
||||
|
||||
// ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ----
|
||||
const schedRef = useRef<ReadScheduler | null>(null);
|
||||
if (!schedRef.current) schedRef.current = new ReadScheduler({ classify: classifyRead, limits: { apple: settings.refresh.parallel, play: settings.refresh.parallel } });
|
||||
const sched = schedRef.current;
|
||||
const [reader, setReader] = useState<SchedulerStats>(() => sched.stats());
|
||||
useEffect(() => {
|
||||
let tm: ReturnType<typeof setTimeout> | null = null;
|
||||
const un = sched.subscribe(() => { if (!tm) tm = setTimeout(() => { tm = null; setReader(sched.stats()); }, 150); });
|
||||
return () => { un(); if (tm) clearTimeout(tm); };
|
||||
}, [sched]);
|
||||
useEffect(() => { sched.setLimits({ apple: settings.refresh.parallel, play: settings.refresh.parallel }); }, [sched, settings.refresh.parallel]);
|
||||
const bus = useRef(new Map<string, Set<(e: ReadEvent) => void>>());
|
||||
const emitRead = useCallback((key: string, e: ReadEvent) => { for (const f of bus.current.get(key) ?? []) { try { f(e); } catch { /* a card's listener never breaks the bus */ } } }, []);
|
||||
const onRead = useCallback((key: string, fn: (e: ReadEvent) => void) => {
|
||||
const set = bus.current.get(key) ?? new Set();
|
||||
set.add(fn); bus.current.set(key, set);
|
||||
return () => { set.delete(fn); if (!set.size) bus.current.delete(key); };
|
||||
}, []);
|
||||
const isReading = useCallback((key: string) => sched.has(key), [sched]);
|
||||
const focusRef = useRef<string | null>(null);
|
||||
const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []);
|
||||
|
||||
const credentialsOf = (a: UnifiedApp | undefined, lanes: string[]): string[] => {
|
||||
const c: string[] = [];
|
||||
if (lanes.includes('apple')) c.push(`apple:${a?.project?.appleAccount ?? a?.ios?.account ?? 'default'}`);
|
||||
if (lanes.includes('play')) c.push(`play:${a?.project?.playAccount ?? a?.android?.account ?? 'default'}`);
|
||||
return c;
|
||||
};
|
||||
const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined);
|
||||
|
||||
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority): Promise<ToolResult> => {
|
||||
const key = readKey(target, method, args);
|
||||
const app = appsRef.current.find(a => a.target === target);
|
||||
const pf = platformsOf(app, app ? statusRef.current(app) : undefined);
|
||||
const lanes = lanesOf(method, pf);
|
||||
const p = sched.enqueue<ToolResult>({ key, lanes, credentials: credentialsOf(app, lanes), priority, run: () => api.call<ToolResult>(method, target, { args }, newJob(method)) });
|
||||
emitRead(key, { kind: 'start' });
|
||||
p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) }));
|
||||
return p;
|
||||
}, [sched, emitRead]);
|
||||
|
||||
const readAll = useCallback(async (force = false) => {
|
||||
if (!force && !settingsRef.current.refresh.autoRead) return;
|
||||
if (force || isStale('portfolio.status', liveRef.current?.at)) {
|
||||
sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], credentials: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {});
|
||||
}
|
||||
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
|
||||
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));
|
||||
for (const a of list) {
|
||||
const pf = platformsOf(a, statusRef.current(a));
|
||||
for (const r of readsFor({ folder: !!a.project, ...pf })) {
|
||||
const key = readKey(a.target, r.method, r.args);
|
||||
if (sched.has(key)) continue;
|
||||
if (!force) {
|
||||
const c = await api.cached<ToolResult>(r.method, a.target, r.args).catch(() => null);
|
||||
if (c && !isStale(r.method, c.at)) continue;
|
||||
}
|
||||
schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [sched, schedule, refreshPortfolio]);
|
||||
|
||||
const invalidate = useCallback((target: string) => {
|
||||
const pre = `${target}\u001f`;
|
||||
for (const k of [...bus.current.keys()]) if (k.startsWith(pre)) emitRead(k, { kind: 'invalidate' });
|
||||
const a = appsRef.current.find(x => x.target === target);
|
||||
if (a) for (const r of readsFor({ folder: !!a.project, ...platformsOf(a, statusRef.current(a)) })) {
|
||||
const key = readKey(target, r.method, r.args);
|
||||
if (!bus.current.has(key)) schedule(target, r.method, r.args, 1).catch(() => {});
|
||||
}
|
||||
}, [emitRead, schedule]);
|
||||
|
||||
const importApps = useCallback(async (account?: string) => {
|
||||
const ids = (account ? [account] : accounts.map(a => a.id)).filter(id => account || !overrides.ignored.includes(`account:${id}`));
|
||||
if (!ids.length) return;
|
||||
|
|
@ -236,21 +348,27 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]);
|
||||
|
||||
// Settings > Refresh: on launch, then every N minutes (store reads use the accounts' API quota: off by default)
|
||||
// Settings > Reading: everything past its TTL is read in the background on start, when the window shows again after
|
||||
// a while, and every N minutes while it is visible (the scheduler keeps it inside the stores' limits).
|
||||
const launched = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || launched.current) return;
|
||||
if (!ready || launched.current || !apps.length) return;
|
||||
launched.current = true;
|
||||
if (settings.refresh.onLaunch && (projects.length || accounts.length)) importApps().finally(() => refreshPortfolio(true));
|
||||
}, [ready, settings.refresh.onLaunch, projects.length, accounts.length, importApps, refreshPortfolio]);
|
||||
const busyRef = useRef(portfolioBusy);
|
||||
busyRef.current = portfolioBusy;
|
||||
if (settings.refresh.autoRead) readAll(false).catch(() => {});
|
||||
}, [ready, apps.length, settings.refresh.autoRead, readAll]);
|
||||
useEffect(() => {
|
||||
const m = settings.refresh.autoMinutes;
|
||||
if (!m) return;
|
||||
const tm = setInterval(() => { if (!busyRef.current) refreshPortfolio(true); }, m * 60_000);
|
||||
return () => clearInterval(tm);
|
||||
}, [settings.refresh.autoMinutes, refreshPortfolio]);
|
||||
if (!settings.refresh.autoRead) return;
|
||||
let hiddenAt = 0;
|
||||
const vis = () => {
|
||||
if (document.hidden) { hiddenAt = Date.now(); return; }
|
||||
if (hiddenAt && Date.now() - hiddenAt > 60_000) readAll(false).catch(() => {});
|
||||
hiddenAt = 0;
|
||||
};
|
||||
document.addEventListener('visibilitychange', vis);
|
||||
const m = settings.refresh.everyMinutes;
|
||||
const tm = m ? setInterval(() => { if (!document.hidden) readAll(false).catch(() => {}); }, m * 60_000) : null;
|
||||
return () => { document.removeEventListener('visibilitychange', vis); if (tm) clearInterval(tm); };
|
||||
}, [settings.refresh.autoRead, settings.refresh.everyMinutes, readAll]);
|
||||
|
||||
// icons: cached first, then read (offline, cheap)
|
||||
useEffect(() => {
|
||||
|
|
@ -296,12 +414,15 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const liveFor = useCallback((a: UnifiedApp) => (a.project ? liveOf(a.project) : portfolio?.apps.find(x => x.project === a.target && x.release.source === 'live') ?? live?.value.apps.find(x => x.project === a.target)), [liveOf, portfolio, live]);
|
||||
const iconFor = useCallback((a: UnifiedApp) => (a.project ? icons[a.project.id] ?? a.storeIcon : a.storeIcon), [icons]);
|
||||
const appByKey = useCallback((key: string) => apps.find(a => a.key === key), [apps]);
|
||||
statusRef.current = statusFor;
|
||||
const readerState = useMemo<ReaderState>(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]);
|
||||
|
||||
const value = useMemo<Data>(() => ({
|
||||
ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
importBusy, icons, reader: readerState, schedule, onRead, isReading, readAll, invalidate, setFocus,
|
||||
refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
toasts, toast, updateToast, dismiss, cancelJob,
|
||||
}), [ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
}), [readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
toasts, toast, updateToast, dismiss, cancelJob]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
|
@ -309,6 +430,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
export interface ReadState<T> {
|
||||
data: T | null;
|
||||
/** Past its TTL (lib/freshness.ts): a refresh is due or running. */
|
||||
stale?: boolean;
|
||||
/** When `data` was read (from the store or the cache). */
|
||||
at: string | null;
|
||||
fromCache: boolean;
|
||||
|
|
@ -339,7 +462,7 @@ export function useWrite(projectId: string | null) {
|
|||
const o = await api.writeWithConfirm(w.method, projectId, w.args, job);
|
||||
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(o.dryRun)), progress: undefined, job: undefined });
|
||||
else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined });
|
||||
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
|
||||
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); d.invalidate(projectId); }
|
||||
// exit 3: the command stopped for an owner decision it did not get (a price change of a live product: the
|
||||
// Catalog tab's owner action applies it); everything else it could do was done
|
||||
else if (o.result?.exitCode === 3) { d.updateToast(tid, { kind: 'info', title: t('write.declined'), detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
|
||||
|
|
@ -369,52 +492,63 @@ function notifyAfterRead(method: string, prev: unknown, next: unknown, settings:
|
|||
|
||||
/**
|
||||
* One read method of one app (a project id, or a store-only app's store target). The cached value shows at once with
|
||||
* its time; offline reads (`network: false`) then refresh by themselves, store reads only on demand (they use the
|
||||
* account's API quota). A failed read keeps the last good value and says so: never an empty "all fine".
|
||||
* its time; then, past its TTL (lib/freshness.ts), it is read again through the background scheduler (offline reads
|
||||
* always; store reads while Settings > Reading > auto-read is on). A read the background reader runs for this key
|
||||
* lands here too (the read bus). A failed read keeps the last good value and says so: never an empty "all fine".
|
||||
*/
|
||||
export function useRead<T>(target: string, method: string, o: { network: boolean; args?: Record<string, unknown>; title?: string; appName?: string; skip?: boolean }): ReadState<T> {
|
||||
const d = useData();
|
||||
const argsKey = JSON.stringify(o.args ?? {});
|
||||
const key = readKey(target, method, JSON.parse(argsKey) as Record<string, unknown>);
|
||||
const [s, set] = useState<Omit<ReadState<T>, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
||||
const seq = useRef(0);
|
||||
const dataRef = useRef<T | null>(null);
|
||||
dataRef.current = s.data;
|
||||
const dRef = useRef(d);
|
||||
dRef.current = d;
|
||||
const skipRef = useRef(o.skip);
|
||||
skipRef.current = o.skip;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const my = ++seq.current;
|
||||
const job = newJob(method);
|
||||
set(x => ({ ...x, loading: true, error: null }));
|
||||
const tid = o.network ? d.toast({ id: job, kind: 'job', title: o.title ?? t('toast.reading'), detail: t('toast.readingDetail'), progress: null, job }) : null;
|
||||
try {
|
||||
const r = await api.call<ToolResult>(method, target, { args: JSON.parse(argsKey) as Record<string, unknown> }, job);
|
||||
if (my !== seq.current) return;
|
||||
if (usable(method, r)) {
|
||||
notifyAfterRead(method, dataRef.current, r.data, d.settings, o.appName);
|
||||
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, error: null, command: r.command });
|
||||
if (tid) d.updateToast(tid, { kind: 'ok', title: t('toast.readOk', { what: o.title ?? method }), detail: undefined, progress: undefined, job: undefined });
|
||||
} else {
|
||||
const msg = failureText(r);
|
||||
set(x => ({ ...x, loading: false, error: msg, command: r.command }));
|
||||
if (tid) d.updateToast(tid, { kind: 'bad', title: t('toast.readFailedWhat', { what: o.title ?? method }), detail: withHint(msg), progress: undefined, job: undefined });
|
||||
if (skipRef.current) return;
|
||||
set(x => ({ ...x, loading: true }));
|
||||
await dRef.current.schedule(target, method, JSON.parse(argsKey) as Record<string, unknown>, 0).catch(() => {});
|
||||
}, [target, method, argsKey]);
|
||||
|
||||
// results of this key, whoever asked for them (this card, the background reader, a write's invalidation)
|
||||
useEffect(() => {
|
||||
if (o.skip) return;
|
||||
return d.onRead(key, e => {
|
||||
if (e.kind === 'start') set(x => ({ ...x, loading: true }));
|
||||
else if (e.kind === 'invalidate') { set(x => ({ ...x, stale: true })); refresh(); }
|
||||
else if (e.kind === 'error') { if (/cancel/i.test(e.error)) set(x => ({ ...x, loading: false })); else set(x => ({ ...x, loading: false, error: oneLine(e.error) })); }
|
||||
else {
|
||||
const r = e.result;
|
||||
if (usable(method, r)) {
|
||||
notifyAfterRead(method, dataRef.current, r.data, dRef.current.settings, o.appName);
|
||||
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command });
|
||||
} else set(x => ({ ...x, loading: false, error: oneLine(failureText(r)), command: r.command }));
|
||||
}
|
||||
} catch (e) {
|
||||
if (my !== seq.current) return;
|
||||
const msg = errorText(e);
|
||||
set(x => ({ ...x, loading: false, error: msg }));
|
||||
if (tid) d.updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailedWhat', { what: o.title ?? method }), detail: msg, progress: undefined, job: undefined });
|
||||
}
|
||||
}, [target, method, argsKey, o.network, o.title, o.appName, d.settings]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
});
|
||||
}, [key, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (o.skip) { set({ data: null, at: null, fromCache: false, loading: false, error: null, command: null }); return; }
|
||||
const auto = !o.network || dRef.current.settings.refresh.autoRead;
|
||||
const running = dRef.current.isReading(key);
|
||||
set({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
||||
const go = (at: string | null) => {
|
||||
if (!alive) return;
|
||||
const due = !o.network || isStale(method, at);
|
||||
if (running) set(x => ({ ...x, loading: true }));
|
||||
else if (auto && due) refresh();
|
||||
else set(x => ({ ...x, loading: false }));
|
||||
};
|
||||
api.cached<ToolResult>(method, target, JSON.parse(argsKey) as Record<string, unknown>).then(c => {
|
||||
if (!alive) return;
|
||||
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: !o.network, error: null, command: c.value.command });
|
||||
else set(x => ({ ...x, loading: !o.network }));
|
||||
if (!o.network) refresh();
|
||||
}, () => { if (alive) { set(x => ({ ...x, loading: !o.network })); if (!o.network) refresh(); } });
|
||||
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: true, stale: isStale(method, c.at), error: null, command: c.value.command });
|
||||
go(c && c.value.data !== undefined ? c.at : null);
|
||||
}, () => go(null));
|
||||
return () => { alive = false; };
|
||||
}, [target, method, argsKey, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { StarIcon } from '@phosphor-icons/react/dist/csr/Star';
|
|||
import type { ReactNode } from 'react';
|
||||
import type { ReleasePhase, StoreRelease } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { share } from '../lib/format';
|
||||
import { ago, share } from '../lib/format';
|
||||
import { hueOf, monogram, type Tone } from '../lib/model';
|
||||
import { fixHint } from '../lib/views';
|
||||
import { Icon, type IconName, StoreGlyph } from './icons';
|
||||
|
|
@ -173,3 +173,17 @@ export function StudioMark({ studio, size = 'md' }: { studio: { name: string; co
|
|||
export function StoreOnlyTag() {
|
||||
return <span className="chip outline so-tag" title={t('apps.storeOnlyHint')}><Icon name="cloud" />{t('apps.storeOnly')}</span>;
|
||||
}
|
||||
|
||||
/** The state of one read beside its card's title: "read 3 min ago" with a small spinner while it refreshes, or
|
||||
* "couldn't refresh: <reason>" over the last good value, and the per-card refresh icon (no "Read" button first). */
|
||||
export function ReadControl({ r, label }: { r: { data: unknown; at: string | null; loading: boolean; error: string | null; refresh: () => Promise<void> }; label?: string }) {
|
||||
const text = r.error ? t('read.couldNotRefresh', { reason: r.error }) : r.at ? t('read.ago', { when: ago(r.at) }) : r.loading ? t('read.waiting') : '';
|
||||
return (
|
||||
<span className="read-ctl" data-read-state={r.loading ? 'loading' : r.error ? 'error' : r.data !== null ? 'ok' : 'none'}>
|
||||
{text && <span className={`read-stamp ${r.error ? 'read-err' : ''}`} title={r.error ?? undefined}>{text}</span>}
|
||||
<button className="btn btn-sm btn-icon" onClick={() => { r.refresh(); }} disabled={r.loading} aria-label={label ?? t('read.refresh')} title={label ?? t('read.refresh')}>
|
||||
<Icon name="refresh" className={r.loading ? 'spin' : undefined} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@
|
|||
},
|
||||
"refresh": {
|
||||
"autoMinutes": 0,
|
||||
"autoRead": true,
|
||||
"everyMinutes": 15,
|
||||
"icons": true,
|
||||
"onLaunch": false,
|
||||
"parallel": 4
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@
|
|||
},
|
||||
"refresh": {
|
||||
"autoMinutes": 0,
|
||||
"autoRead": true,
|
||||
"everyMinutes": 15,
|
||||
"icons": true,
|
||||
"onLaunch": false,
|
||||
"parallel": 4
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@
|
|||
},
|
||||
"refresh": {
|
||||
"autoMinutes": 0,
|
||||
"autoRead": true,
|
||||
"everyMinutes": 15,
|
||||
"icons": true,
|
||||
"onLaunch": false,
|
||||
"parallel": 4
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@
|
|||
},
|
||||
"refresh": {
|
||||
"autoMinutes": 0,
|
||||
"autoRead": true,
|
||||
"everyMinutes": 15,
|
||||
"icons": true,
|
||||
"onLaunch": false,
|
||||
"parallel": 4
|
||||
|
|
|
|||
257
apps/desktop/tests/scheduler.test.ts
Normal file
257
apps/desktop/tests/scheduler.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// The background read scheduler (src/lib/scheduler.ts) with a fake clock: concurrency per lane, priority, dedupe,
|
||||
// spacing per credential, backoff with jitter on 429, slowing down and pausing on App Store Connect's quota header;
|
||||
// and the TTLs / plan of src/lib/freshness.ts, the "Copy for AI" markdown of src/lib/issues.ts.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor, ttlOf } from '../src/lib/freshness';
|
||||
import { appleSignatureView, issueMarkdown, playIssueView } from '../src/lib/issues';
|
||||
import { type Clock, type Outcome, ReadScheduler, type ReadTask } from '../src/lib/scheduler';
|
||||
|
||||
class FakeClock implements Clock {
|
||||
t = 0;
|
||||
private timers: { at: number; fn: () => void; id: number }[] = [];
|
||||
private n = 0;
|
||||
rand = 0.5;
|
||||
now() { return this.t; }
|
||||
setTimeout(fn: () => void, ms: number) { const id = ++this.n; this.timers.push({ at: this.t + ms, fn, id }); return id; }
|
||||
clearTimeout(h: unknown) { this.timers = this.timers.filter(x => x.id !== h); }
|
||||
random() { return this.rand; }
|
||||
/** Moves time forward, firing due timers in order (and letting promise callbacks run in between). */
|
||||
async advance(ms: number) {
|
||||
const end = this.t + ms;
|
||||
for (;;) {
|
||||
await flush();
|
||||
const next = this.timers.filter(x => x.at <= end).sort((a, b) => a.at - b.at)[0];
|
||||
if (!next) break;
|
||||
this.t = next.at;
|
||||
this.timers = this.timers.filter(x => x !== next);
|
||||
next.fn();
|
||||
}
|
||||
this.t = end;
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
|
||||
|
||||
/** A task whose run resolves when the test says so. */
|
||||
function controlled() {
|
||||
const runs: { key: string; done: (v: unknown) => void; fail: (e: unknown) => void }[] = [];
|
||||
const task = (key: string, o: Partial<ReadTask<unknown>> = {}): ReadTask<unknown> => ({
|
||||
key, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, ...o,
|
||||
run: () => new Promise((done, fail) => { runs.push({ key, done, fail }); }),
|
||||
});
|
||||
return { runs, task };
|
||||
}
|
||||
|
||||
describe('ReadScheduler', () => {
|
||||
test('at most N at once per lane, the rest wait for a slot', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 2, play: 1 } });
|
||||
const { runs, task } = controlled();
|
||||
for (const k of ['a', 'b', 'c', 'd']) s.enqueue(task(k)).catch(() => {});
|
||||
s.enqueue(task('p1', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {});
|
||||
s.enqueue(task('p2', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1']);
|
||||
expect(s.stats()).toMatchObject({ running: 3, queued: 3, total: 6 });
|
||||
runs[0]!.done(1);
|
||||
await clock.advance(0);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1', 'c']);
|
||||
// a task in both lanes needs a slot in each
|
||||
const both = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1, play: 1 } });
|
||||
const c2 = controlled();
|
||||
both.enqueue(c2.task('apple-only')).catch(() => {});
|
||||
both.enqueue(c2.task('both', { lanes: ['apple', 'play'], credentials: [] })).catch(() => {});
|
||||
both.enqueue(c2.task('play-only', { lanes: ['play'], credentials: [] })).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(c2.runs.map(r => r.key)).toEqual(['apple-only', 'play-only']);
|
||||
});
|
||||
|
||||
test('priority first (visible app, portfolio, the rest), FIFO within one; re-enqueueing raises it', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1 } });
|
||||
const { runs, task } = controlled();
|
||||
s.enqueue(task('first')).catch(() => {});
|
||||
s.enqueue(task('rest-1', { priority: 2 })).catch(() => {});
|
||||
s.enqueue(task('rest-2', { priority: 2 })).catch(() => {});
|
||||
s.enqueue(task('portfolio', { priority: 1 })).catch(() => {});
|
||||
s.enqueue(task('visible', { priority: 0 })).catch(() => {});
|
||||
s.enqueue(task('rest-2', { priority: 0 })).catch(() => {}); // the owner opened that tab
|
||||
await clock.advance(0);
|
||||
for (let i = 0; i < 5; i++) { runs.at(-1)!.done(i); await clock.advance(0); }
|
||||
expect(runs.map(r => r.key)).toEqual(['first', 'rest-2', 'visible', 'portfolio', 'rest-1']);
|
||||
});
|
||||
|
||||
test('one in-flight read per key: the same promise, one run', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0 });
|
||||
const { runs, task } = controlled();
|
||||
const p1 = s.enqueue(task('k'));
|
||||
const p2 = s.enqueue(task('k'));
|
||||
await clock.advance(0);
|
||||
const p3 = s.enqueue(task('k'));
|
||||
expect(s.has('k')).toBe(true);
|
||||
expect(runs).toHaveLength(1);
|
||||
runs[0]!.done('v');
|
||||
expect(await Promise.all([p1, p2, p3])).toEqual(['v', 'v', 'v']);
|
||||
expect(s.has('k')).toBe(false);
|
||||
// finished: a new enqueue runs again
|
||||
s.enqueue(task('k')).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(runs).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('starts on one credential are spaced; other credentials are not held up', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 400, limits: { apple: 8 } });
|
||||
const { runs, task } = controlled();
|
||||
for (const k of ['a', 'b', 'c']) s.enqueue(task(k)).catch(() => {});
|
||||
s.enqueue(task('other', { credentials: ['apple:k2'] })).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'other']);
|
||||
await clock.advance(399);
|
||||
expect(runs).toHaveLength(2);
|
||||
await clock.advance(1);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'other', 'b']);
|
||||
await clock.advance(400);
|
||||
expect(runs.map(r => r.key)).toEqual(['a', 'other', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('429: runs again later with exponential backoff and jitter, the key waits too; gives up after maxAttempts', async () => {
|
||||
const clock = new FakeClock();
|
||||
let attempts = 0;
|
||||
const classify = (r: unknown): Outcome => ({ throttled: (r as { status: number }).status === 429 });
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 1000, backoffMaxMs: 60_000, maxAttempts: 4, classify });
|
||||
const starts: number[] = [];
|
||||
const p = s.enqueue({ key: 'x', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { starts.push(clock.now()); attempts++; return { status: attempts < 3 ? 429 : 200 }; } });
|
||||
let other = false;
|
||||
await clock.advance(0);
|
||||
s.enqueue({ key: 'y', lanes: ['play'], credentials: ['play:k'], priority: 2, run: async () => { other = true; return { status: 200 }; } }).catch(() => {});
|
||||
await clock.advance(0);
|
||||
// first try at 0; jitter (rand 0.5) -> 0.75 x the exponential step: 750 ms, then 1500 ms
|
||||
expect(starts).toEqual([0]);
|
||||
expect(s.stats().paused['play:k']).toBe(750);
|
||||
await clock.advance(749);
|
||||
expect(starts).toEqual([0]);
|
||||
expect(other).toBe(false); // the throttled key holds back its other reads as well
|
||||
await clock.advance(1);
|
||||
expect(starts).toEqual([0, 750]);
|
||||
await clock.advance(1500);
|
||||
expect(starts).toEqual([0, 750, 2250]);
|
||||
expect(await p).toEqual({ status: 200 });
|
||||
await clock.advance(0);
|
||||
expect(other).toBe(true);
|
||||
// always 429: resolved with the last answer after maxAttempts, counted as failed
|
||||
const s2 = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 10, maxAttempts: 3, classify });
|
||||
let n = 0;
|
||||
const q = s2.enqueue({ key: 'z', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { n++; return { status: 429 }; } });
|
||||
await clock.advance(1000);
|
||||
expect(await q).toEqual({ status: 429 });
|
||||
expect(n).toBe(3);
|
||||
});
|
||||
|
||||
test('App Store Connect quota: spreads the rest of the hour when it runs low, pauses at the reserve', async () => {
|
||||
const clock = new FakeClock();
|
||||
let remaining = 3412;
|
||||
const classify = (_r: unknown, _e: unknown, t: ReadTask<unknown>): Outcome => ({ quota: { credential: t.credentials[0]!, limit: 3600, remaining } });
|
||||
const s = new ReadScheduler({ clock, spacingMs: 100, reserve: 100, slowBelow: 0.2, pauseMs: 600_000, limits: { apple: 4 }, classify });
|
||||
const starts: number[] = [];
|
||||
const run = async () => { starts.push(clock.now()); return {}; };
|
||||
const add = (k: string) => s.enqueue({ key: k, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, run }).catch(() => {});
|
||||
add('a'); add('b');
|
||||
await clock.advance(0);
|
||||
await clock.advance(100);
|
||||
expect(starts).toEqual([0, 100]); // plenty left: the base spacing
|
||||
expect(s.stats().quota['apple:k1']).toMatchObject({ limit: 3600, remaining: 3412 });
|
||||
remaining = 500; // under 20 %: (500 - 100 reserve) left for the hour -> one start every 9 s
|
||||
add('c'); add('d');
|
||||
await clock.advance(100);
|
||||
const c = starts.at(-1)!;
|
||||
await clock.advance(8_000);
|
||||
expect(starts.at(-1)).toBe(c);
|
||||
await clock.advance(1_100);
|
||||
expect(starts.at(-1)! - c).toBe(9_000);
|
||||
remaining = 80; // at the reserve: the key pauses for 10 minutes
|
||||
add('e'); add('f');
|
||||
await clock.advance(9_000);
|
||||
const e = starts.at(-1)!;
|
||||
await clock.advance(599_000);
|
||||
expect(starts.at(-1)).toBe(e);
|
||||
expect(Object.keys(s.stats().paused)).toEqual(['apple:k1']);
|
||||
await clock.advance(2_000);
|
||||
expect(starts.at(-1)! - e).toBe(600_000);
|
||||
// the quota a run reported stays the lowest of the hour, and is forgotten after it
|
||||
remaining = 3000;
|
||||
add('g');
|
||||
await clock.advance(600_000);
|
||||
expect(s.stats().quota['apple:k1']!.remaining).toBe(80);
|
||||
await clock.advance(3_600_000);
|
||||
expect(s.stats().quota['apple:k1']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('progress for the status line: done of total in one round, failures, clear()', async () => {
|
||||
const clock = new FakeClock();
|
||||
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1 } });
|
||||
const { runs, task } = controlled();
|
||||
const ps = ['a', 'b', 'c'].map(k => s.enqueue(task(k)).catch(e => String(e)));
|
||||
await clock.advance(0);
|
||||
runs[0]!.done(1);
|
||||
await clock.advance(0);
|
||||
expect(s.stats()).toMatchObject({ done: 1, running: 1, queued: 1, total: 3 });
|
||||
runs[1]!.fail(new Error('boom'));
|
||||
await clock.advance(0);
|
||||
expect(s.stats()).toMatchObject({ done: 2, failed: 1, total: 3 });
|
||||
s.clear();
|
||||
runs[2]?.done(1);
|
||||
await clock.advance(0);
|
||||
expect(await ps[1]).toBe('Error: boom');
|
||||
expect(s.stats()).toMatchObject({ done: 0, total: 0, running: 0, queued: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('freshness', () => {
|
||||
test('TTLs: status 5 min, reviews 15 min, catalog / prices / listings 30 min, health / analytics 6 h', () => {
|
||||
expect([ttlOf('apple.status'), ttlOf('play.status'), ttlOf('reviews.list'), ttlOf('catalog.diff'), ttlOf('prices.current'), ttlOf('project.listings'), ttlOf('health'), ttlOf('analytics.compare')].map(x => x / 60_000))
|
||||
.toEqual([5, 5, 15, 30, 30, 30, 360, 360]);
|
||||
const now = Date.parse('2026-10-13T12:00:00Z');
|
||||
expect(isStale('apple.status', '2026-10-13T11:56:00Z', now)).toBe(false);
|
||||
expect(isStale('apple.status', '2026-10-13T11:55:00Z', now)).toBe(true);
|
||||
expect(isStale('health', '2026-10-13T07:00:00Z', now)).toBe(false);
|
||||
expect(isStale('health', null, now)).toBe(true);
|
||||
expect(isStale('health', 'garbage', now)).toBe(true);
|
||||
});
|
||||
test('lanes, the reads of an app, keys, throttling text, one-line reasons', () => {
|
||||
expect(lanesOf('apple.status', { ios: true, android: true })).toEqual(['apple']);
|
||||
expect(lanesOf('health', { ios: true, android: true })).toEqual(['apple', 'play']);
|
||||
expect(lanesOf('health', { ios: false, android: true })).toEqual(['play']);
|
||||
expect(lanesOf('scan', { ios: true, android: true })).toEqual(['local']);
|
||||
const all = readsFor({ folder: true, ios: true, android: true }).map(r => r.method);
|
||||
expect(all).toEqual(expect.arrayContaining(['apple.status', 'play.status', 'apple.testflight_status', 'reviews.list', 'health', 'catalog.diff', 'prices.current', 'project.listings', 'validate', 'scan', 'analytics.compare', 'aso.audit', 'apple.ppo_status']));
|
||||
expect(readsFor({ folder: false, ios: false, android: true }).map(r => r.method)).toEqual(['play.status', 'reviews.list', 'health']);
|
||||
expect(readKey('p1', 'reviews.list', { days: 30 })).toBe(readKey('p1', 'reviews.list', { days: 30 }));
|
||||
expect(readKey('p1', 'reviews.list', { days: 30 })).not.toBe(readKey('p1', 'reviews.list', {}));
|
||||
expect(looksThrottled('GET /v1/apps -> 429 Too Many Requests')).toBe(true);
|
||||
expect(looksThrottled('{"error":{"status":"RESOURCE_EXHAUSTED"}}')).toBe(true);
|
||||
expect(looksThrottled('-> 403 forbidden')).toBe(false);
|
||||
expect(oneLine('12:00:01 [health] reading\n12:00:02 [health] error: key not found\n')).toBe('error: key not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue detail', () => {
|
||||
test('Copy for AI: title, type, counts, versions, top frames, console link', () => {
|
||||
const v = playIssueView({ type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.Combo.update', reports: 412, users: 268, firstVersion: '224', lastVersion: '231', id: 'a1', uri: 'https://play.google.com/console/x' });
|
||||
expect(v.sampleArgs).toEqual({ issue: 'a1' });
|
||||
const stack = Array.from({ length: 20 }, (_, i) => `\tat com.example.F${i}(F.kt:${i})`);
|
||||
const md = issueMarkdown('Prism Puddle', v, { store: 'play', version: '231', device: 'Galaxy A12', os: 'API 31', stack });
|
||||
expect(md).toContain('## Google Play CRASH: java.lang.IllegalStateException');
|
||||
expect(md).toContain('- Reports: 412, users: 268');
|
||||
expect(md).toContain('- versionCode: 224 to 231');
|
||||
expect(md).toContain('F14(F.kt:14)');
|
||||
expect(md).not.toContain('F15(F.kt:15)');
|
||||
expect(md).toContain('... 5 more frames');
|
||||
expect(md).toContain('Console: https://play.google.com/console/x');
|
||||
const a = appleSignatureView({ build: '48', type: 'HANGS', signature: 'ComboMeter.update + 212', weight: 0.31, id: 's1' });
|
||||
expect(a.sampleArgs).toEqual({ signature: 's1' });
|
||||
expect(issueMarkdown('X', a, null)).toContain('- Share of reports: 31 %');
|
||||
expect(playIssueView({ type: 'CRASH', cause: '', location: '', reports: 1, users: 1 }).sampleArgs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import { createHash, createPrivateKey, sign } from 'node:crypto';
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { ascKey } from '../lib/credentials';
|
||||
import { errorBody } from '../lib/http';
|
||||
import { noteAsc } from '../lib/quota';
|
||||
import { AmbiguousWriteError, retrySafe } from '../lib/upsert';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
|
|
@ -77,6 +78,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
|||
if (!safe) throw new AmbiguousWriteError(`${method} ${path} -> no answer (${(e as Error).message})`);
|
||||
throw e;
|
||||
}
|
||||
noteAsc(r);
|
||||
const t = await r.text();
|
||||
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 5) { await rt.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
|
||||
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${path} -> ${r.status} ${errorBody(t)}`, r.status);
|
||||
|
|
@ -89,6 +91,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
|||
async function getAs(path: string, accept: string): Promise<J> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
|
||||
noteAsc(r);
|
||||
const t = await r.text();
|
||||
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await rt.sleep(2000 * attempt); continue; }
|
||||
if (!r.ok) throw new Error(`GET ${path} -> ${r.status} ${errorBody(t, 800)}`);
|
||||
|
|
@ -101,6 +104,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
|||
async function bytes(path: string, accept = 'application/a-gzip'): Promise<Uint8Array | null> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
|
||||
noteAsc(r);
|
||||
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await r.body?.cancel(); await rt.sleep(2000 * attempt); continue; }
|
||||
if (r.status === 404) { await r.body?.cancel(); return null; }
|
||||
if (!r.ok) throw new Error(`GET ${path.replace(/\?.*$/, '')} -> ${r.status} ${errorBody(await r.text(), 600)}`);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { Ctx } from './lib/ctx';
|
|||
import { REPORTING_SCOPE } from './lib/google-auth';
|
||||
import {
|
||||
type AppleHealth, ANR_METRICS, betaCrashCounts, CRASH_METRICS, deviceBreakdown, healthFindings, healthMarkdown, type HealthSnapshot, healthTable,
|
||||
issuesQuery, latestDailyEnd, metricQuery, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, ymd, ymdString,
|
||||
type IssueSample, issuesQuery, latestDailyEnd, metricQuery, parseDiagnosticLogs, parseErrorReports, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, reportsQuery, ymd, ymdString,
|
||||
} from './lib/health';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
import { plainError, storeReadError } from './lib/google-errors';
|
||||
|
|
@ -55,7 +55,7 @@ export async function collectAppleHealth(root: string, bundleId: string, appIdHi
|
|||
for (const b of builds) {
|
||||
log('health', `apple: diagnostic signatures of build ${b.attributes.version}`);
|
||||
for (const s of ((await c.asc('GET', `/v1/builds/${b.id}/diagnosticSignatures?limit=10`).catch(none({ data: [] }))).data ?? []) as J[])
|
||||
signatures.push({ build: b.attributes.version, type: s.attributes.diagnosticType, signature: s.attributes.signature ?? '', weight: Number(s.attributes.weight ?? 0) });
|
||||
signatures.push({ build: b.attributes.version, type: s.attributes.diagnosticType, signature: s.attributes.signature ?? '', weight: Number(s.attributes.weight ?? 0), ...(s.id ? { id: String(s.id) } : {}) });
|
||||
}
|
||||
signatures.sort((a, b) => b.weight - a.weight);
|
||||
log('health', 'apple: TestFlight crash reports');
|
||||
|
|
@ -65,6 +65,24 @@ export async function collectAppleHealth(root: string, bundleId: string, appIdHi
|
|||
return { metrics, signatures: signatures.slice(0, 15), betaCrashes: betaCrashCounts(subs, buildNo, since) };
|
||||
}
|
||||
|
||||
/** One sample report of a Play error issue (`--issue <id>`, errorReports:search over the last `days`). */
|
||||
export async function playIssueSample(root: string, pkg: string, issueId: string, days: number): Promise<IssueSample | null> {
|
||||
const g = googleClient(root, REPORTING_SCOPE);
|
||||
const app = `${REPORTING_API}/apps/${encodeURIComponent(pkg)}`;
|
||||
const set = await g.json<Parameters<typeof latestDailyEnd>[0]>('GET', `${app}/crashRateMetricSet`);
|
||||
const end = latestDailyEnd(set) ?? ymd(new Date(), 2);
|
||||
log('health', `play: a sample report of issue ${issueId}`);
|
||||
return parseErrorReports(await g.json('GET', `${app}/errorReports:search?${reportsQuery(end, days, issueId)}`));
|
||||
}
|
||||
|
||||
/** The logs of one App Store diagnostic signature (`--signature <id>`). */
|
||||
export async function appleSignatureSample(root: string, bundleId: string, appIdHint: string | undefined, id: string): Promise<IssueSample | null> {
|
||||
if (!/^[A-Za-z0-9_-]{1,200}$/.test(id)) throw new Error('--signature: a diagnostic signature id');
|
||||
const c = await ascClient(root, bundleId, appIdHint);
|
||||
log('health', `apple: logs of diagnostic signature ${id}`);
|
||||
return parseDiagnosticLogs(await c.getAs(`/v1/diagnosticSignatures/${encodeURIComponent(id)}/logs?limit=1`, 'application/vnd.apple.diagnostic-logs+json'));
|
||||
}
|
||||
|
||||
/** Both stores (or the chosen one); a store that cannot be read becomes { error } instead of failing the run. */
|
||||
export async function collectHealth(ctx: Ctx, o: { apple: boolean; play: boolean; days: number }): Promise<HealthSnapshot> {
|
||||
const { config, root } = ctx.loaded;
|
||||
|
|
@ -91,10 +109,23 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const weekly = a.has('weekly');
|
||||
const days = a.num('days', 7);
|
||||
if (!(days >= 1 && days <= 90)) throw new Error('--days: 1-90');
|
||||
const issue = a.get('issue'), signature = a.get('signature');
|
||||
if (issue || signature) {
|
||||
// one issue's sample stack (the desktop's Health detail): --issue <Play error issue id> or --signature <App Store id>
|
||||
const { config, root } = ctx.loaded;
|
||||
if (issue && !config.play) throw new Error('--issue: the config has no play section');
|
||||
if (signature && !config.apple) throw new Error('--signature: the config has no apple section');
|
||||
const sample = issue ? await playIssueSample(root, config.play!.packageName, issue, Math.max(days, 28)) : await appleSignatureSample(root, config.apple!.bundleId, config.apple!.appId, signature!);
|
||||
if (a.has('json')) { printJson({ sample }); return; }
|
||||
console.log(sample ? [`${sample.store} ${sample.type ?? ''} ${sample.version ?? ''} ${sample.device ?? ''} ${sample.os ?? ''}`.replace(/\s+/g, ' ').trim(), ...sample.stack].join('\n') : 'no sample report in the window');
|
||||
return;
|
||||
}
|
||||
const h = await collectHealth(ctx, { apple: both || a.has('apple'), play: both || a.has('play'), days });
|
||||
if (a.has('json')) { printJson(h); return; }
|
||||
console.log(healthTable(h));
|
||||
for (const [s, x] of [['Google Play', h.play], ['App Store', h.apple]] as const) if (x && 'error' in x) console.log(`\n${s}: ${x.error}`);
|
||||
const play = h.play && !('error' in h.play) ? h.play : null;
|
||||
for (const i of play?.issues.filter(x => x.hint) ?? []) console.log(`\nlikely cause of ${i.cause.slice(0, 80)}: ${i.hint!.cause}\nfix: ${i.hint!.fix}`);
|
||||
console.log('\n' + formatFindings(healthFindings(h)));
|
||||
if (weekly) {
|
||||
const dir = ctx.loaded.resolve(a.get('out') ?? '.storebound/health');
|
||||
|
|
|
|||
22
cli/lib/crash-hints.ts
Normal file
22
cli/lib/crash-hints.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// "Likely cause" hints for known crash signatures, shared by `storebound health` and the desktop Health tab.
|
||||
// Only entries verified in a real app (or documented by the library itself) go here: a wrong hint costs more than none.
|
||||
|
||||
export interface CrashHint { id: string; cause: string; fix: string }
|
||||
|
||||
interface Rule extends CrashHint { match: (text: string) => boolean }
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
id: 'capacitor-permission-states-npe',
|
||||
match: x => /NullPointerException/.test(x) && /com\.getcapacitor\.Bridge\.getPermissionStates/.test(x),
|
||||
cause: 'A Capacitor plugin\'s checkPermissions() (for example FirebaseMessaging) ran during an activity transition (focus / visibilitychange while the app resumes or rotates), when the bridge has no activity.',
|
||||
fix: 'Ask for the permission state only when App.getState() says active, the page is visible and the app has been active for a short delay (e.g. 500 ms); do not call checkPermissions() from focus / visibilitychange handlers directly.',
|
||||
},
|
||||
];
|
||||
|
||||
/** The hint for an issue's text (cause, location, top frames), or null. */
|
||||
export function crashHint(...texts: (string | undefined | null)[]): CrashHint | null {
|
||||
const text = texts.filter(Boolean).join('\n');
|
||||
const r = RULES.find(x => x.match(text));
|
||||
return r ? { id: r.id, cause: r.cause, fix: r.fix } : null;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
// https://developer.apple.com/documentation/appstoreconnectapi/power-and-performance-metrics-and-logs
|
||||
// GET /v1/apps/{id}/perfPowerMetrics (application/vnd.apple.xcode-metrics+json), GET /v1/builds/{id}/diagnosticSignatures
|
||||
// (HANGS, DISK_WRITES, LAUNCHES), GET /v1/apps/{id}/betaFeedbackCrashSubmissions (TestFlight crash reports, API 4.0+).
|
||||
import { type CrashHint, crashHint } from './crash-hints';
|
||||
import type { Finding, Level } from './validate-rules';
|
||||
import { mdTable, pct, textTable } from './table';
|
||||
import { findingText, type GoogleFinding, parseGoogleError, plainError } from './google-errors';
|
||||
|
|
@ -89,11 +90,79 @@ export function issuesQuery(end: Ymd, days: number, o: { type?: 'CRASH' | 'ANR';
|
|||
return p.toString();
|
||||
}
|
||||
|
||||
export interface Issue { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string }
|
||||
export interface Issue {
|
||||
/** Likely cause of a known signature (cli/lib/crash-hints.ts). */
|
||||
hint?: CrashHint;
|
||||
type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string;
|
||||
/** The issue id (the last part of its resource name "apps/<pkg>/errorIssues/<id>"): `health --issue <id>` reads a sample report. */
|
||||
id?: string;
|
||||
/** versionCode of the first and last app version with the issue; Android API levels; the last report's time. */
|
||||
firstVersion?: string; firstOs?: string; lastOs?: string; lastSeen?: string;
|
||||
}
|
||||
|
||||
export function parseIssues(resp: { errorIssues?: { type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string; lastAppVersion?: { versionCode?: string }; issueUri?: string }[] }): Issue[] {
|
||||
return (resp.errorIssues ?? []).map(i => ({ type: i.type === 'APPLICATION_NOT_RESPONDING' ? 'ANR' : i.type ?? '?', cause: i.cause ?? '', location: i.location ?? '',
|
||||
reports: Number(i.errorReportCount ?? 0), users: Number(i.distinctUsers ?? 0), lastVersion: i.lastAppVersion?.versionCode, uri: i.issueUri }));
|
||||
interface ApiIssue {
|
||||
name?: string; type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string; issueUri?: string; lastErrorReportTime?: string;
|
||||
firstAppVersion?: { versionCode?: string }; lastAppVersion?: { versionCode?: string }; firstOsVersion?: { apiLevel?: string }; lastOsVersion?: { apiLevel?: string };
|
||||
}
|
||||
|
||||
export function parseIssues(resp: { errorIssues?: ApiIssue[] }): Issue[] {
|
||||
return (resp.errorIssues ?? []).map(i => {
|
||||
const extra: Partial<Issue> = {};
|
||||
const id = i.name?.split('/').pop();
|
||||
if (id) extra.id = id;
|
||||
if (i.firstAppVersion?.versionCode) extra.firstVersion = i.firstAppVersion.versionCode;
|
||||
if (i.firstOsVersion?.apiLevel) extra.firstOs = i.firstOsVersion.apiLevel;
|
||||
if (i.lastOsVersion?.apiLevel) extra.lastOs = i.lastOsVersion.apiLevel;
|
||||
if (i.lastErrorReportTime) extra.lastSeen = i.lastErrorReportTime;
|
||||
const hint = crashHint(i.cause, i.location);
|
||||
if (hint) extra.hint = hint;
|
||||
return { type: i.type === 'APPLICATION_NOT_RESPONDING' ? 'ANR' : i.type ?? '?', cause: i.cause ?? '', location: i.location ?? '',
|
||||
reports: Number(i.errorReportCount ?? 0), users: Number(i.distinctUsers ?? 0), lastVersion: i.lastAppVersion?.versionCode, uri: i.issueUri, ...extra };
|
||||
});
|
||||
}
|
||||
|
||||
/** errorReports:search query of one issue (the newest report first), over the same UTC-midnight window as the issues. */
|
||||
export function reportsQuery(end: Ymd, days: number, issueId: string): string {
|
||||
if (!/^[A-Za-z0-9_-]{1,200}$/.test(issueId)) throw new Error(`--issue: an error issue id (letters, digits, - and _), not "${issueId.slice(0, 40)}"`);
|
||||
const p = new URLSearchParams(issuesQuery(end, days));
|
||||
p.delete('orderBy');
|
||||
p.set('filter', `errorIssueId = ${issueId}`);
|
||||
p.set('pageSize', '1');
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
/** One sample report of an issue (Play errorReports, App Store diagnostic logs): the stack as lines, top frame first. */
|
||||
export interface IssueSample { store: 'play' | 'apple'; time?: string; version?: string; os?: string; device?: string; type?: string; stack: string[] }
|
||||
|
||||
const MAX_FRAMES = 60;
|
||||
|
||||
export function parseErrorReports(resp: { errorReports?: { reportText?: string; eventTime?: string; type?: string; appVersion?: { versionCode?: string }; osVersion?: { apiLevel?: string };
|
||||
deviceModel?: { marketingName?: string; deviceId?: { buildBrand?: string; buildDevice?: string } } }[] }): IssueSample | null {
|
||||
const r = resp.errorReports?.[0];
|
||||
if (!r) return null;
|
||||
const stack = (r.reportText ?? '').split('\n').map(l => l.replace(/\s+$/, '')).filter(l => l.trim()).slice(0, MAX_FRAMES);
|
||||
const dev = r.deviceModel?.marketingName ?? [r.deviceModel?.deviceId?.buildBrand, r.deviceModel?.deviceId?.buildDevice].filter(Boolean).join('/');
|
||||
return { store: 'play', stack, ...(r.eventTime ? { time: r.eventTime } : {}), ...(r.appVersion?.versionCode ? { version: r.appVersion.versionCode } : {}),
|
||||
...(r.osVersion?.apiLevel ? { os: `API ${r.osVersion.apiLevel}` } : {}), ...(dev ? { device: dev } : {}), ...(r.type ? { type: r.type } : {}) };
|
||||
}
|
||||
|
||||
interface ApiFrame { symbolName?: string; binaryName?: string; offsetIntoSymbol?: number; subFrames?: ApiFrame[] }
|
||||
|
||||
/** GET /v1/diagnosticSignatures/{id}/logs (application/vnd.apple.diagnostic-logs+json): the first log's heaviest call
|
||||
* stack, following the first sub frame down, as "binary symbol + offset" lines. */
|
||||
export function parseDiagnosticLogs(resp: { productData?: { diagnosticLogs?: { callStackTree?: { callStacks?: { callStackRootFrames?: ApiFrame[] }[] }[];
|
||||
diagnosticMetaData?: { appVersion?: string; buildVersion?: string; osVersion?: string; deviceType?: string; eventTime?: string } }[] }[] }): IssueSample | null {
|
||||
const log = resp.productData?.flatMap(p => p.diagnosticLogs ?? [])[0];
|
||||
if (!log) return null;
|
||||
const stack: string[] = [];
|
||||
let f: ApiFrame | undefined = log.callStackTree?.[0]?.callStacks?.[0]?.callStackRootFrames?.[0];
|
||||
while (f && stack.length < MAX_FRAMES) {
|
||||
stack.push(`${f.binaryName ?? '?'} ${f.symbolName ?? '???'}${f.offsetIntoSymbol !== undefined ? ` + ${f.offsetIntoSymbol}` : ''}`);
|
||||
f = f.subFrames?.[0];
|
||||
}
|
||||
const m = log.diagnosticMetaData ?? {};
|
||||
const version = [m.appVersion, m.buildVersion ? `(${m.buildVersion})` : ''].filter(Boolean).join(' ');
|
||||
return { store: 'apple', stack, ...(version ? { version } : {}), ...(m.osVersion ? { os: m.osVersion } : {}), ...(m.deviceType ? { device: m.deviceType } : {}), ...(m.eventTime ? { time: m.eventTime } : {}) };
|
||||
}
|
||||
|
||||
export interface PlayHealth {
|
||||
|
|
@ -111,7 +180,7 @@ export interface AppleHealth {
|
|||
/** Latest values per metric identifier (e.g. hangRate, launchTime) from perfPowerMetrics, with the app version. */
|
||||
metrics: { category: string; metric: string; unit: string; version: string; value: number; goal?: string }[];
|
||||
/** Top diagnostic signatures of the latest builds. */
|
||||
signatures: { build: string; type: string; signature: string; weight: number }[];
|
||||
signatures: { build: string; type: string; signature: string; weight: number; /** `health --signature <id>` reads its logs. */ id?: string }[];
|
||||
/** TestFlight crash submissions in the window (count per build). */
|
||||
betaCrashes: { build: string; count: number }[];
|
||||
}
|
||||
|
|
@ -262,7 +331,8 @@ export function healthMarkdown(h: HealthSnapshot, o: { appName: string; previous
|
|||
['user-perceived ANR rate', pct(a), pct(prev ? weightedAverage(prev.anr, 'userPerceivedAnrRate') : null), delta(a, prev ? weightedAverage(prev.anr, 'userPerceivedAnrRate') : null), pct(PLAY_THRESHOLDS.anr)],
|
||||
]), '');
|
||||
if (p.issues.length) out.push('### Top issues', '', mdTable(['type', 'cause', 'location', 'reports', 'users', 'last versionCode'],
|
||||
p.issues.map(i => [i.type, i.cause.slice(0, 80), i.location.slice(0, 80), i.reports, i.users, i.lastVersion ?? ''])), '');
|
||||
p.issues.map(i => [i.type, i.cause.slice(0, 80), i.location.slice(0, 80), i.reports, i.users, i.lastVersion ?? ''])), '',
|
||||
...p.issues.filter(i => i.hint).map(i => `- ${i.cause.slice(0, 80)}: likely cause: ${i.hint!.cause} Fix: ${i.hint!.fix}`), ...(p.issues.some(i => i.hint) ? [''] : []));
|
||||
}
|
||||
const ap = h.apple && !('error' in h.apple) ? h.apple : null;
|
||||
if (ap) {
|
||||
|
|
|
|||
51
cli/lib/quota.ts
Normal file
51
cli/lib/quota.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Store API quota seen by a command, passed to `storebound serve` (the desktop's read scheduler) as one marked stderr
|
||||
// line per change: App Store Connect's X-Rate-Limit header ("user-hour-lim:3600;user-hour-rem:3412;") and 429 /
|
||||
// RESOURCE_EXHAUSTED answers of the Google APIs. Outside serve (STOREBOUND_MACHINE unset) nothing is printed.
|
||||
|
||||
/** Prefix of the quota line on stderr; runner.ts takes it out of the output. */
|
||||
export const QUOTA_MARK = '\u001estorebound-quota ';
|
||||
|
||||
export interface QuotaInfo {
|
||||
store: 'apple' | 'play';
|
||||
/** Requests per hour of this key (App Store Connect). */
|
||||
limit?: number;
|
||||
/** Requests left in the current hour. */
|
||||
remaining?: number;
|
||||
/** The store answered 429 / RESOURCE_EXHAUSTED at least once in this run. */
|
||||
throttled?: boolean;
|
||||
}
|
||||
|
||||
/** "user-hour-lim:3600;user-hour-rem:3412;" -> { limit: 3600, remaining: 3412 }; null when it is not there. */
|
||||
export function parseAscRateLimit(header: string | null | undefined): { limit: number; remaining: number } | null {
|
||||
if (!header) return null;
|
||||
const lim = /user-hour-lim:(\d+)/i.exec(header), rem = /user-hour-rem:(\d+)/i.exec(header);
|
||||
return lim && rem ? { limit: Number(lim[1]), remaining: Number(rem[1]) } : null;
|
||||
}
|
||||
|
||||
let last = '';
|
||||
function emit(q: QuotaInfo): void {
|
||||
if (process.env.STOREBOUND_MACHINE !== '1') return;
|
||||
const line = JSON.stringify(q);
|
||||
if (line === last) return;
|
||||
last = line;
|
||||
process.stderr.write(QUOTA_MARK + line + '\n');
|
||||
}
|
||||
|
||||
/** After every App Store Connect answer. */
|
||||
export function noteAsc(r: { status: number; headers: { get(n: string): string | null } }): void {
|
||||
const rl = parseAscRateLimit(r.headers.get('x-rate-limit'));
|
||||
if (rl) emit({ store: 'apple', ...rl, ...(r.status === 429 ? { throttled: true } : {}) });
|
||||
else if (r.status === 429) emit({ store: 'apple', throttled: true });
|
||||
}
|
||||
|
||||
/** After every Google answer (Play Developer API, Reporting API). */
|
||||
export function noteGoogle(status: number): void {
|
||||
if (status === 429) emit({ store: 'play', throttled: true });
|
||||
}
|
||||
|
||||
/** Folds the marked lines of one run into one value (the latest counts; throttled if any line said so). */
|
||||
export function mergeQuota(a: QuotaInfo | undefined, b: QuotaInfo): QuotaInfo {
|
||||
if (!a || a.store !== b.store) return b;
|
||||
const remaining = b.remaining ?? a.remaining, limit = b.limit ?? a.limit;
|
||||
return { store: b.store, ...(limit === undefined ? {} : { limit }), ...(remaining === undefined ? {} : { remaining }), ...(a.throttled || b.throttled ? { throttled: true } : {}) };
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { RESULT_MARK } from './log';
|
||||
import { mergeQuota, QUOTA_MARK, type QuotaInfo } from './quota';
|
||||
import { toolPath } from './tools';
|
||||
import * as rt from './rt';
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ export interface RunOptions {
|
|||
}
|
||||
|
||||
/** `err`: the stderr lines only (for the command's own error line). */
|
||||
export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean }
|
||||
export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean; /** Store quota the run saw (cli/lib/quota.ts). */ quota?: QuotaInfo }
|
||||
|
||||
export interface Running { result: Promise<RunResult>; cancel(): void }
|
||||
|
||||
|
|
@ -73,11 +74,16 @@ export function runCommand(argv: string[], o: RunOptions): Running {
|
|||
const lines: string[] = [];
|
||||
const errs: string[] = [];
|
||||
let data: unknown;
|
||||
let quota: QuotaInfo | undefined;
|
||||
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
|
||||
if (kind === 'stdout' && line.startsWith(RESULT_MARK)) {
|
||||
try { data = JSON.parse(line.slice(RESULT_MARK.length)); } catch { lines.push(line); }
|
||||
return;
|
||||
}
|
||||
if (kind === 'stderr' && line.startsWith(QUOTA_MARK)) {
|
||||
try { quota = mergeQuota(quota, JSON.parse(line.slice(QUOTA_MARK.length)) as QuotaInfo); } catch { /* a broken quota line is dropped */ }
|
||||
return;
|
||||
}
|
||||
lines.push(line);
|
||||
if (kind === 'stderr') errs.push(line);
|
||||
o.onLine?.(line, kind);
|
||||
|
|
@ -85,7 +91,7 @@ export function runCommand(argv: string[], o: RunOptions): Running {
|
|||
const result = (async (): Promise<RunResult> => {
|
||||
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
||||
const code = await p.exited;
|
||||
return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled };
|
||||
return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), ...(quota ? { quota } : {}), cancelled };
|
||||
})();
|
||||
return { result, cancel: () => { cancelled = true; p.kill(); } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ export const TOOLS: ToolDef[] = [
|
|||
description: 'Read-only crash / ANR vitals from the Play Developer Reporting API (user-perceived rates vs the bad behavior thresholds 1.09 % / 0.47 %, worst device models, top issues) and App Store power and performance metrics, diagnostic signatures and TestFlight crash reports. Exit 1 when a metric is over a store threshold.',
|
||||
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 7 } },
|
||||
args: i => [...(i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []), ...opt('days', i.days)] },
|
||||
{ name: 'health_issue', title: 'Crash issue sample', command: ['health'], write: false, network: true,
|
||||
description: 'Read-only sample report of one crash / ANR issue: a Play error issue (issue: its id from health --json) via errorReports:search, or the logs of an App Store diagnostic signature (signature: its id). The stack trace as lines, with version, device and OS.',
|
||||
props: { issue: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,200}$', description: 'Play error issue id' }, signature: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,200}$', description: 'App Store diagnostic signature id' } },
|
||||
args: i => [...opt('issue', i.issue), ...opt('signature', i.signature)] },
|
||||
{ name: 'health_report', title: 'Weekly health report', command: ['health', '--weekly'], write: true, network: true, idempotent: true,
|
||||
description: 'Weekly health summary as Markdown in .storebound/health/<date>.md (plus <date>.json for the next week-over-week comparison). Local files only; dry_run prints the report.',
|
||||
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' } },
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,10 +69,33 @@ Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli
|
|||
tools, first read.
|
||||
- **Command palette** (Cmd/Ctrl+K): apps, tabs, screens and actions. Cmd/Ctrl+1 Portfolio, +2-5 workspace screens,
|
||||
Cmd/Ctrl+Shift+[ ] app tabs, arrow keys in the tab list. Toasts show background jobs with progress and Cancel.
|
||||
- **Unknown, never false.** Store reads run on demand (they use the account's API quota) and are saved in app data
|
||||
(`cache.sqlite`, results only, never keys); a screen opens with the saved read and its time ("Last read 3 h ago,
|
||||
saved copy"), a failed read keeps the previous one and says so, and nothing that was not read is shown as fine.
|
||||
Settings clears the saved reads.
|
||||
- **Unknown, never false.** Every read is saved in app data (`cache.sqlite`, keyed by app + method + arguments,
|
||||
results only, never keys); a card opens with the saved read at once and "read 3 min ago", a small spinner while it
|
||||
refreshes, and a failed read keeps the previous one with "couldn't refresh: <one-line reason>". Nothing that was not
|
||||
read is shown as fine. Settings clears the saved reads.
|
||||
- **Automatic reading** (`src/lib/scheduler.ts`, `src/lib/freshness.ts`, `store.tsx`). On start, when the window shows
|
||||
again after a minute hidden, and every N minutes while it is visible (Settings > Store reads: auto-read on by
|
||||
default, every 15 min; 0 = only on start; paused while hidden), the window reads everything of every app that is past
|
||||
its TTL: status and submission state 5 min, TestFlight 5 min, reviews 15 min, catalog / prices / listings / checks
|
||||
30 min, health / analytics / ASO 6 h. "Refresh all" in the toolbar reads everything now; each card has a refresh
|
||||
icon. Writes invalidate the app's reads. All reads go through one scheduler:
|
||||
- lanes `apple`, `play`, `local`, each with at most Settings > parallel (default 4) at once; a read that needs both
|
||||
stores takes a slot in each;
|
||||
- starts on one credential (account) at least 250 ms apart;
|
||||
- App Store Connect's `X-Rate-Limit` header (user-hour-lim / user-hour-rem, about 3600 per key per hour) reaches the
|
||||
window as the ToolResult's `quota` (`cli/lib/quota.ts`: a marked stderr line under `serve`, taken out of the
|
||||
output by `runner.ts`). Below 20 % left, starts are spread over the rest of the hour; at 100 left (kept for the
|
||||
owner's own commands) the key pauses 10 minutes;
|
||||
- 429 / RESOURCE_EXHAUSTED (Play Developer API, Reporting API; the CLI already retries inside one command): the read
|
||||
runs again with exponential backoff and equal jitter (5 s base, 5 min cap, 4 attempts), and its key waits too;
|
||||
- priorities: the app on screen first, then the live portfolio status, then the rest; one in-flight read per key.
|
||||
The sidebar's status line says "Reading 12 of 48 · App Store limit 3,412/3,600 left this hour". The boot check's
|
||||
auto-read case (13 apps, cold cache) loads everything without a click and finds no "not read yet" on any tab.
|
||||
- **Health issue detail.** A top issue (Play) or diagnostic signature (App Store) opens in place: full title and type,
|
||||
reports / users, first and last versionCode, Android API range, last report, a sample stack (`health.issue` =
|
||||
`health --issue <id>` via errorReports:search, or `health --signature <id>` via the signature's logs; cached like
|
||||
every read), "Open in Play Console" (issueUri), "Copy for AI" (compact markdown with the top 15 frames) and a likely
|
||||
cause with its fix for known signatures (`cli/lib/crash-hints.ts`, shared with `storebound health`).
|
||||
- **Jobs.** The window tags a call with a job id; the host maps sidecar request ids to it, so progress lines reach the
|
||||
right toast, and `job_cancel` sends `$/cancelRequest` (or stops a download). Live portfolio reads run up to four
|
||||
apps at a time with their own accounts and report n / total.
|
||||
|
|
|
|||
61
tests/quota-crash-hints.test.ts
Normal file
61
tests/quota-crash-hints.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Store quota lines for the desktop's read scheduler (cli/lib/quota.ts), crash "likely cause" hints
|
||||
// (cli/lib/crash-hints.ts) and the sample report parsers of `health --issue / --signature` (cli/lib/health.ts).
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { crashHint } from '../cli/lib/crash-hints';
|
||||
import { parseDiagnosticLogs, parseErrorReports, parseIssues, reportsQuery } from '../cli/lib/health';
|
||||
import { mergeQuota, parseAscRateLimit } from '../cli/lib/quota';
|
||||
|
||||
describe('quota', () => {
|
||||
test('X-Rate-Limit header', () => {
|
||||
expect(parseAscRateLimit('user-hour-lim:3600;user-hour-rem:3412;')).toEqual({ limit: 3600, remaining: 3412 });
|
||||
expect(parseAscRateLimit('user-hour-rem:12;user-hour-lim:3500')).toEqual({ limit: 3500, remaining: 12 });
|
||||
expect(parseAscRateLimit(null)).toBeNull();
|
||||
expect(parseAscRateLimit('something else')).toBeNull();
|
||||
});
|
||||
test('merging the lines of one run keeps the latest count and any throttling', () => {
|
||||
const a = mergeQuota(undefined, { store: 'apple', limit: 3600, remaining: 3500 });
|
||||
const b = mergeQuota(a, { store: 'apple', throttled: true });
|
||||
expect(b).toEqual({ store: 'apple', limit: 3600, remaining: 3500, throttled: true });
|
||||
expect(mergeQuota(b, { store: 'apple', limit: 3600, remaining: 3490 })).toEqual({ store: 'apple', limit: 3600, remaining: 3490, throttled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('crash hints', () => {
|
||||
test('Capacitor getPermissionStates NPE', () => {
|
||||
const h = crashHint('java.lang.NullPointerException', 'com.getcapacitor.Bridge.getPermissionStates');
|
||||
expect(h?.id).toBe('capacitor-permission-states-npe');
|
||||
expect(h?.fix).toContain('App.getState()');
|
||||
expect(crashHint('java.lang.NullPointerException: Attempt to invoke virtual method', null, '\tat com.getcapacitor.Bridge.getPermissionStates(Bridge.java:1234)')?.id).toBe('capacitor-permission-states-npe');
|
||||
});
|
||||
test('no hint for other issues', () => {
|
||||
expect(crashHint('java.lang.NullPointerException', 'com.example.Main.onCreate')).toBeNull();
|
||||
expect(crashHint('java.lang.IllegalStateException', 'com.getcapacitor.Bridge.getPermissionStates')).toBeNull();
|
||||
expect(crashHint(undefined, null)).toBeNull();
|
||||
});
|
||||
test('the issue list carries the hint and the detail fields', () => {
|
||||
const [i] = parseIssues({ errorIssues: [{ name: 'apps/com.example/errorIssues/abc123', type: 'CRASH', cause: 'java.lang.NullPointerException', location: 'com.getcapacitor.Bridge.getPermissionStates',
|
||||
errorReportCount: '9', distinctUsers: '7', firstAppVersion: { versionCode: '20' }, lastAppVersion: { versionCode: '24' }, firstOsVersion: { apiLevel: '29' }, lastOsVersion: { apiLevel: '35' }, lastErrorReportTime: '2026-10-12T10:00:00Z', issueUri: 'https://play.google.com/console/x' }] });
|
||||
expect(i).toMatchObject({ id: 'abc123', firstVersion: '20', lastVersion: '24', firstOs: '29', lastOs: '35', lastSeen: '2026-10-12T10:00:00Z', uri: 'https://play.google.com/console/x' });
|
||||
expect(i!.hint?.id).toBe('capacitor-permission-states-npe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sample reports', () => {
|
||||
test('errorReports:search query and answer', () => {
|
||||
const q = new URLSearchParams(reportsQuery({ year: 2026, month: 10, day: 12 }, 28, 'abc123'));
|
||||
expect(q.get('filter')).toBe('errorIssueId = abc123');
|
||||
expect(q.get('pageSize')).toBe('1');
|
||||
expect(q.get('orderBy')).toBeNull();
|
||||
expect(() => reportsQuery({ year: 2026, month: 10, day: 12 }, 28, 'a b')).toThrow();
|
||||
const s = parseErrorReports({ errorReports: [{ reportText: 'java.lang.NullPointerException\n\tat com.getcapacitor.Bridge.getPermissionStates(Bridge.java:1)\n\n', eventTime: '2026-10-12T10:00:00Z', type: 'CRASH',
|
||||
appVersion: { versionCode: '24' }, osVersion: { apiLevel: '34' }, deviceModel: { marketingName: 'Galaxy A12' } }] });
|
||||
expect(s).toEqual({ store: 'play', stack: ['java.lang.NullPointerException', '\tat com.getcapacitor.Bridge.getPermissionStates(Bridge.java:1)'], time: '2026-10-12T10:00:00Z', version: '24', os: 'API 34', device: 'Galaxy A12', type: 'CRASH' });
|
||||
expect(parseErrorReports({})).toBeNull();
|
||||
});
|
||||
test('App Store diagnostic logs: the first call stack, top down', () => {
|
||||
const s = parseDiagnosticLogs({ productData: [{ diagnosticLogs: [{ diagnosticMetaData: { appVersion: '2.3.0', buildVersion: '48', osVersion: 'iOS 18.6', deviceType: 'iPhone15,2' },
|
||||
callStackTree: [{ callStacks: [{ callStackRootFrames: [{ binaryName: 'App', symbolName: 'main', offsetIntoSymbol: 4, subFrames: [{ binaryName: 'App', symbolName: 'Combo.update' }] }] }] }] }] }] });
|
||||
expect(s).toEqual({ store: 'apple', stack: ['App main + 4', 'App Combo.update'], version: '2.3.0 (48)', os: 'iOS 18.6', device: 'iPhone15,2' });
|
||||
expect(parseDiagnosticLogs({ productData: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -118,7 +118,8 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
|
|||
case 'app_info': return { version: conf.version, identifier: conf.identifier, platform: 'macos', debug: false, home: HOME };
|
||||
case 'projects_list': return projects();
|
||||
case 'accounts_list': return fx.accounts_list;
|
||||
case 'workspace_get': return fx.workspace_get;
|
||||
// background store reads are the auto-read case's subject (runAutoReadCase); the saved-state cases check drawing
|
||||
case 'workspace_get': return withAutoRead(fx.workspace_get, false);
|
||||
case 'store_apps_cached': return fx.store_apps_cached ?? fx.accounts_list.map(x => ({ account: x.id, entry: null }));
|
||||
case 'cache_get': {
|
||||
const st = live && STORE_READS[String(a.method)];
|
||||
|
|
@ -169,6 +170,12 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
|
|||
};
|
||||
}
|
||||
|
||||
/** The saved workspace with Settings > Reading > auto-read set (older states have no such field: on by default). */
|
||||
function withAutoRead(w: HostFixture['workspace_get'], on: boolean): HostFixture['workspace_get'] {
|
||||
const settings = (w.settings ?? {}) as { refresh?: Record<string, unknown> };
|
||||
return { ...w, settings: { ...settings, refresh: { ...settings.refresh, autoRead: on } } };
|
||||
}
|
||||
|
||||
/** Before any page script, as Tauri: the frozen prototype (tauri.conf.json freezePrototype), then the IPC object. */
|
||||
const initScript = (freeze: boolean) => `(() => {
|
||||
${freeze ? 'Object.freeze(Object.prototype);' : ''}
|
||||
|
|
@ -582,6 +589,106 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
|
|||
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
|
||||
}
|
||||
|
||||
/** Longest wait for the background reader to read everything of every app (a loaded machine). */
|
||||
const READ_ALL_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Auto-read: the dev fixture state (src/dev/fixtures.ts: 2 app folders and the store-only apps of 5 accounts, at least 8
|
||||
* apps) with an empty cache. Without any click the background reader must read everything: the status line shows
|
||||
* "Reading n of m" and the App Store quota, then "Everything read"; then no tab of any app says "not read yet". The
|
||||
* Health tab's first top issue opens and shows its sample stack. Zero uncaught errors.
|
||||
*/
|
||||
async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
|
||||
const name = 'auto-read (fixture state, cold cache)';
|
||||
const fx = await import('../apps/desktop/src/dev/fixtures');
|
||||
const freeze = conf.app.security.freezePrototype === true;
|
||||
const server = serve(DIST, cspHeader(conf.app.security.csp), false);
|
||||
const ctx = await browser.newContext({ viewport: { width: 1360, height: 900 }, reducedMotion: 'reduce' });
|
||||
const page = await ctx.newPage();
|
||||
const problems: string[] = [];
|
||||
const notes: string[] = [];
|
||||
const logged: string[] = [];
|
||||
let reads = 0;
|
||||
// fixture art is file: URLs (blocked by the CSP): icons are left out
|
||||
const clean = (v: unknown): unknown => (v === undefined ? null : JSON.parse(JSON.stringify(v), (_k, x) => (typeof x === 'string' && x.startsWith('file:') ? null : x)));
|
||||
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
|
||||
switch (cmd) {
|
||||
case 'plugin:event|listen': return Math.floor(Math.random() * 1e9);
|
||||
case 'plugin:event|unlisten': return null;
|
||||
case 'log_webview': logged.push(`${String(a.level)}: ${String(a.message).slice(0, 300)}`); return null;
|
||||
case 'cache_get': return null;
|
||||
case 'sidecar_call': {
|
||||
const method = String(a.method);
|
||||
if (method === 'system.tools') return clean(await fx.mockInvoke(cmd, a));
|
||||
reads++;
|
||||
return clean(fx.fixtureRead(method, String(a.projectId), ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {}));
|
||||
}
|
||||
default: return clean(await fx.mockInvoke(cmd, a));
|
||||
}
|
||||
};
|
||||
await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record<string, unknown>) => {
|
||||
try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; }
|
||||
});
|
||||
page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | ')));
|
||||
page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); });
|
||||
await page.addInitScript(initScript(freeze));
|
||||
let visited = 0;
|
||||
const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
|
||||
const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); };
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)'));
|
||||
const wizard = page.locator('.wizard[role="dialog"]');
|
||||
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); }
|
||||
const apps = await page.locator('nav.side .nav-app').count();
|
||||
if (apps < 8) problems.push(`want at least 8 apps in the sidebar, got ${apps}`);
|
||||
// no click: the status line shows the reader's progress, then that everything is read
|
||||
const line = '[data-testid="reader-line"]';
|
||||
if (!(await page.waitForFunction(s => /Reading \d+ of \d+/.test(document.querySelector(s)?.textContent ?? ''), line, { timeout: WAIT_MS, polling: 20 }).then(() => true, () => false)))
|
||||
problems.push('status line: no "Reading n of m" while reading');
|
||||
else notes.push(`status line while reading: ${await text(line)}`);
|
||||
if (!(await shows(page, line, 'Everything read', READ_ALL_MS))) problems.push(`status line: not "Everything read" after ${READ_ALL_MS / 1000} s: ${await text(line)}`);
|
||||
const done = await text(line);
|
||||
notes.push(`status line after: ${done}; ${reads} reads, ${apps} apps`);
|
||||
if (!/App Store limit 3,412\/3,600 left this hour/.test(done)) problems.push(`status line: no App Store quota (${done})`);
|
||||
await settle(page);
|
||||
// every tab of every app: nothing says "not read yet" (the reads were done in the background)
|
||||
const shown = await page.locator('nav.side .nav-app:visible').count();
|
||||
for (let i = 0; i < shown; i++) {
|
||||
await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`));
|
||||
await settle(page);
|
||||
const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim();
|
||||
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
|
||||
for (const id of tabs) {
|
||||
await page.locator(`[role="tab"]#${id}`).click({ timeout: WAIT_MS }).catch(e => problems.push(`${appName} ${id}: ${String(e).split('\n')[0]}`));
|
||||
await settle(page);
|
||||
await crashCheck(`${appName} ${id}`);
|
||||
const tx = await text();
|
||||
if (/not read yet|Read the stores to see this/i.test(tx)) problems.push(`${appName} ${id}: says "${(/not read yet|Read the stores to see this/i.exec(tx) ?? [''])[0]}" after the background read`);
|
||||
visited++;
|
||||
}
|
||||
}
|
||||
// Health: the first top issue opens with its full title, the sample stack and Copy for AI
|
||||
await page.locator('nav.side .nav-app', { hasText: 'Prism Puddle' }).first().click({ timeout: WAIT_MS });
|
||||
await settle(page);
|
||||
await page.locator('[role="tab"]#tab-health').click({ timeout: WAIT_MS });
|
||||
await settle(page);
|
||||
await page.locator('.issue-row').first().click({ timeout: WAIT_MS }).catch(() => problems.push('Health: no issue row to open'));
|
||||
if (!(await shows(page, '[data-testid="issue-stack"]', 'ComboMeter.update(ComboMeter.kt:88)'))) problems.push('Health: the opened issue shows no sample stack');
|
||||
const det = await text('[data-testid="issue-detail"]');
|
||||
for (const w of ['ComboMeter.update called after the level ended', 'Copy for AI', 'Open in Play Console', '224 – 231']) if (!det.includes(w)) problems.push(`Health issue detail: missing "${w}"`);
|
||||
notes.push(`issue detail: ${det.slice(0, 160)}`);
|
||||
await crashCheck('Health');
|
||||
} catch (e) {
|
||||
problems.push(`case stopped: ${String(e).split('\n')[0]}`);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
server.stop(true);
|
||||
}
|
||||
for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`);
|
||||
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Errors the window must say in words (pitfall 57). `api-disabled`: the last health read of the app is Google's live
|
||||
* 403 SERVICE_DISABLED (Reporting API off in Cloud project 123456789000): the portfolio shows it once for the project,
|
||||
|
|
@ -724,6 +831,7 @@ export async function bootCheck(): Promise<CaseResult[]> {
|
|||
if (!ONLY.length || ONLY.includes('submit') || ONLY.includes(`submit-${v}`)) cases.push(() => runSubmitCase(browser, sidecar, base, v));
|
||||
for (const p404 of [true, false]) if (!ONLY.length || ONLY.includes('release-state')) cases.push(() => runReleaseStateCase(browser, sidecar, p404));
|
||||
for (const v of ['api-disabled', 'scan-findings', 'scan-failed'] as const) if (!ONLY.length || ONLY.includes('errors') || ONLY.includes(`errors-${v}`)) cases.push(() => runErrorCase(browser, sidecar, v));
|
||||
if (!ONLY.length || ONLY.includes('auto-read')) cases.unshift(() => runAutoReadCase(browser));
|
||||
const results: CaseResult[] = new Array(cases.length);
|
||||
let next = 0;
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue