154 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|