Desktop D2 UI: design system, portfolio, release timeline, catalog parity, listings, health, reviews, TestFlight, checks, command palette, toasts, first-run guide, en + tr with i18n check, 7:1 theme check, dev fixture mode and screenshot harness

This commit is contained in:
CRTkafa 2026-10-11 03:34:47 +08:00
parent 95b7cf7ced
commit 2ef0769730
44 changed files with 5475 additions and 742 deletions

View file

@ -1,69 +1,137 @@
// 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 { api, errorText, type ToolStatus } from '../api';
import type { Data } from '../App';
import { api, type DownloadInfo, errorText, 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';
/** Status tag of one tool. */
export function toolState(t: ToolStatus): { text: string; tone: 'ok' | 'warn' | 'local' } {
if (!t.found) return { text: 'missing', tone: 'warn' };
return t.source === 'app' ? { text: 'app copy', tone: 'local' } : { text: 'installed', tone: 'ok' };
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' };
}
/**
* Local tools storebound uses for screenshots, previews, icons and captures. ffmpeg and Chrome for Testing will be
* downloadable into the app's data folder (checksum or signature verified, never system-wide, never bundled);
* Xcode and the Android SDK are link-out only. Sources and licenses: docs/desktop.md.
*/
export function Dependencies({ data }: { data: Data }) {
const [tools, setTools] = useState<ToolStatus[] | null>(data.sidecar?.tools?.tools ?? null);
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 [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 { setTools((await api.call<{ tools: ToolStatus[] }>('system.tools')).tools); } catch (e) { setError(errorText(e)); } finally { setBusy(false); }
try {
const [s, i] = await Promise.all([api.call<{ tools: ToolStatus[] }>('system.tools'), api.toolsInfo()]);
setTools(s.tools); setInfo(i);
} catch (e) { setError(errorText(e)); } finally { setBusy(false); }
}, []);
useEffect(() => { if (!tools) refresh(); }, [tools, refresh]);
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 (
<>
<header className="head">
<div>
<h1>Dependencies</h1>
<p>Local tools for screenshots, app previews, icons and simulator or emulator captures. Store work (listings, products, releases) needs none of them.</p>
</div>
<div className="actions"><button className="btn" onClick={refresh} disabled={busy}>{busy ? 'Checking' : 'Check again'}</button></div>
</header>
<div className="stack">
{error && <div className="notice notice-bad" role="alert">{error}</div>}
<div className="card">
<table>
<thead><tr><th>Tool</th><th>Status</th><th>Version</th><th>Used for</th><th><span className="sr">Get it</span></th></tr></thead>
<tbody>
{(tools ?? []).map(t => {
const st = toolState(t);
return (
<tr key={t.id}>
<td><div className="name">{t.name}</div>{t.path && <div className="path selectable" title={t.path}>{shortPath(t.path, data.home)}</div>}</td>
<td><span className={`tag tag-${st.tone}`}>{st.text}</span></td>
<td className="selectable">{t.version ?? ''}</td>
<td className="muted">{t.usedFor}</td>
<td style={{ textAlign: 'right' }}>
{t.install.kind === 'download'
? <button className="btn" disabled title={`${t.install.note} One-click download comes with the next update.`}>Download</button>
: <button className="btn btn-quiet" onClick={() => open(t.install.url)} title={t.install.note}>Get it</button>}
</td>
</tr>
);
})}
{!tools && <tr><td colSpan={5} className="muted">Checking...</td></tr>}
</tbody>
</table>
</div>
<div className="notice notice-info">
ffmpeg and Chrome for Testing will download into this app's data folder only, verified before use, and are never part of the installer. Xcode and the Android SDK come from Apple and Google.
</div>
<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>
<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>
);
}