165 lines
11 KiB
TypeScript
165 lines
11 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, useReadState } from '../store';
|
|
import { Icon, type IconName } from '../ui/icons';
|
|
import { Chip, Notice, Skeleton } from '../ui/kit';
|
|
import { PageHeader, Section, StatTile, StatusPill } from '../ui/ds';
|
|
import '../styles/deps.css';
|
|
|
|
/** The local tools check (system.tools): one registry key. */
|
|
const TOOLS_KEY = '*\u001fsystem.tools';
|
|
|
|
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' };
|
|
if (x.status === 'outdated') return { text: t('deps.outdated', { version: x.required ?? '' }), 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);
|
|
// local tools check: one registry key, so a second click (or a remount) joins the check in flight
|
|
const busy = useReadState(TOOLS_KEY).busy;
|
|
const [dl, setDl] = useState<Record<string, Dl>>({});
|
|
|
|
const reg = d.registry;
|
|
const refresh = useCallback(async () => {
|
|
setError(null);
|
|
try {
|
|
const [s, i] = await reg.read({ key: TOOLS_KEY, lanes: ['local'], accounts: [], priority: 0, run: () => Promise.all([api.call<LocalTools>('system.tools'), api.toolsInfo()]) }, { force: true });
|
|
setTools(s.tools); setInfo(i); setSdk(s.androidSdk ?? null);
|
|
} catch (e) { setError(errorText(e)); }
|
|
}, [reg]);
|
|
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 scr-deps">
|
|
<PageHeader icon="package" eyebrow={t('v2cfg.workspace')} title={t('nav.dependencies')} subtitle={t('deps.lead')} actions={<><button className="btn" onClick={refresh} disabled={busy}><Icon name="refresh" className={busy ? 'spin' : undefined} />{t('deps.checkAgain')}</button></>} />
|
|
{error && <Notice tone="bad">{error}</Notice>}
|
|
{tools && <div className="ds-grid deps-kpis">
|
|
<StatTile icon="check" tone={found === tools.length ? 'ok' : undefined} label={t('v2cfg.depsFound')} value={`${found} / ${tools.length}`} />
|
|
<StatTile icon="alert" tone={tools.some(x => !x.found || x.status === 'outdated') ? 'warn' : undefined} label={t('v2cfg.depsNeeds')} value={tools.filter(x => !x.found || x.status === 'outdated').length} />
|
|
<StatTile icon="download" label={t('v2cfg.depsInApp')} value={info.filter(i => i.installed).length} sub={t('v2cfg.depsInAppSub')} />
|
|
</div>}
|
|
<Section 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 deps-grid">
|
|
{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 tool-${st.tone}`} key={x.id}>
|
|
<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></span>
|
|
<span className="row tool-chips" style={{ gap: 6 }}><StatusPill tone={st.tone === 'accent' ? 'info' : st.tone}>{st.text}</StatusPill>{x.version && <Chip tone="outline">{x.version}</Chip>}{x.required && <Chip tone="neutral">{t('v2cfg.needs', { version: x.required })}</Chip>}</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 tool-acts">
|
|
{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>
|
|
)}
|
|
</Section>
|
|
{sdk && (
|
|
<Section 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>
|
|
)}
|
|
</Section>
|
|
)}
|
|
<div className="grid g2 deps-src">
|
|
{info.map(m => (
|
|
<Section 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>
|
|
</Section>
|
|
))}
|
|
</div>
|
|
<Section 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>
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|