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

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

View file

@ -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