storebound/apps/desktop/src/screens/Dependencies.tsx

154 lines
9.5 KiB
TypeScript

// Dependencies: local tools for screenshots, previews, icons and captures. ffmpeg and Chrome for Testing download
// into the app data folder in one click (checksum or signature verified, progress, cancel); the rest link out.
// Store work needs none of them. Sources and licenses: docs/desktop.md.
import { useCallback, useEffect, useState } from 'react';
import { type AndroidSdkLookup, api, type DownloadInfo, errorText, type LocalTools, newJob, onDownload, type ToolStatus } from '../api';
import { t } from '../i18n';
import { bytes } from '../lib/format';
import { shortPath } from '../paths';
import { useData } from '../store';
import { Icon, type IconName } from '../ui/icons';
import { Card, Chip, Notice, Skeleton } from '../ui/kit';
const TOOL_ICON: Record<string, IconName> = { ffmpeg: 'image', ffprobe: 'search', chrome: 'globe', xcode: 'terminal', simctl: 'phone', adb: 'phone', git: 'layers' };
/** Status chip of one tool. */
export function toolState(x: ToolStatus): { text: string; tone: 'ok' | 'warn' | 'accent' } {
if (!x.found) return { text: t('deps.missing'), tone: 'warn' };
return x.source === 'app' ? { text: t('deps.appCopy'), tone: 'accent' } : { text: t('deps.installed'), tone: 'ok' };
}
interface Dl { job: string; stage: string; received: number; total: number | null }
export function Dependencies() {
const d = useData();
const [tools, setTools] = useState<ToolStatus[] | null>(d.sidecar?.tools?.tools ?? null);
const [sdk, setSdk] = useState<AndroidSdkLookup | null>(d.sidecar?.tools?.androidSdk ?? null);
const [info, setInfo] = useState<DownloadInfo[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [dl, setDl] = useState<Record<string, Dl>>({});
const refresh = useCallback(async () => {
setBusy(true); setError(null);
try {
const [s, i] = await Promise.all([api.call<LocalTools>('system.tools'), api.toolsInfo()]);
setTools(s.tools); setInfo(i); setSdk(s.androidSdk ?? null);
} catch (e) { setError(errorText(e)); } finally { setBusy(false); }
}, []);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
const un = onDownload(e => setDl(m => {
const k = Object.keys(m).find(x => m[x]!.job === e.job);
return k ? { ...m, [k]: { ...m[k]!, stage: e.stage, received: e.received, total: e.total } } : m;
}));
return () => { un.then(f => f()); };
}, []);
const install = async (tool: 'ffmpeg' | 'chrome') => {
const job = newJob(`dl-${tool}`);
setDl(m => ({ ...m, [tool]: { job, stage: 'resolve', received: 0, total: null } }));
const tid = d.toast({ id: job, kind: 'job', title: t('deps.downloading', { tool: tool === 'chrome' ? 'Chrome for Testing' : 'ffmpeg' }), detail: t('dl.resolve'), progress: null, job });
try {
const v = await api.installTool(tool, job);
d.updateToast(tid, { kind: 'ok', title: t('deps.installedToast', { tool: tool === 'chrome' ? 'Chrome for Testing' : 'ffmpeg' }), detail: v, progress: undefined, job: undefined });
} catch (e) {
const msg = errorText(e);
d.updateToast(tid, { kind: msg === 'cancelled' ? 'info' : 'bad', title: msg === 'cancelled' ? t('toast.cancelled') : t('deps.failed'), detail: msg === 'cancelled' ? t('deps.cancelledDetail') : msg, progress: undefined, job: undefined });
} finally {
setDl(m => { const { [tool]: _gone, ...rest } = m; return rest; });
refresh();
}
};
const uninstall = async (tool: 'ffmpeg' | 'chrome') => {
try { await api.uninstallTool(tool); d.toast({ kind: 'ok', title: t('deps.removed') }); } catch (e) { setError(errorText(e)); }
refresh();
};
const open = (url: string) => api.openExternal(url).catch(e => setError(errorText(e)));
const copy = (s: string) => { navigator.clipboard?.writeText(s).then(() => d.toast({ kind: 'ok', title: t('deps.copied') }), () => {}); };
const found = tools?.filter(x => x.found).length ?? 0;
const managed = (id: string): DownloadInfo | undefined => info.find(i => i.tool === (id === 'ffprobe' ? 'ffmpeg' : id));
return (
<div className="page">
<div className="ph">
<div><h1>{t('nav.dependencies')}</h1><p>{t('deps.lead')}</p></div>
<div className="actions"><button className="btn" onClick={refresh} disabled={busy}><Icon name="refresh" className={busy ? 'spin' : undefined} />{t('deps.checkAgain')}</button></div>
</div>
{error && <Notice tone="bad">{error}</Notice>}
<Card title={t('deps.tools')} icon="package" sub={tools ? t('deps.found', { found, total: tools.length }) : t('common.loading')} pad={false}>
{!tools ? <div className="card-body"><Skeleton lines={6} /></div> : (
<div className="list">
{tools.map(x => {
const st = toolState(x);
const m = x.install.kind === 'download' ? managed(x.id) : undefined;
const running = m ? dl[m.tool] : undefined;
const pct = running?.total ? running.received / running.total : null;
return (
<div className="li tool-row" key={x.id} style={{ alignItems: 'center' }}>
<span className="ti"><Icon name={TOOL_ICON[x.id] ?? 'package'} /></span>
<div className="li-main">
<span className="row" style={{ gap: 8 }}><span className="li-title">{x.name}</span><Chip tone={st.tone}>{st.text}</Chip>{x.version && <span className="small muted num">{x.version}</span>}</span>
<span className="li-sub">{x.usedFor}</span>
{x.path && <span className="li-sub mono-id selectable" title={x.path}>{shortPath(x.path, d.info?.home)}</span>}
</div>
<div className="actions" style={{ flexWrap: 'nowrap' }}>
{running ? (
<div className="prog" aria-live="polite">
<div className={`bar${pct === null ? ' indet' : ''}`}><span style={{ width: `${(pct ?? 0.35) * 100}%` }} /></div>
<div className="c"><span>{t(`dl.${running.stage}` as 'dl.download')}</span><span>{running.total ? `${bytes(running.received)} / ${bytes(running.total)}` : ''}</span></div>
</div>
) : null}
{running ? <button className="btn btn-sm" onClick={() => d.cancelJob(running.job)}>{t('common.cancel')}</button>
: m && x.id !== 'ffprobe' ? (
m.available ? (
m.installed ? <button className="btn btn-sm btn-ghost" onClick={() => uninstall(m.tool)}><Icon name="trash" />{t('deps.remove')}</button>
: <button className="btn btn-sm btn-primary" onClick={() => install(m.tool)} title={`${m.license} · ${m.source}`}><Icon name="download" />{t('deps.download')}{m.size ? ` · ${bytes(m.size)}` : ''}</button>
) : <Chip tone="neutral">{t('deps.unavailable')}</Chip>
) : x.install.kind === 'link' && !x.found ? <button className="btn btn-sm" onClick={() => open(x.install.url)}><Icon name="external" />{t('deps.getIt')}</button> : null}
</div>
</div>
);
})}
</div>
)}
</Card>
{sdk && (
<Card title={t('deps.sdkTitle')} icon="phone" sub={t('deps.sdkSub')} id="android-sdk"
actions={sdk.path ? <Chip tone="ok">{t('deps.installed')}</Chip> : <><Chip tone="warn">{t('deps.missing')}</Chip><button className="btn btn-sm" onClick={() => open('https://developer.android.com/studio')}><Icon name="external" />{t('deps.getIt')}</button></>}>
{sdk.path ? (
<dl className="kv">
<dt>{t('deps.sdkPath')}</dt><dd className="mono-id selectable" title={sdk.path}>{shortPath(sdk.path, d.info?.home)}</dd>
<dt>{t('deps.sdkFrom')}</dt><dd className="small">{sdk.source}</dd>
</dl>
) : (
<div className="stack" style={{ gap: 6 }}>
<span className="small">{t('deps.sdkNone')}</span>
<ul className="note-list small">{sdk.checked.map((c, i) => <li key={i} className="selectable">{d.info?.home ? c.split(d.info.home).join("~") : c}</li>)}</ul>
</div>
)}
</Card>
)}
<div className="grid g2">
{info.map(m => (
<Card key={m.tool} title={m.tool === 'chrome' ? 'Chrome for Testing' : `ffmpeg ${m.version ?? ''}`} icon={m.tool === 'chrome' ? 'globe' : 'image'} sub={m.installed ? t('deps.inAppData', { folder: m.folder }) : t('deps.notDownloaded')}>
<dl className="kv">
<dt>{t('deps.source')}</dt><dd className="selectable small">{m.source}</dd>
<dt>{t('deps.license')}</dt><dd className="small">{m.license}</dd>
<dt>{t('deps.verify')}</dt><dd className="small">{m.tool === 'chrome' ? t('deps.verifyChrome') : t('deps.verifyFfmpeg')}</dd>
</dl>
<div className="actions" style={{ marginTop: 12 }}><button className="btn btn-sm btn-ghost" onClick={() => open(m.licenseUrl)}><Icon name="external" />{t('deps.licenseLink')}</button></div>
</Card>
))}
</div>
<Card title={t('deps.terminal')} icon="terminal" sub={t('deps.terminalSub')}>
<div className="stack">
{['storebound doctor', 'bun add -d playwright', 'brew install ffmpeg'].map(c => (
<div className="cmd" key={c}><code className="selectable">{c}</code><button className="btn btn-sm btn-ghost btn-icon" onClick={() => copy(c)} aria-label={t('deps.copy', { cmd: c })}><Icon name="copy" /></button></div>
))}
</div>
</Card>
</div>
);
}