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

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

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

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

View file

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

View file

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

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

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

View file

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