Skip to content

Commit d9c6095

Browse files
committed
refactor(frontend): split Settings.tsx into per-section components
The monolithic 1079-LOC views/Settings.tsx mixed all 8 setting cards (appearance, accessibility, notifications/security, remote-access, ffmpeg-config, app-behavior, data-management, about/updater/licenses) with their TanStack Query subscriptions, file-browser handles, theme installer state, self-updater state machine, and the GPL-compliance licenses modal in a single render tree. Extract each card into its own self-contained component so a maintainer touching e.g. the updater state machine doesn't have to navigate around remote-access toggles. - components/settings/sections/AppearanceSection.tsx — theme picker + installer + language picker; owns its own themeStore + profileStore + useFileBrowser subscriptions. - components/settings/sections/AccessibilitySection.tsx — HC toggle + panic hotkey rebind row; subsumes the Phase A inline AccessibilityCard. - components/settings/sections/NotificationsSecuritySection.tsx — notification + at-rest-encryption toggles + KeyRotationSection. - components/settings/sections/RemoteAccessSection.tsx — backend host/port/token + the two remote-enable toggles. - components/settings/sections/FFmpegConfigSection.tsx — FFmpeg path + version + update-available banner; preserves the per-platform delivery comment as load-bearing maintainer context. - components/settings/sections/AppBehaviorSection.tsx — start-minimized + log-retention. - components/settings/sections/DataManagementSection.tsx — profile storage path + export + clear-all-data (with the ConfirmDialog flow). - components/settings/sections/AboutSection.tsx — app version + links + the Tauri self-updater discriminated-union state machine + the third-party licenses modal. Each section subscribes to its own stores / hooks / api namespace; no state lifted up to the parent. The orchestrator (views/Settings.tsx) becomes a ~110-LOC tab coordinator owning only the activeTab state, the global-loading / error states, and the empty-profile guard. The Profile / Global tab IA is preserved byte-identical (per feedback_preserve_menus). Pure file-split for maintainability.
1 parent 7879525 commit d9c6095

9 files changed

