// App data shared by every screen: app folders, studios, store accounts and the apps they see, the pairing / ignore // overrides, settings, the sidecar, the portfolio (offline check + the last live read from the cache), app icons, and // background jobs shown as toasts with progress and cancel. 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, } 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 { 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 { withLastGood } from '../../../cli/lib/train'; import { failureText, usable } from './lib/results'; export type ToastKind = 'job' | 'ok' | 'bad' | 'info'; export interface Toast { id: string; kind: ToastKind; title: string; detail?: string; /** null: running without a known total. */ progress?: { done: number; total: number } | null; /** Cancellable job id. */ job?: string; /** The job's last progress lines (the toast's log), newest last. */ lines?: string[]; } 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): 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.accounts.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; projects: Project[]; accounts: Account[]; studios: Studio[]; overrides: Overrides; settings: Settings; /** Last app list read of each account (null: never read). */ storeApps: Record | null>; /** Last account check of this session. */ accountHealth: Record; /** Every app across studios and accounts, ignored ones included (`ignored: true`). */ apps: UnifiedApp[]; sidecar: SidecarStatus | null; sidecarError: string | null; /** Latest portfolio read (offline check, or live when just read). */ portfolio: PortfolioResult | null; /** Last live read of the stores (cached across runs). */ live: CacheEntry | null; portfolioBusy: 'offline' | 'live' | null; portfolioError: string | null; importBusy: boolean; icons: Record; /** 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, priority: Priority) => Promise; /** 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; /** 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; /** Reads the apps of one account (or all) from the stores. */ importApps: (account?: string) => Promise; checkAccount: (id: string) => Promise; reload: () => Promise; updateOverrides: (fn: (o: Overrides) => Overrides) => Promise; updateSettings: (s: Settings) => Promise; statusOf: (p: Project) => AppStatus | undefined; liveOf: (p: Project) => AppStatus | undefined; statusFor: (a: UnifiedApp) => AppStatus | undefined; liveFor: (a: UnifiedApp) => AppStatus | undefined; iconFor: (a: UnifiedApp) => string | null; appByKey: (key: string) => UnifiedApp | undefined; toasts: Toast[]; toast: (t: Omit & { id?: string }) => string; updateToast: (id: string, patch: Partial) => void; dismiss: (id: string) => void; cancelJob: (job: string) => void; } const Ctx = createContext(null); export const useData = (): Data => { const d = useContext(Ctx); if (!d) throw new Error('useData outside DataProvider'); return d; }; const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at(-1)?.replace(/^\S+ \[[^\]]+\] /, '') ?? ''; /** 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; }; export function DataProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [info, setInfo] = useState(null); const [projects, setProjects] = useState([]); const [accounts, setAccounts] = useState([]); const [studios, setStudios] = useState([]); const [overrides, setOverrides] = useState(NO_OVERRIDES); const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [storeApps, setStoreApps] = useState | null>>({}); const [accountHealth, setAccountHealth] = useState>({}); const [sidecar, setSidecar] = useState(null); const [sidecarError, setSidecarError] = useState(null); const [portfolio, setPortfolio] = useState(null); const [live, setLive] = useState | null>(null); const [portfolioBusy, setBusy] = useState(null); const [portfolioError, setPortfolioError] = useState(null); const [importBusy, setImportBusy] = useState(false); const [icons, setIcons] = useState>({}); const [toasts, setToasts] = useState([]); const timers = useRef(new Map>()); const liveRef = useRef(live); liveRef.current = live; const settingsRef = useRef(settings); settingsRef.current = settings; const dismiss = useCallback((id: string) => { setToasts(ts => ts.filter(x => x.id !== id)); const tm = timers.current.get(id); if (tm) { clearTimeout(tm); timers.current.delete(id); } }, []); const autoClose = useCallback((id: string, kind: ToastKind) => { if (kind === 'job') return; const old = timers.current.get(id); if (old) clearTimeout(old); timers.current.set(id, setTimeout(() => dismiss(id), kind === 'bad' ? 9000 : 4500)); }, [dismiss]); const toast = useCallback((x: Omit & { id?: string }) => { const id = x.id ?? newJob('toast'); setToasts(ts => [...ts.filter(o => o.id !== id), { ...x, id }].slice(-4)); autoClose(id, x.kind); return id; }, [autoClose]); const updateToast = useCallback((id: string, patch: Partial) => { setToasts(ts => ts.map(x => (x.id === id ? { ...x, ...patch } : x))); if (patch.kind) autoClose(id, patch.kind); }, [autoClose]); const cancelJob = useCallback((job: string) => { api.cancel(job).catch(() => {}); }, []); const reload = useCallback(async () => { const [p, a, w, s] = await Promise.all([api.projects(), api.accounts(), api.workspace(), api.storeAppsCached()]); // whatever an older or newer host sends, the screens get complete values (lib/normalize.ts) const ws = normalizeWorkspace(w); setProjects(normalizeProjects(p)); setAccounts(normalizeAccounts(a)); setStudios(ws.studios); setOverrides(ws.overrides); setSettings(ws.settings); setStoreApps(Object.fromEntries((Array.isArray(s) ? s : []).filter(x => x && typeof x.account === 'string').map(x => [x.account, x.entry ?? null]))); }, []); const apps = useMemo(() => buildApps({ projects, accounts, overrides, summaryOf: p => portfolio?.apps.find(a => a.project === p.path)?.summary ?? liveRef.current?.value.apps.find(a => a.project === p.path)?.summary, storeApps: Object.entries(storeApps).filter(([, e]) => !!e).map(([account, e]) => ({ account, apps: e!.value.apps })), }), [projects, accounts, overrides, portfolio, storeApps]); const appsRef = useRef(apps); appsRef.current = apps; const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => { 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; try { const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : []; let r = await api.portfolio(liveRead, job, stores); // a store that failed this time keeps its last good releases ("couldn't refresh"), never turns empty const prevLive = liveRef.current; if (liveRead && prevLive) r = { ...r, apps: r.apps.map(a => withLastGood(prevLive.value.apps.find(x => x.project === a.project), a, prevLive.at)) }; setPortfolio(r); 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(() => {}); 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 }); } } catch (e) { 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 }); } finally { setBusy(null); } }, [toast, updateToast]); // ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ---- const schedRef = useRef(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(() => sched.stats()); useEffect(() => { let tm: ReturnType | 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 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(null); const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []); const accountsOf = (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, priority: Priority): Promise => { 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({ key, lanes, accounts: accountsOf(app, lanes), priority, run: () => api.call(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'], accounts: [], 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(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; const job = newJob('import'); setImportBusy(true); const tid = toast({ id: job, kind: 'job', title: t('toast.importing'), detail: t('toast.importingDetail'), progress: { done: 0, total: ids.length }, job }); let ok = 0, apps = 0; const errors: string[] = []; for (const [n, id] of ids.entries()) { try { const e = await api.refreshStoreApps(id, job); setStoreApps(m => ({ ...m, [id]: e })); ok++; apps += e.value.apps.length; } catch (e) { const msg = errorText(e); if (/cancel/i.test(msg)) { updateToast(tid, { kind: 'info', title: t('toast.cancelled'), detail: undefined, progress: undefined, job: undefined }); setImportBusy(false); return; } errors.push(`${accounts.find(a => a.id === id)?.label ?? id}: ${msg}`); } updateToast(tid, { progress: { done: n + 1, total: ids.length } }); } setImportBusy(false); updateToast(tid, errors.length ? { kind: ok ? 'info' : 'bad', title: t('toast.importPartial', { count: errors.length }), detail: errors[0], progress: undefined, job: undefined } : { kind: 'ok', title: t('toast.importDone', { count: apps }), detail: t('toast.importDoneDetail', { count: ok }), progress: undefined, job: undefined }); }, [accounts, overrides.ignored, toast, updateToast]); const checkAccount = useCallback(async (id: string) => { try { const check = await api.checkAccount(id, newJob('check')); setAccountHealth(m => ({ ...m, [id]: { check, at: new Date().toISOString() } })); } catch (e) { setAccountHealth(m => ({ ...m, [id]: { error: errorText(e), at: new Date().toISOString() } })); } }, []); const updateOverrides = useCallback(async (fn: (o: Overrides) => Overrides) => { const next = fn(overrides); setOverrides(next); try { setOverrides(await api.setOverrides(next)); } catch (e) { setOverrides(overrides); toast({ kind: 'bad', title: t('toast.saveFailed'), detail: errorText(e) }); } }, [overrides, toast]); const updateSettings = useCallback(async (s: Settings) => { const old = settings; setSettings(s); try { setSettings(await api.setSettings(s)); } catch (e) { setSettings(old); toast({ kind: 'bad', title: t('toast.saveFailed'), detail: errorText(e) }); } }, [settings, toast]); // start: app info, folders, accounts, workspace, sidecar, cached live portfolio useEffect(() => { api.appInfo().then(i => { setInfo(i); setCrashContext(i.home, i.version); }, () => {}); reload().catch(() => {}).finally(() => setReady(true)); api.sidecarStatus().then(s => { setSidecar(s); setSidecarError(null); }, e => setSidecarError(errorText(e))); api.cached('portfolio.status', '*', { live: true }).then(e => { if (e) setLive(e); }, () => {}); }, [reload]); 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). 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]); useEffect(() => { 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(() => { for (const p of projects) { if (p.id in icons || p.missing) continue; setIcons(m => ({ ...m, [p.id]: null })); api.cached('project.icon', p.id).then(c => { if (c?.value.data?.dataUrl) setIcons(m => ({ ...m, [p.id]: c.value.data!.dataUrl })); }, () => {}).finally(() => { reads.icon(p.id).then(r => setIcons(m => ({ ...m, [p.id]: r.data?.dataUrl ?? m[p.id] ?? null })), () => {}); }); } }, [projects]); // eslint-disable-line react-hooks/exhaustive-deps // progress of jobs -> their toasts useEffect(() => { const un = onProgress(p => { if (!p.job) return; setToasts(ts => ts.map(x => { if (x.job !== p.job) return x; if (p.total) return { ...x, progress: { done: p.n ?? 0, total: p.total } }; if (p.line) { // upload and render lines carry their own progress ("312/890 MB (35%), part 4/12"): the bar follows them const lp = progressOf(p.line); return { ...x, detail: lastLine(p.line) || x.detail, lines: keepLines(x.lines, lastLine(p.line) || p.line), ...(lp ? { progress: lp } : {}) }; } return x; })); }); const unD = onDownload(e => { setToasts(ts => ts.map(x => (x.job === e.job && e.stage !== 'done' && e.stage !== 'error' && e.stage !== 'cancelled' ? { ...x, detail: t(`dl.${e.stage}`), progress: e.total ? { done: e.received, total: e.total } : null } : x))); }); return () => { un.then(f => f()); unD.then(f => f()); }; }, []); const statusOf = useCallback((p: Project) => portfolio?.apps.find(a => a.project === p.path || a.app?.id === p.path), [portfolio]); const liveOf = useCallback((p: Project) => { const cur = portfolio?.apps.find(a => (a.project === p.path || a.app?.id === p.path) && a.release.source === 'live'); return cur ?? live?.value.apps.find(a => a.project === p.path || a.app?.id === p.path); }, [portfolio, live]); const statusFor = useCallback((a: UnifiedApp) => (a.project ? statusOf(a.project) : portfolio?.apps.find(x => x.project === a.target) ?? live?.value.apps.find(x => x.project === a.target)), [statusOf, portfolio, live]); 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(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]); const value = useMemo(() => ({ 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, 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, importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey, toasts, toast, updateToast, dismiss, cancelJob]); return {children}; } export interface ReadState { 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; loading: boolean; error: string | null; /** The command line it ran (shown with errors). */ command: string | null; refresh: () => Promise; } export interface WriteRun { method: string; args: Record; title: string; done: string; after?: () => void } /** * Writes of one app folder: dry run, the native dialog that shows it (Apply / Cancel, or the owner action's own * button), then the real run with a host-minted token. One toast follows it from the dry run to the result, with the * command's progress lines; Cancel stops it. `busy` is the key of the running write. */ export function useWrite(projectId: string | null) { const d = useData(); const [busy, setBusy] = useState(null); const run = useCallback(async (w: WriteRun) => { if (!projectId) return null; const key = `${w.method}:${JSON.stringify(w.args)}`; const job = newJob(w.method); setBusy(key); const tid = d.toast({ id: job, kind: 'job', title: w.title, detail: t('write.dryRunning'), progress: null, job }); try { 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?.(); 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?.(); } else d.updateToast(tid, { kind: 'bad', title: t('write.failed'), detail: withHint(o.result ? failureText(o.result) : undefined), progress: undefined, job: undefined }); return o; } catch (e) { const msg = errorText(e); d.updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('write.failed'), detail: msg, progress: undefined, job: undefined }); return null; } finally { setBusy(null); } }, [projectId, d]); return { busy, run, isBusy: (method: string, args: Record = {}) => busy === `${method}:${JSON.stringify(args)}` }; } /** 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 { const name = appName ?? ''; if (method === 'reviews.list' && settings.notify.lowReviews && 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 (method === 'health' && settings.notify.crash && 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(() => {}); } } /** * 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; 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(target: string, method: string, o: { network: boolean; args?: Record; title?: string; appName?: string; skip?: boolean }): ReadState { const d = useData(); const argsKey = JSON.stringify(o.args ?? {}); const key = readKey(target, method, JSON.parse(argsKey) as Record); const [s, set] = useState, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null }); const dataRef = useRef(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 () => { if (skipRef.current) return; set(x => ({ ...x, loading: true })); await dRef.current.schedule(target, method, JSON.parse(argsKey) as Record, 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 })); } }); }, [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(method, target, JSON.parse(argsKey) as Record).then(c => { if (!alive) return; 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 return { ...s, refresh }; }