665 lines
38 KiB
TypeScript
665 lines
38 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 { 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, 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 { 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 scheduler (deduped, rate limited); the result also goes to the read bus. */
|
|
schedule: (target: string, method: string, args: Record<string, unknown>, priority: Priority) => Promise<ToolResult>;
|
|
/** Listens to one read's bus key. */
|
|
onRead: (key: string, fn: (e: ReadEvent) => void) => () => void;
|
|
isReading: (key: string) => boolean;
|
|
/** Reads everything of every app that is past its TTL (force: everything). */
|
|
readAll: (force?: boolean) => Promise<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;
|
|
/** 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);
|
|
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; };
|
|
|
|
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 [portfolioBusy, setBusy] = useState<Data['portfolioBusy']>(null);
|
|
const [portfolioError, setPortfolioError] = useState<string | null>(null);
|
|
const [importBusy, setImportBusy] = useState(false);
|
|
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])));
|
|
}, []);
|
|
|
|
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): 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;
|
|
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;
|
|
} finally { setBusy(null); }
|
|
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);
|
|
if (!schedRef.current) schedRef.current = new ReadScheduler({ classify: classifyRead, limits: { apple: settings.refresh.parallel, play: settings.refresh.parallel } });
|
|
const sched = schedRef.current;
|
|
const [reader, setReader] = useState<SchedulerStats>(() => sched.stats());
|
|
useEffect(() => {
|
|
let tm: ReturnType<typeof setTimeout> | null = null;
|
|
const un = sched.subscribe(() => { if (!tm) tm = setTimeout(() => { tm = null; setReader(sched.stats()); }, 150); });
|
|
return () => { un(); if (tm) clearTimeout(tm); };
|
|
}, [sched]);
|
|
useEffect(() => { sched.setLimits({ apple: settings.refresh.parallel, play: settings.refresh.parallel }); }, [sched, settings.refresh.parallel]);
|
|
const bus = useRef(new Map<string, Set<(e: ReadEvent) => void>>());
|
|
const emitRead = useCallback((key: string, e: ReadEvent) => { for (const f of bus.current.get(key) ?? []) { try { f(e); } catch { /* a card's listener never breaks the bus */ } } }, []);
|
|
const onRead = useCallback((key: string, fn: (e: ReadEvent) => void) => {
|
|
const set = bus.current.get(key) ?? new Set();
|
|
set.add(fn); bus.current.set(key, set);
|
|
return () => { set.delete(fn); if (!set.size) bus.current.delete(key); };
|
|
}, []);
|
|
const isReading = useCallback((key: string) => sched.has(key), [sched]);
|
|
const focusRef = useRef<string | null>(null);
|
|
const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []);
|
|
|
|
const 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): 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 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, notice]);
|
|
|
|
/**
|
|
* 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));
|
|
for (const a of list) {
|
|
const pf = platformsOf(a, statusRef.current(a));
|
|
for (const r of readsFor({ folder: !!a.project, ...pf })) {
|
|
const key = readKey(a.target, r.method, r.args);
|
|
if (sched.has(key)) continue;
|
|
if (!force) {
|
|
const c = await api.cached<ToolResult>(r.method, a.target, r.args).catch(() => null);
|
|
if (c && !isStale(r.method, c.at)) continue;
|
|
}
|
|
schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {});
|
|
}
|
|
}
|
|
await portfolioRead;
|
|
return ok;
|
|
}, [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<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, 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]);
|
|
return <Ctx.Provider value={value}>{children}</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;
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
export interface WriteRun { method: string; args: Record<string, unknown>; 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<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 {
|
|
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<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'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<T>(target: string, method: string, o: { network: boolean; args?: Record<string, unknown>; title?: string; appName?: string; skip?: boolean }): ReadState<T> {
|
|
const d = useData();
|
|
const argsKey = JSON.stringify(o.args ?? {});
|
|
const key = readKey(target, method, JSON.parse(argsKey) as Record<string, unknown>);
|
|
const [s, set] = useState<Omit<ReadState<T>, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
|
const dataRef = useRef<T | null>(null);
|
|
dataRef.current = s.data;
|
|
const dRef = useRef(d);
|
|
dRef.current = d;
|
|
const skipRef = useRef(o.skip);
|
|
skipRef.current = o.skip;
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (skipRef.current) return;
|
|
set(x => ({ ...x, loading: true }));
|
|
await dRef.current.schedule(target, method, JSON.parse(argsKey) as Record<string, unknown>, 0).catch(() => {});
|
|
}, [target, method, argsKey]);
|
|
|
|
// results of this key, whoever asked for them (this card, the background reader, a write's invalidation)
|
|
useEffect(() => {
|
|
if (o.skip) return;
|
|
return d.onRead(key, e => {
|
|
if (e.kind === 'start') set(x => ({ ...x, loading: true }));
|
|
else if (e.kind === 'invalidate') { set(x => ({ ...x, stale: true })); refresh(); }
|
|
else if (e.kind === 'error') { if (/cancel/i.test(e.error)) set(x => ({ ...x, loading: false })); else set(x => ({ ...x, loading: false, error: oneLine(e.error) })); }
|
|
else {
|
|
const r = e.result;
|
|
if (usable(method, r)) {
|
|
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<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, refresh };
|
|
}
|