Desktop: background store reads from the tray (no document.hidden skip, backoff), notifications by kind (rejected, approved, live, halted, crash, reviews) with switches, quiet hours and mute, click-through on next show; tray menu with apps and their states, read now, mute, updates, settings, tooltip summary and attention dot; tray_update command; tests

This commit is contained in:
CRTkafa 2026-10-22 22:24:05 +08:00
parent 5d8a0f5e9a
commit 03ef999dac
27 changed files with 1050 additions and 55 deletions

View file

@ -4,18 +4,20 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import {
type Account, type AccountCheck, api, type AppInfo, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, errorText, newJob, onDownload,
onProgress, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult,
onProgress, onTrayAction, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult,
} from './api';
import { t } from './i18n';
import { buildApps, NO_OVERRIDES, type Overrides, type UnifiedApp } from './lib/identity';
import { isError, type HealthSnapshot, type Review } from './lib/model';
import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify';
import { crashCrossed, mayNotify, type NoticeKind, newLowReviews, nextReadDelay, type ReleaseChange, releaseChanges } from './lib/notify';
import { traySummary } from './lib/tray';
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 type { Route } from './nav';
import { withLastGood } from '../../../cli/lib/train';
import { failureText, usable } from './lib/results';
@ -92,12 +94,17 @@ export interface Data {
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>;
readAll: (force?: boolean) => Promise<boolean>;
/** Notifications are muted until this time (epoch ms; 0 = not muted). */
muteUntil: number;
/** Mutes notifications for `minutes` (0 = unmute). */
setMute: (minutes: number) => 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>;
/** True when the read succeeded. */
refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise<boolean>;
/** Reads the apps of one account (or all) from the stores. */
importApps: (account?: string) => Promise<void>;
checkAccount: (id: string) => Promise<void>;
@ -128,6 +135,19 @@ const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at(
/** A failure line with how to fix it, when the error is a known one. */
const withHint = (msg: string | undefined): string | undefined => { const h = fixHint(msg); return h && msg ? `${msg} · ${t(h)}` : msg; };
const storeName = (s: 'ios' | 'android'): string => (s === 'ios' ? 'App Store' : 'Google Play');
/** Title and body of the notification of one release change (by kind; a rejection adds the store's reason when it has one). */
export function releaseText(c: ReleaseChange): [string, string] {
const v = { name: c.name, store: storeName(c.store), version: c.version };
switch (c.kind) {
case 'rejected': return [t('notify.rejectedTitle', v), [t('notify.rejectedBody', v), c.reason].filter(Boolean).join(' ')];
case 'approved': return [t('notify.approvedTitle', v), t('notify.approvedBody', v)];
case 'live': return [t('notify.liveTitle', v), t('notify.liveBody', v)];
case 'halted': return [t('notify.haltedTitle', v), t('notify.haltedBody', v)];
default: return [t('notify.releaseTitle', v), t('notify.releaseBody', { ...v, phase: t(`phase.${c.to}` as 'phase.live') })];
}
}
export function DataProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [info, setInfo] = useState<AppInfo | null>(null);
@ -153,6 +173,21 @@ export function DataProvider({ children }: { children: ReactNode }) {
const settingsRef = useRef(settings);
settingsRef.current = settings;
// Mute for a while (the tray's "Mute notifications for 1 hour"): kept across restarts
const [muteUntil, setMuteUntil] = useState<number>(() => { try { return Number(localStorage.getItem('storebound.muteUntil')) || 0; } catch { return 0; } });
const muteRef = useRef(muteUntil);
muteRef.current = muteUntil;
const setMute = useCallback((minutes: number) => {
const until = minutes > 0 ? Date.now() + minutes * 60_000 : 0;
setMuteUntil(until); muteRef.current = until;
try { localStorage.setItem('storebound.muteUntil', String(until)); } catch { /* private mode: mute lasts until the window closes */ }
}, []);
/** One desktop notification: its kind's toggle, quiet hours and mute decide; `route` is where the window opens when it is next shown from the tray. */
const notice = useCallback((kind: NoticeKind, title: string, body: string, route?: Route): void => {
if (!mayNotify(settingsRef.current.notify, kind, { muteUntil: muteRef.current })) return;
api.notify(title, body, route ? JSON.stringify(route) : undefined).catch(() => {});
}, []);
const dismiss = useCallback((id: string) => {
setToasts(ts => ts.filter(x => x.id !== id));
const tm = timers.current.get(id);
@ -196,7 +231,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
const appsRef = useRef(apps);
appsRef.current = apps;
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => {
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false): Promise<boolean> => {
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null);
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
@ -210,8 +245,11 @@ export function DataProvider({ children }: { children: ReactNode }) {
if (liveRead) {
const changes = releaseChanges(liveRef.current?.value, r);
setLive({ value: r, at: r.at });
if (settingsRef.current.notify.reviewState) for (const c of changes.slice(0, 5))
api.notify(t('notify.releaseTitle', { name: c.name }), t('notify.releaseBody', { store: c.store === 'ios' ? 'App Store' : 'Google Play', version: c.version, phase: t(`phase.${c.to}` as 'phase.live') })).catch(() => {});
for (const c of changes.slice(0, 5)) {
const [title, body] = releaseText(c);
const app = appsRef.current.find(a => a.target === c.project);
notice(c.kind, title, body, app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined);
}
const failed = r.apps.filter(a => a.storeErrors.ios || a.storeErrors.android).length;
if (tid) updateToast(tid, failed ? { kind: 'info', title: t('toast.readDonePartial'), detail: t('toast.readDonePartialDetail', { count: failed }), progress: undefined, job: undefined }
: { kind: 'ok', title: t('toast.readDone'), detail: t('toast.readDoneDetail', { count: r.apps.length }), progress: undefined, job: undefined });
@ -220,8 +258,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
const msg = errorText(e);
setPortfolioError(msg);
if (tid) updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailed'), detail: msg, progress: undefined, job: undefined });
return false;
} finally { setBusy(null); }
}, [toast, updateToast]);
return true;
}, [toast, updateToast, notice]);
// ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ----
const schedRef = useRef<ReadScheduler | null>(null);
@ -252,22 +292,42 @@ export function DataProvider({ children }: { children: ReactNode }) {
return c;
};
const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined);
const lastData = useRef(new Map<string, unknown>());
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, accounts: accountsOf(app, lanes), priority, run: () => api.call<ToolResult>(method, target, { args }, newJob(method)) });
// reviews and health: compared with the previous read here (not in a card), so the tray app notices them with no window open
const watch = method === 'reviews.list' || method === 'health';
const run = async (): Promise<ToolResult> => {
const before = watch ? (lastData.current.has(key) ? lastData.current.get(key) : (await api.cached<ToolResult>(method, target, args).catch(() => null))?.value.data ?? null) : null;
const result = await api.call<ToolResult>(method, target, { args }, newJob(method));
if (watch && usable(method, result)) {
notifyAfterRead(method, before, result.data, notice, app?.name, app?.key);
lastData.current.set(key, result.data);
}
return result;
};
const p = sched.enqueue<ToolResult>({ key, lanes, accounts: accountsOf(app, lanes), priority, run });
emitRead(key, { kind: 'start' });
p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) }));
return p;
}, [sched, emitRead]);
}, [sched, emitRead, notice]);
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'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {});
/**
* Reads everything past its TTL. The portfolio read is a LIVE one (refreshPortfolio(true)): it is what releaseChanges
* compares, so a rejection is seen even when no screen asked. Resolves when that read is done: false when it failed.
*/
const readAll = useCallback(async (force = false): Promise<boolean> => {
if (!force && !settingsRef.current.refresh.autoRead) return true;
let ok = true;
let portfolioRead: Promise<void> | null = null;
const pkey = '*\u001fportfolio.status';
if (!sched.has(pkey) && (force || isStale('portfolio.status', liveRef.current?.at))) {
portfolioRead = sched.enqueue<boolean>({ key: pkey, lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) })
.then(r => { ok = r !== false; }, () => { ok = false; });
}
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));
@ -283,6 +343,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {});
}
}
await portfolioRead;
return ok;
}, [sched, schedule, refreshPortfolio]);
const invalidate = useCallback((target: string) => {
@ -353,27 +415,48 @@ export function DataProvider({ children }: { children: ReactNode }) {
useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]);
// 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).
// a while, and every N minutes (with a little jitter) EVEN WHILE THE WINDOW IS HIDDEN: the app lives in the tray, and
// that is where a rejection has to be noticed. One pass at a time; after a failed pass the wait doubles (nextReadDelay).
const launched = useRef(false);
useEffect(() => {
if (!ready || launched.current || !apps.length) return;
launched.current = true;
if (settings.refresh.autoRead) readAll(false).catch(() => {});
}, [ready, apps.length, settings.refresh.autoRead, readAll]);
const passing = useRef(false);
useEffect(() => {
if (!settings.refresh.autoRead) return;
let hiddenAt = 0;
const pass = async (): Promise<boolean> => {
if (passing.current) return true;
passing.current = true;
try { return await readAll(false); } catch { return false; } finally { passing.current = false; }
};
const vis = () => {
if (document.hidden) { hiddenAt = Date.now(); return; }
if (hiddenAt && Date.now() - hiddenAt > 60_000) readAll(false).catch(() => {});
if (hiddenAt && Date.now() - hiddenAt > 60_000) pass().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); };
let tm: ReturnType<typeof setTimeout> | null = null;
let stopped = false, failures = 0;
const next = () => {
if (stopped || !m) return;
tm = setTimeout(async () => { failures = (await pass()) ? 0 : failures + 1; next(); }, nextReadDelay(m, failures));
};
next();
return () => { stopped = true; document.removeEventListener('visibilitychange', vis); if (tm) clearTimeout(tm); };
}, [settings.refresh.autoRead, settings.refresh.everyMinutes, readAll]);
// the tray: tooltip, attention icon and the apps list follow every read; its menu asks for a read or the mute
const readRef = useRef(readAll);
readRef.current = readAll;
useEffect(() => {
const un = onTrayAction(a => { if (a === 'read') readRef.current(true).catch(() => {}); else setMute(isMutedNow() ? 0 : 60); });
return () => { un.then(f => f()); };
}, [setMute]);
const isMutedNow = () => muteRef.current > Date.now();
// icons: cached first, then read (offline, cheap)
useEffect(() => {
for (const p of projects) {
@ -419,14 +502,33 @@ export function DataProvider({ children }: { children: ReactNode }) {
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;
useEffect(() => {
if (!ready) return;
const muted = muteUntil > Date.now();
const sum = traySummary(
apps.filter(a => !a.ignored).map(a => ({ key: a.key, name: a.name, status: liveFor(a) ?? statusFor(a) })), muted,
{ phaseText: p => t(`phase.${p}` as 'phase.live'), store: s => (s === 'ios' ? 'App Store' : 'Play'), none: t('tray.noState'), muted: t('tray.mutedTip'),
tipReview: n => t('tray.tipReview', { count: n }), tipAttention: n => t('tray.tipAttention', { count: n }),
labels: { open: t('tray.open'), readNow: t('tray.readNow'), apps: t('tray.apps'), noApps: t('tray.noApps'), mute: t('tray.mute'), unmute: t('tray.unmute'), update: t('tray.update'), settings: t('tray.settings'), quit: t('tray.quit') } },
);
api.trayUpdate(sum).catch(() => {});
}, [ready, apps, live, portfolio, muteUntil, statusFor, liveFor]);
// the mute ends by itself: the tray menu follows
useEffect(() => {
if (muteUntil <= Date.now()) return;
const tm = setTimeout(() => setMuteUntil(0), muteUntil - Date.now() + 500);
return () => clearTimeout(tm);
}, [muteUntil]);
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, reader: readerState, schedule, onRead, isReading, readAll, invalidate, setFocus,
importBusy, icons, reader: readerState, muteUntil, setMute, schedule, onRead, isReading, readAll, invalidate, setFocus,
refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
toasts, toast, updateToast, dismiss, cancelJob,
}), [readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
}), [readerState, muteUntil, setMute, 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>;
@ -481,16 +583,19 @@ export function useWrite(projectId: string | null) {
return { busy, run, isBusy: (method: string, args: Record<string, unknown> = {}) => busy === `${method}:${JSON.stringify(args)}` };
}
type Notice = (kind: NoticeKind, title: string, body: string, route?: Route) => void;
/** Notifications a fresh read may trigger (Settings > Notifications), compared with the read it replaces. */
function notifyAfterRead(method: string, prev: unknown, next: unknown, settings: Settings, appName: string | undefined): void {
function notifyAfterRead(method: string, prev: unknown, next: unknown, notice: Notice, appName: string | undefined, appKey: string | undefined): void {
const name = appName ?? '';
if (method === 'reviews.list' && settings.notify.lowReviews && Array.isArray(next)) {
const route = (tab: 'reviews' | 'health'): Route | undefined => (appKey ? { screen: 'app', appId: appKey, tab } : undefined);
if (method === 'reviews.list' && Array.isArray(next)) {
const fresh = newLowReviews(prev as Review[] | null, next as Review[]);
if (fresh.length) api.notify(t('notify.lowTitle', { name, count: fresh.length }), `${'★'.repeat(fresh[0]!.rating)} ${fresh[0]!.title ?? ''} ${fresh[0]!.body}`.trim()).catch(() => {});
if (fresh.length) notice('lowReviews', t('notify.lowTitle', { name, count: fresh.length }), `${'★'.repeat(fresh[0]!.rating)} ${fresh[0]!.title ?? ''} ${fresh[0]!.body}`.trim(), route('reviews'));
}
if (method === 'health' && settings.notify.crash && next && typeof next === 'object') {
if (method === 'health' && next && typeof next === 'object') {
const crossed = crashCrossed(prev as HealthSnapshot | null, next as HealthSnapshot);
if (crossed.length && !isError((next as HealthSnapshot).play)) api.notify(t('notify.crashTitle', { name }), crossed.map(k => (k === 'crash' ? t('health.crash') : t('health.anr'))).join(', ')).catch(() => {});
if (crossed.length && !isError((next as HealthSnapshot).play)) notice('crash', t('notify.crashTitle', { name }), crossed.map(k => (k === 'crash' ? t('health.crash') : t('health.anr'))).join(', '), route('health'));
}
}
@ -528,7 +633,6 @@ export function useRead<T>(target: string, method: string, o: { network: boolean
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 }));
}