|
| 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