Lines changed: 1165 additions & 1000 deletions
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { Github, BookOpen, RefreshCw } from 'lucide-react';
4+
import { Card, CardHeader, CardTitle, CardDescription, CardBody } from '@/components/ui/Card';
5+
import { Button } from '@/components/ui/Button';
6+
import { Modal } from '@/components/ui/Modal';
7+
import { Logo } from '@/components/layout/Logo';
8+
import { api } from '@/lib/client';
9+
10+
/**
11+
* Discriminated union of the self-updater state machine. Each variant
12+
* renders its own surface inside the card. `import('@tauri-apps/plugin-updater').Update`
13+
* carries the Tauri-side download/install handle while we're in the
14+
* 'available' state.
15+
*/
16+
type UpdateState =
17+
| { kind: 'idle' }
18+
| { kind: 'checking' }
19+
| { kind: 'no-update' }
20+
| {
21+
kind: 'available';
22+
version: string;
23+
notes: string;
24+
update: import('@tauri-apps/plugin-updater').Update;
25+
}
26+
| { kind: 'downloading'; downloaded: number; total: number | null }
27+
| { kind: 'ready-to-restart' }
28+
| { kind: 'error'; detail: string };
29+
30+
/**
31+
* App version + GitHub/docs links + the self-updater state machine +
32+
* GPL-compliance third-party licenses modal entrypoint.
33+
*
34+
* `updaterSupported` is `true` on macOS / Windows / Linux AppImage, and
35+
* `false` on Linux .deb/.rpm installs (those are package-manager driven).
36+
*/
37+
export function AboutSection() {
38+
const { t } = useTranslation();
39+
const [appVersion, setAppVersion] = useState<string>('');
40+
const [updaterSupported, setUpdaterSupported] = useState<boolean | null>(null);
41+
const [updateState, setUpdateState] = useState<UpdateState>({ kind: 'idle' });
42+
const [licensesModalOpen, setLicensesModalOpen] = useState(false);
43+
44+
// Probe app version + updater support once on mount; both are stable
45+
// for the lifetime of the process.
46+
useEffect(() => {
47+
let cancelled = false;
48+
api.system
49+
.appVersion()
50+
.then(({ version }) => {
51+
if (!cancelled) setAppVersion(version);
52+
})
53+
.catch(() => {});
54+
import('@/utils/selfUpdate')
55+
.then(({ isUpdaterSupported }) => isUpdaterSupported())
56+
.then((supported) => {
57+
if (!cancelled) setUpdaterSupported(supported);
58+
})
59+
.catch(() => {
60+
if (!cancelled) setUpdaterSupported(false);
61+
});
62+
return () => {
63+
cancelled = true;
64+
};
65+
}, []);
66+
67+
const handleCheckForUpdates = useCallback(async (): Promise<void> => {
68+
setUpdateState({ kind: 'checking' });
69+
try {
70+
const { checkForUpdate } = await import('@/utils/selfUpdate');
71+
const update = await checkForUpdate();
72+
if (!update) {
73+
setUpdateState({ kind: 'no-update' });
74+
return;
75+
}
76+
setUpdateState({
77+
kind: 'available',
78+
version: update.version,
79+
notes: update.body ?? '',
80+
update,
81+
});
82+
} catch (e) {
83+
const detail = e instanceof Error ? e.message : String(e);
84+
// Security-relevant: record verification / network failures into
85+
// the audit chain so operators can grep for tampered-update
86+
// attempts. Best-effort — don't block the UI on the audit POST.
87+
api.system.recordAppUpdateFailure(detail).catch(() => {});
88+
setUpdateState({ kind: 'error', detail });
89+
}
90+
}, []);
91+
92+
const handleInstallUpdate = useCallback(async (): Promise<void> => {
93+
if (updateState.kind !== 'available') return;
94+
const update = updateState.update;
95+
setUpdateState({ kind: 'downloading', downloaded: 0, total: null });
96+
try {
97+
const { downloadAndInstall } = await import('@/utils/selfUpdate');
98+
await downloadAndInstall(update, ({ downloaded, total }) => {
99+
setUpdateState({ kind: 'downloading', downloaded, total });
100+
});
101+
setUpdateState({ kind: 'ready-to-restart' });
102+
} catch (e) {
103+
setUpdateState({
104+
kind: 'error',
105+
detail: e instanceof Error ? e.message : String(e),
106+
});
107+
}
108+
}, [updateState]);
109+
110+
const handleRelaunch = useCallback(async (): Promise<void> => {
111+
try {
112+
const { relaunch } = await import('@/utils/selfUpdate');
113+
await relaunch();
114+
} catch (e) {
115+
setUpdateState({
116+
kind: 'error',
117+
detail: e instanceof Error ? e.message : String(e),
118+
});
119+
}
120+
}, []);
121+
122+
return (
123+
<>
124+
<Card>
125+
<CardHeader>
126+
<div>
127+
<CardTitle>{t('settings.about')}</CardTitle>
128+
<CardDescription>{t('settings.aboutDescription')}</CardDescription>
129+
</div>
130+
</CardHeader>
131+
<CardBody>
132+
<div className="text-center py-4">
133+
<div className="flex justify-center mb-4">
134+
<Logo size="lg" />
135+
</div>
136+
<div className="text-sm text-text-secondary mb-1">
137+
{t('settings.version')} {appVersion || '…'}
138+
</div>
139+
<div className="text-xs text-text-tertiary mb-6">
140+
{t('settings.tagline')}
141+
</div>
142+
<div className="flex justify-center gap-3">
143+
<Button
144+
variant="ghost"
145+
size="sm"
146+
onClick={() =>
147+
window.open(
148+
'https://github.com/ScopeCreep-zip/SpiritStream',
149+
'_blank',
150+
'noopener,noreferrer',
151+
)
152+
}
153+
>
154+
<Github className="w-4 h-4" />
155+
{t('settings.github')}
156+
</Button>
157+
<Button
158+
variant="ghost"
159+
size="sm"
160+
onClick={() =>
161+
window.open(
162+
'https://deepwiki.com/ScopeCreep-zip/SpiritStream',
163+
'_blank',
164+
'noopener,noreferrer',
165+
)
166+
}
167+
>
168+
<BookOpen className="w-4 h-4" />
169+
{t('settings.docs')}
170+
</Button>
171+
{updaterSupported && (
172+
<Button
173+
variant="ghost"
174+
size="sm"
175+
onClick={handleCheckForUpdates}
176+
disabled={updateState.kind === 'checking' || updateState.kind === 'downloading'}
177+
>
178+
<RefreshCw
179+
className={`w-4 h-4 ${updateState.kind === 'checking' ? 'animate-spin' : ''}`}
180+
/>
181+
{updateState.kind === 'checking'
182+
? t('settings.updateChecking', { defaultValue: 'Checking…' })
183+
: t('settings.updates')}
184+
</Button>
185+
)}
186+
</div>
187+
<div className="mt-3">
188+
<button
189+
type="button"
190+
className="text-xs text-text-tertiary underline hover:text-text-secondary"
191+
onClick={() => setLicensesModalOpen(true)}
192+
>
193+
{t('settings.thirdPartyLicenses', { defaultValue: 'Third-party licenses' })}
194+
</button>
195+
</div>
196+
{/* Update state machine surface — mutually exclusive states. */}
197+
<div className="mt-4 text-xs">
198+
{updateState.kind === 'no-update' && (
199+
<div className="text-text-tertiary">
200+
{t('settings.updateNone', {
201+
defaultValue: 'You are running the latest version.',
202+
})}
203+
</div>
204+
)}
205+
{updaterSupported === false && (
206+
<div className="text-text-tertiary">
207+
{t('settings.updateDistroManaged', {
208+
defaultValue:
209+
"Updates are managed by your distribution's package manager. Run `apt upgrade spiritstream` or `dnf upgrade spiritstream`.",
210+
})}
211+
</div>
212+
)}
213+
{updateState.kind === 'available' && (
214+
<div className="rounded-md border border-status-info/40 bg-status-info/10 px-3 py-2 text-left">
215+
<div className="font-medium text-status-info mb-1">
216+
{t('settings.updateAvailableTitle', {
217+
defaultValue: 'Update available: {{version}}',
218+
version: updateState.version,
219+
})}
220+
</div>
221+
{updateState.notes && (
222+
<pre className="whitespace-pre-wrap text-text-secondary text-xs mt-2 max-h-32 overflow-auto">
223+
{updateState.notes}
224+
</pre>
225+
)}
226+
<Button variant="primary" size="sm" className="mt-3" onClick={handleInstallUpdate}>
227+
{t('settings.updateInstall', { defaultValue: 'Download and install' })}
228+
</Button>
229+
</div>
230+
)}
231+
{updateState.kind === 'downloading' && (
232+
<div className="text-text-secondary">
233+
{t('settings.updateDownloading', { defaultValue: 'Downloading update…' })}
234+
{updateState.total && updateState.total > 0 && (
235+
<span>
236+
{' '}
237+
{Math.round((updateState.downloaded / updateState.total) * 100)}%
238+
</span>
239+
)}
240+
</div>
241+
)}
242+
{updateState.kind === 'ready-to-restart' && (
243+
<div className="rounded-md border border-status-info/40 bg-status-info/10 px-3 py-2">
244+
<div className="text-status-info mb-2">
245+
{t('settings.updateReady', {
246+
defaultValue: 'Update installed. Restart to apply.',
247+
})}
248+
</div>
249+
<Button variant="primary" size="sm" onClick={handleRelaunch}>
250+
{t('settings.updateRestart', { defaultValue: 'Restart now' })}
251+
</Button>
252+
</div>
253+
)}
254+
{updateState.kind === 'error' && (
255+
<div className="rounded-md border border-error-border bg-error-subtle px-3 py-2 text-left">
256+
<div className="font-medium text-error-text mb-1">
257+
{t('settings.updateErrorTitle', { defaultValue: 'Update check failed' })}
258+
</div>
259+
<div className="text-error-text text-xs break-words">{updateState.detail}</div>
260+
</div>
261+
)}
262+
</div>
263+
</div>
264+
</CardBody>
265+
</Card>
266+
267+
{/* GPL compliance — Third-party licenses modal. FFmpeg is the
268+
load-bearing GPL dep (we ship the BtbN GPL build for hardware
269+
encoders on Windows / Linux, and evermeet's GPL build on
270+
macOS). The modal displays the bundled version + a link to
271+
the matching source tarball attached to the GitHub release. */}
272+
<Modal
273+
open={licensesModalOpen}
274+
onClose={() => setLicensesModalOpen(false)}
275+
title={t('settings.thirdPartyLicensesTitle', { defaultValue: 'Third-party licenses' })}
276+
footer={
277+
<Button variant="ghost" onClick={() => setLicensesModalOpen(false)}>
278+
{t('common.close', { defaultValue: 'Close' })}
279+
</Button>
280+
}
281+
>
282+
<div className="flex flex-col gap-4 text-sm">
283+
<p className="text-text-secondary">
284+
{t('settings.thirdPartyLicensesIntro', {
285+
defaultValue:
286+
'SpiritStream bundles the following third-party software. License notices and corresponding source code are linked below.',
287+
})}
288+
</p>
289+
<div className="rounded-md border border-border-subtle p-3">
290+
<div className="flex items-baseline justify-between gap-2">
291+
<div className="font-medium">FFmpeg</div>
292+
<div className="text-xs text-text-tertiary">GPL v2 or later</div>
293+
</div>
294+
<div className="text-xs text-text-tertiary mt-1">
295+
{t('settings.thirdPartyLicensesFfmpegBundled', {
296+
defaultValue: 'Bundled with this build of SpiritStream.',
297+
})}
298+
</div>
299+
<ul className="mt-3 text-xs flex flex-col gap-1">
300+
<li>
301+
<a
302+
href="https://www.ffmpeg.org/legal.html"
303+
target="_blank"
304+
rel="noopener noreferrer"
305+
className="text-accent-text underline"
306+
>
307+
{t('settings.thirdPartyLicensesFfmpegLicense', {
308+
defaultValue: 'License (ffmpeg.org/legal.html)',
309+
})}
310+
</a>
311+
</li>
312+
<li>
313+
<a
314+
href={`https://github.com/ScopeCreep-zip/SpiritStream/releases/tag/v${appVersion || '0.0.0'}`}
315+
target="_blank"
316+
rel="noopener noreferrer"
317+
className="text-accent-text underline"
318+
>
319+
{t('settings.thirdPartyLicensesFfmpegSource', {
320+
defaultValue: 'Matching source tarball (attached to this release)',
321+
})}
322+
</a>
323+
</li>
324+
<li>
325+
<a
326+
href="https://www.ffmpeg.org/download.html"
327+
target="_blank"
328+
rel="noopener noreferrer"
329+
className="text-accent-text underline"
330+
>
331+
{t('settings.thirdPartyLicensesFfmpegUpstream', {
332+
defaultValue: 'Upstream binary distributors',
333+
})}
334+
</a>
335+
</li>
336+
</ul>
337+
</div>
338+
</div>
339+
</Modal>
340+
</>
341+
);
342+
}

0 commit comments

Comments
 (0)