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:
parent
5d8a0f5e9a
commit
03ef999dac
27 changed files with 1050 additions and 55 deletions
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue