- app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the context menu list sections and sub views; old tab ids, section and section/sub links all resolve - Players > Game services (games only): both stores, one row per leaderboard / achievement with its languages and each store's state, Game Center locale map, Play API-disabled state with the console plan - games status --json: items, localeMap, playApiDisabled; inspect summary: games - sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio filter, workspace as a footer icon row - boot check opens places like a person (section, then sub view)
770 lines
47 KiB
TypeScript
770 lines
47 KiB
TypeScript
// 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 { askPreview } from './ui/DryRunPreview';
|
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
import {
|
|
type Account, type AccountCheck, api, type AppInfo, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, errorText, newJob, onDownload,
|
|
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, 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 { accountCheckKey, affectedReads, appTags, IMPORT_ALL_KEY, importKey, methodOfKey, PORTFOLIO_CHECK_KEY, PORTFOLIO_KEY, READ_ALL_KEY, type ReadQuery, ReadRegistry, type ReadStatus } from './lib/read-registry';
|
|
import { fixHint } from './lib/views';
|
|
import type { Route } from './nav';
|
|
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<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.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;
|
|
// a store side the owner ignored is not read (reviews, health, status of that store)
|
|
return { ios: !a.ignoredStores.includes('ios') && (!!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl)), android: !a.ignoredStores.includes('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<string, CacheEntry<AppsListResult> | null>;
|
|
/** Last account check of this session. */
|
|
accountHealth: Record<string, AccountHealth>;
|
|
/** 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<PortfolioResult> | null;
|
|
portfolioBusy: 'offline' | 'live' | null;
|
|
portfolioError: string | null;
|
|
importBusy: boolean;
|
|
icons: Record<string, string | null>;
|
|
/** Background reader: progress and the App Store Connect quota seen this hour. */
|
|
reader: ReaderState;
|
|
/** Runs one read through the read registry and the scheduler (single-flight, rate limited, "read just now" memo
|
|
* unless `force`); the result also goes to the read bus. */
|
|
schedule: (target: string, method: string, args: Record<string, unknown>, priority: Priority, force?: boolean) => Promise<ToolResult>;
|
|
/** The one read registry (lib/read-registry.ts): every read button asks it through useReadState. */
|
|
registry: ReadRegistry;
|
|
/** 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<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 reads of the app that `writeMethod` changed are read again (all of them without one). */
|
|
invalidate: (target: string, writeMethod?: string) => void;
|
|
/** The app on screen (its reads go first). */
|
|
setFocus: (key: string | null) => 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>;
|
|
reload: () => Promise<void>;
|
|
updateOverrides: (fn: (o: Overrides) => Overrides) => Promise<void>;
|
|
updateSettings: (s: Settings) => Promise<void>;
|
|
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<Toast, 'id'> & { id?: string }) => string;
|
|
updateToast: (id: string, patch: Partial<Toast>) => void;
|
|
dismiss: (id: string) => void;
|
|
cancelJob: (job: string) => void;
|
|
}
|
|
|
|
const Ctx = createContext<Data | null>(null);
|
|
/** The read registry on its own context (DataProvider fills it), so read buttons and tests need only this. */
|
|
export const RegistryCtx = createContext<ReadRegistry | null>(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; };
|
|
|
|
/** The scheduler credential of a store account. */
|
|
const credOf = (a: Account): string => `${a.kind === 'play' ? 'play' : 'apple'}:${a.id}`;
|
|
|
|
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);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
|
const [studios, setStudios] = useState<Studio[]>([]);
|
|
const [overrides, setOverrides] = useState<Overrides>(NO_OVERRIDES);
|
|
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
|
|
const [storeApps, setStoreApps] = useState<Record<string, CacheEntry<AppsListResult> | null>>({});
|
|
const [accountHealth, setAccountHealth] = useState<Record<string, AccountHealth>>({});
|
|
const [sidecar, setSidecar] = useState<SidecarStatus | null>(null);
|
|
const [sidecarError, setSidecarError] = useState<string | null>(null);
|
|
const [portfolio, setPortfolio] = useState<PortfolioResult | null>(null);
|
|
const [live, setLive] = useState<CacheEntry<PortfolioResult> | null>(null);
|
|
const [portfolioError, setPortfolioError] = useState<string | null>(null);
|
|
const [icons, setIcons] = useState<Record<string, string | null>>({});
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
|
const liveRef = useRef(live);
|
|
liveRef.current = live;
|
|
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);
|
|
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<Toast, 'id'> & { 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<Toast>) => {
|
|
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])));
|
|
}, []);
|
|
|
|
// the last config summary seen per folder: a folder whose read has not come back yet (start) or failed this time keeps
|
|
// claiming its store records, so its App Store / Play entries never show up a second time as store-only apps
|
|
const lastSummary = useRef(new Map<string, NonNullable<PortfolioResult['apps'][number]['summary']>>());
|
|
const apps = useMemo(() => buildApps({
|
|
projects, accounts, overrides,
|
|
summaryOf: p => {
|
|
const fresh = portfolio?.apps.find(a => a.project === p.path)?.summary ?? live?.value.apps.find(a => a.project === p.path)?.summary;
|
|
if (fresh) lastSummary.current.set(p.path, fresh);
|
|
return fresh ?? lastSummary.current.get(p.path);
|
|
},
|
|
storeApps: Object.entries(storeApps).filter(([, e]) => !!e).map(([account, e]) => ({ account, apps: e!.value.apps })),
|
|
}), [projects, accounts, overrides, portfolio, live, storeApps]);
|
|
const appsRef = useRef(apps);
|
|
appsRef.current = apps;
|
|
|
|
// ---- reads: one scheduler (lib/scheduler.ts) under one read registry (lib/read-registry.ts) for every read of the
|
|
// window: buttons, notices, cards, the background reader, the tray and Ctrl+K. Same key = one host call (joined);
|
|
// a broad read covers its narrow keys; every button renders busy from the registry (useReadState), never its own flag.
|
|
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 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 regRef = useRef<ReadRegistry | null>(null);
|
|
if (!regRef.current) regRef.current = new ReadRegistry(sched, {
|
|
onEvent: (key, e) => emitRead(key, e.kind === 'start' ? e : e.kind === 'done' ? { kind: 'done', result: e.result as ToolResult } : { kind: 'error', error: errorText(e.error) }),
|
|
});
|
|
const reg = regRef.current;
|
|
// the provider's derived busy flags (portfolioBusy, importBusy) follow the registry
|
|
const regVer = useSyncExternalStore(reg.subscribe, reg.version, reg.version);
|
|
const portfolioBusy: Data['portfolioBusy'] = useMemo(() => (reg.state(PORTFOLIO_KEY).busy ? 'live' : reg.state(PORTFOLIO_CHECK_KEY).busy ? 'offline' : null), [reg, regVer]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
const importBusy = useMemo(() => reg.state({ tags: ['import'] }).busy, [reg, regVer]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
const accountsRef = useRef(accounts);
|
|
accountsRef.current = accounts;
|
|
|
|
const refreshPortfolio = useCallback((liveRead = false, quiet = false): Promise<boolean> => {
|
|
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
|
|
const run = async (): Promise<boolean> => {
|
|
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 });
|
|
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 });
|
|
}
|
|
} 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 });
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
// the live read uses every store account (their quota pauses show on its buttons); the offline check is local
|
|
return reg.read<boolean>(liveRead
|
|
? { key: PORTFOLIO_KEY, tags: ['all', 'portfolio'], lanes: ['apple', 'play'], accounts: accountsRef.current.map(credOf), priority: quiet ? 1 : 0, run, cancel: () => { api.cancel(job).catch(() => {}); } }
|
|
: { key: PORTFOLIO_CHECK_KEY, tags: ['portfolio-check'], lanes: ['local'], accounts: [], priority: 0, run }).catch(() => false);
|
|
}, [reg, toast, updateToast, notice]);
|
|
|
|
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 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) => reg.active(key), [reg]);
|
|
const focusRef = useRef<string | null>(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 lastData = useRef(new Map<string, unknown>());
|
|
|
|
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority, force = false): 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);
|
|
// 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 job = newJob(method);
|
|
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 }, job);
|
|
if (watch && usable(method, result)) {
|
|
notifyAfterRead(method, before, result.data, notice, app?.name, app?.key);
|
|
lastData.current.set(key, result.data);
|
|
}
|
|
return result;
|
|
};
|
|
return reg.read<ToolResult>({ key, tags: appTags(target), lanes, accounts: accountsOf(app, lanes), priority, run, cancel: () => { api.cancel(job).catch(() => {}); } }, { force });
|
|
}, [reg, notice]);
|
|
|
|
/**
|
|
* Refresh all (and the background pass): one group read that covers the portfolio and every app read it plans, so
|
|
* every button and card of those keys shows busy at once and a click on any of them joins. Single-flight: a second
|
|
* Refresh all, the tray's "Read now" or a pass while one runs joins it. Without `force` only reads past their TTL run.
|
|
* Resolves when everything it started is done: false when the portfolio read failed.
|
|
*/
|
|
const readAll = useCallback(async (force = false): Promise<boolean> => {
|
|
if (!force && !settingsRef.current.refresh.autoRead) return true;
|
|
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
|
|
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));
|
|
const planned = list.flatMap(a => readsFor({ folder: !!a.project, ...platformsOf(a, statusRef.current(a)) }).map(r => ({ a, r, key: readKey(a.target, r.method, r.args) })));
|
|
return reg.group<boolean>({
|
|
key: READ_ALL_KEY, tags: ['all'], covers: [PORTFOLIO_KEY, ...planned.map(p => p.key)],
|
|
run: async g => {
|
|
const waits: Promise<unknown>[] = [];
|
|
let portfolioRead: Promise<boolean> | null = null;
|
|
g.release(PORTFOLIO_KEY);
|
|
if (force || isStale('portfolio.status', liveRef.current?.at)) portfolioRead = refreshPortfolio(true, true);
|
|
// the TTL checks of every key at once (cache reads, local), then the reads that are due, in priority order
|
|
const due = await Promise.all(planned.map(async p => {
|
|
if (force || reg.active(p.key)) return true;
|
|
const c = await api.cached<ToolResult>(p.r.method, p.a.target, p.r.args).catch(() => null);
|
|
return !(c && !isStale(p.r.method, c.at));
|
|
}));
|
|
planned.forEach((p, i) => {
|
|
g.release(p.key);
|
|
if (due[i]) waits.push(schedule(p.a.target, p.r.method, p.r.args, p.a.key === focusRef.current ? 0 : 2).catch(() => {}));
|
|
});
|
|
const ok = portfolioRead ? await portfolioRead : true;
|
|
await Promise.allSettled(waits);
|
|
return ok;
|
|
},
|
|
}).catch(() => false);
|
|
}, [reg, schedule, refreshPortfolio]);
|
|
|
|
/** After a write: only the reads it changed (lib/read-registry.ts affectedReads) lose their memo and read again. */
|
|
const invalidate = useCallback((target: string, writeMethod?: string) => {
|
|
const aff = writeMethod ? affectedReads(writeMethod) : { methods: null, portfolio: true };
|
|
const pre = `${target}\u001f`;
|
|
const hits = (k: string) => k.startsWith(pre) && (!aff.methods || aff.methods.includes(methodOfKey(k)));
|
|
reg.invalidate(k => hits(k) || (aff.portfolio && (k === PORTFOLIO_KEY || k === PORTFOLIO_CHECK_KEY)));
|
|
for (const k of [...bus.current.keys()]) if (hits(k)) 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 (hits(key) && !bus.current.has(key)) schedule(target, r.method, r.args, 1, true).catch(() => {});
|
|
}
|
|
}, [reg, emitRead, schedule]);
|
|
|
|
/** Import apps: one read per account (key importKey); "all" is a group that covers them, so an account's own
|
|
* Import button and the page's Import apps are busy together and never read an account twice. */
|
|
const importApps = useCallback(async (account?: string): Promise<void> => {
|
|
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');
|
|
const readOne = (id: string) => {
|
|
const acc = accountsRef.current.find(a => a.id === id);
|
|
const cred = acc ? credOf(acc) : `apple:${id}`;
|
|
return reg.read<CacheEntry<AppsListResult>>({ key: importKey(id), tags: ['import', `account:${id}`], lanes: [cred.startsWith('play:') ? 'play' : 'apple'], accounts: [cred], priority: 0,
|
|
run: () => api.refreshStoreApps(id, job).then(e => { setStoreApps(m => ({ ...m, [id]: e })); return e; }), cancel: () => { api.cancel(job).catch(() => {}); } });
|
|
};
|
|
// one account whose import is already running (alone or inside Import all): join it, no second toast
|
|
if (account && reg.active(importKey(account))) { await readOne(account).catch(() => {}); return; }
|
|
const body = async (g?: { release: (k: string) => void }) => {
|
|
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, n = 0, cancelled = false;
|
|
const errors: string[] = [];
|
|
await Promise.all(ids.map(async id => {
|
|
g?.release(importKey(id));
|
|
try { const e = await readOne(id); ok++; apps += e.value.apps.length; } catch (e) {
|
|
const msg = errorText(e);
|
|
if (/cancel/i.test(msg)) cancelled = true;
|
|
else errors.push(`${accountsRef.current.find(a => a.id === id)?.label ?? id}: ${msg}`);
|
|
}
|
|
updateToast(tid, { progress: { done: ++n, total: ids.length } });
|
|
}));
|
|
if (cancelled) { updateToast(tid, { kind: 'info', title: t('toast.cancelled'), detail: undefined, progress: undefined, job: undefined }); return; }
|
|
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 });
|
|
};
|
|
if (account) { await body(); return; }
|
|
await reg.group({ key: IMPORT_ALL_KEY, tags: ['import'], covers: ids.map(importKey), run: g => body(g) }).catch(() => {});
|
|
}, [reg, accounts, overrides.ignored, toast, updateToast]);
|
|
|
|
/** "Test" of one account: single-flight per account (Test all and the row's Test join). */
|
|
const checkAccount = useCallback(async (id: string) => {
|
|
const acc = accountsRef.current.find(a => a.id === id);
|
|
const cred = acc ? credOf(acc) : `apple:${id}`;
|
|
const job = newJob('check');
|
|
await reg.read({ key: accountCheckKey(id), tags: ['check', `account:${id}`], lanes: [cred.startsWith('play:') ? 'play' : 'apple'], accounts: [cred], priority: 0,
|
|
run: async () => {
|
|
try {
|
|
const check = await api.checkAccount(id, job);
|
|
setAccountHealth(m => ({ ...m, [id]: { check, at: new Date().toISOString() } }));
|
|
} catch (e) { setAccountHealth(m => ({ ...m, [id]: { error: errorText(e), at: new Date().toISOString() } })); }
|
|
return null;
|
|
} }, { force: true }).catch(() => {});
|
|
}, [reg]);
|
|
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<PortfolioResult>('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 (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) pass().catch(() => {});
|
|
hiddenAt = 0;
|
|
};
|
|
document.addEventListener('visibilitychange', vis);
|
|
const m = settings.refresh.everyMinutes;
|
|
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) {
|
|
if (p.id in icons || p.missing) continue;
|
|
setIcons(m => ({ ...m, [p.id]: null }));
|
|
api.cached<ToolResult & { data?: { dataUrl: string | null } }>('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;
|
|
|
|
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, muteUntil, setMute, schedule, registry: reg, onRead, isReading, readAll, invalidate, setFocus,
|
|
refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
|
toasts, toast, updateToast, dismiss, cancelJob,
|
|
}), [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, reg]);
|
|
return <Ctx.Provider value={value}><RegistryCtx.Provider value={reg}>{children}</RegistryCtx.Provider></Ctx.Provider>;
|
|
}
|
|
|
|
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;
|
|
loading: boolean;
|
|
error: string | null;
|
|
/** The command line it ran (shown with errors). */
|
|
command: string | null;
|
|
/** Finished less than a few seconds ago: a click answers from it ("read just now"), no second store call. */
|
|
justNow?: boolean;
|
|
/** Its store account waits on a quota pause or 429 backoff until then (epoch ms). */
|
|
pausedUntil?: number | null;
|
|
/** Reads again (joins / memo); `true` skips the "read just now" memo. */
|
|
refresh: (force?: unknown) => Promise<void>;
|
|
}
|
|
|
|
/** preview: false when the screen already showed this dry run (the editors' Review step); the native dialog still follows. */
|
|
export interface WriteRun { method: string; args: Record<string, unknown>; title: string; done: string; after?: () => void; preview?: false }
|
|
|
|
/**
|
|
* 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<string | null>(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 {
|
|
// the in-app preview of the dry run first (readable, styled); the host's native dialog stays the real gate
|
|
if (w.preview !== false) {
|
|
const dry = await api.call<ToolResult>(w.method, projectId, { args: w.args }, job);
|
|
if (!dry.ok) { d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(dry)), progress: undefined, job: undefined }); return null; }
|
|
if (!(await askPreview(w.title, dry))) { d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined }); return 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 }); d.invalidate(projectId, w.method); w.after?.(); }
|
|
// 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<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, notice: Notice, appName: string | undefined, appKey: string | undefined): void {
|
|
const name = appName ?? '';
|
|
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) 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' && next && typeof next === 'object') {
|
|
const crossed = crashCrossed(prev as HealthSnapshot | null, next as HealthSnapshot);
|
|
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'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The live state of one read query (a key, several keys, tags such as 'all' or 'import') from the read registry:
|
|
* busy while anything overlapping is queued or running (its own read, a broad read covering it, a narrow read of its
|
|
* scope), "read just now", the quota pause of its accounts. Every read button, notice and card renders loading and
|
|
* disabled from this, never from a local flag. Re-renders on every registry change (useSyncExternalStore).
|
|
*/
|
|
export function useReadState(q: ReadQuery): ReadStatus {
|
|
const reg = useContext(RegistryCtx);
|
|
if (!reg) throw new Error('useReadState outside DataProvider');
|
|
const ver = useSyncExternalStore(reg.subscribe, reg.version, reg.version);
|
|
const qk = typeof q === 'string' ? q : JSON.stringify(q);
|
|
return useMemo(() => reg.state(q), [reg, ver, qk]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
}
|
|
|
|
/**
|
|
* 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 read registry (offline reads always;
|
|
* store reads while Settings > Reading > auto-read is on). A read anyone else runs for this key (the background reader,
|
|
* Refresh all, a write's invalidation) lands here too (the read bus), and `loading` follows the registry: busy whenever
|
|
* the key, or a broad read covering it, is in flight. A failed read keeps the last good value and says so.
|
|
*/
|
|
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>);
|
|
// `loading` here is only the first look into the cache; the read itself is the registry's
|
|
const [s, set] = useState<Omit<ReadState<T>, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
|
const st = useReadState(key);
|
|
const dRef = useRef(d);
|
|
dRef.current = d;
|
|
const skipRef = useRef(o.skip);
|
|
skipRef.current = o.skip;
|
|
|
|
const refresh = useCallback(async (force?: unknown) => {
|
|
if (skipRef.current) return;
|
|
set(x => ({ ...x, loading: false }));
|
|
// a click: highest priority, joins a read in flight, answered from the memo when it finished just now. The answer
|
|
// is applied here too: a join or a memo answer sends no bus event, and a card that mounted after the read (a tab
|
|
// opened again) would otherwise wait forever. `force === true` (after a save of this app's files) skips the memo.
|
|
const r = await dRef.current.schedule(target, method, JSON.parse(argsKey) as Record<string, unknown>, 0, force === true).catch(() => null);
|
|
if (r && usable(method, r)) set(x => (x.data === r.data ? x : { data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command }));
|
|
}, [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') return;
|
|
if (e.kind === 'invalidate') { set(x => ({ ...x, stale: true })); refresh(); }
|
|
else if (e.kind === 'error') { if (!/cancel/i.test(e.error)) set(x => ({ ...x, error: oneLine(e.error) })); }
|
|
else {
|
|
const r = e.result;
|
|
if (usable(method, r)) 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, 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;
|
|
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);
|
|
// in flight already (Refresh all, the background reader): the registry shows it busy; nothing to start
|
|
if (!dRef.current.isReading(key) && 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: 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, loading: !o.skip && (s.loading || st.busy), justNow: st.justNow, pausedUntil: st.pausedUntil, refresh };
|
|
}
|
|
|
|
/** The cached value of one read (and every later result of it), without ever starting a read: for summaries such as a
|
|
* section's "what's next" line that must not cost a store call. Null until the read ran once. */
|
|
export function useCachedRead<T>(target: string | null, method: string, args: Record<string, unknown> = {}): T | null {
|
|
const d = useData();
|
|
const argsKey = JSON.stringify(args);
|
|
const [v, set] = useState<T | null>(null);
|
|
useEffect(() => {
|
|
set(null);
|
|
if (!target) return;
|
|
let alive = true;
|
|
const a = JSON.parse(argsKey) as Record<string, unknown>;
|
|
api.cached<ToolResult>(method, target, a).then(c => { if (alive && c && c.value.data !== undefined && usable(method, c.value)) set(c.value.data as T); }, () => {});
|
|
const un = d.onRead(readKey(target, method, a), e => { if (e.kind === 'done' && usable(method, e.result)) set(e.result.data as T); });
|
|
return () => { alive = false; un(); };
|
|
}, [target, method, argsKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
return v;
|
|
}
|