Merge Batch 16 — store discipline (L142, L93, CC13, CC14)

This commit is contained in:
2026-07-02 11:10:58 -04:00
21 changed files with 816 additions and 366 deletions
+35 -23
View File
@@ -935,12 +935,22 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
([preload/index.ts](src/preload/index.ts)); the renderer callers ([store/sources.ts](src/renderer/src/store/sources.ts)) ([preload/index.ts](src/preload/index.ts)); the renderer callers ([store/sources.ts](src/renderer/src/store/sources.ts))
and the preview mock ([mockApi.ts](src/renderer/src/mockApi.ts)) updated to suit. The `Api` type derives from and the preview mock ([mockApi.ts](src/renderer/src/mockApi.ts)) updated to suit. The `Api` type derives from
the preload, so the rename is enforced end-to-end by the compiler.* the preload, so the rename is enforced end-to-end by the compiler.*
- [ ] **L93 — SettingsView bypasses the store layer.** It calls `window.api` directly for - [x] **L93 — SettingsView bypasses the store layer.** It calls `window.api` directly for
cookies/ffmpeg/yt-dlp/app-update/backup while using stores for settings/templates/errorlog — mixed cookies/ffmpeg/yt-dlp/app-update/backup while using stores for settings/templates/errorlog — mixed
data-access in one component. *Deferred (CC13's concrete instance): these one-off, card-local IPC reads data-access in one component. *Fixed (Batch 16, the CC13 view-model pass): each card's IPC + state machine
(version checks, backup export/import, cookie sign-in) have no shared state to own, so a store per call is moved into a colocated view-model hook on the `useDownloadBar` pattern —
arguably overkill — but the "no `window.api` in components" convention argues for thin hooks. Best done as [useAboutCard](src/renderer/src/components/settings/useAboutCard.ts) /
the focused CC13 view-model pass, not folded into this copy/cleanup batch.* [useBackupCard](src/renderer/src/components/settings/useBackupCard.ts) (reloads the settings/templates
stores via new store `reload()` actions after a restore) /
[useCookiesCard](src/renderer/src/components/settings/useCookiesCard.ts) /
[useSoftwareUpdateCard](src/renderer/src/components/settings/useSoftwareUpdateCard.ts) /
[useNetworkCard](src/renderer/src/components/settings/useNetworkCard.ts) (PO-token mint); AppearanceCard's
one-liner became a settings-store action (`openHighContrastSettings`). The sweep also cleared the two
non-settings components: TerminalView's run/stream/stop →
[useTerminalRun](src/renderer/src/components/useTerminalRun.ts), and LibraryView's scheduled-sync IPC →
sources-store state/actions (`scheduledSyncEnabled`/`loadScheduledSync`/`setScheduledSync`/
`commitSyncTime`). No `window.api` remains in any component (`useDownloadBar` is itself the view-model
layer, per CC13).*
- [x] **L94 — `summarizeQueue` recomputed every render** in [DownloadsView.tsx](src/renderer/src/components/DownloadsView.tsx) - [x] **L94 — `summarizeQueue` recomputed every render** in [DownloadsView.tsx](src/renderer/src/components/DownloadsView.tsx)
(no `useMemo`) — and again on every store change in App's taskbar effect. Memoize/share. *Fixed: a 1-entry (no `useMemo`) — and again on every store change in App's taskbar effect. Memoize/share. *Fixed: a 1-entry
items-ref memo `queueSummaryOf` in [queueStats.ts](src/renderer/src/store/queueStats.ts) — the store hands items-ref memo `queueSummaryOf` in [queueStats.ts](src/renderer/src/store/queueStats.ts) — the store hands
@@ -1191,14 +1201,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
*Fixed in [store/history.ts](src/renderer/src/store/history.ts): `add` now `.slice(0, MAX_ENTRIES)` (500, the *Fixed in [store/history.ts](src/renderer/src/store/history.ts): `add` now `.slice(0, MAX_ENTRIES)` (500, the
same cap as main's `history.ts`) and de-dupes by `url` as well as `id`, so the optimistic in-memory list same cap as main's `history.ts`) and de-dupes by `url` as well as `id`, so the optimistic in-memory list
matches what a reload would show (also aligns with main's M35 url de-dup).* matches what a reload would show (also aligns with main's M35 url de-dup).*
- [ ] **L142 — IPC mutation return values are discarded everywhere.** history/templates/sources/settings - [x] **L142 — IPC mutation return values are discarded everywhere.** history/templates/sources/settings
IPC calls return the authoritative (validated/capped/sanitized) state, but every renderer caller does IPC calls return the authoritative (validated/capped/sanitized) state, but every renderer caller does
optimistic-only updates and ignores it — client and persisted state can silently diverge until reload (generalizes M34). optimistic-only updates and ignores it — client and persisted state can silently diverge until reload (generalizes M34).
*Deferred (targeted, own PR): settings already reconcile (M34). Extending "apply the returned authoritative *Fixed (Batch 16, the focused reconciliation PR): a keyed latest-wins reconciler
state" to history/templates/sources touches each store's mutation path and each corresponding IPC return ([lib/reconcile.ts](src/renderer/src/lib/reconcile.ts), unit-tested in
shape, and the divergence it guards against is rare (only when main caps/sanitizes differently than the [test/reconcile.test.ts](test/reconcile.test.ts)) applies main's authoritative return on every mutation —
optimistic update) and self-heals on reload. Best done as one focused reconciliation PR with each store history add/remove/removeMany/clear, template save/remove, and sources setWatched/removeSource/
verified, not folded into this UX batch. Standard already recorded under CC14.* setMediaItemDownloaded (keyed per source so overlapping completions don't suppress each other) — extending
the M34 settings pattern to all four stores. The seq guard drops a stale response when a newer call was
issued on the same collection, so an overlapped pair can't transiently roll back the newer optimistic
update; `loadSources` shares the mutations' key so a slow list read can't clobber a newer authoritative
result either.*
- [x] **L143 — No in-window "quit anyway."** With a download active (or tray mode), closing only hides; - [x] **L143 — No in-window "quit anyway."** With a download active (or tray mode), closing only hides;
quitting requires the tray menu. If the tray ever fails to create, the only exit is Task Manager quitting requires the tray menu. If the tray ever fails to create, the only exit is Task Manager
(mitigated today by the embedded fallback tray icon). (mitigated today by the embedded fallback tray icon).
@@ -2001,25 +2015,23 @@ split — but that style is unenforced and several "do the same thing two ways"
exactly the kind of sweeping move that wants its own reviewed PR, not an unattended batch. The `lib/` exactly the kind of sweeping move that wants its own reviewed PR, not an unattended batch. The `lib/`
convention is already established (both sides have one and new pure utils land there); the wholesale convention is already established (both sides have one and new pure utils land there); the wholesale
`views/`+`core/` split is the 1.x task. Standard recorded.* `views/`+`core/` split is the 1.x task. Standard recorded.*
- [ ] **CC13 — View/state boundary (the project's "MVVM").** It's React+Zustand, but the container/ - [x] **CC13 — View/state boundary (the project's "MVVM").** It's React+Zustand, but the container/
presentational split is inconsistent: orchestration lives in stores for downloads/sources yet inside the presentational split is inconsistent: orchestration lives in stores for downloads/sources yet inside the
component for DownloadBar, and SettingsView calls `window.api` directly (L93, UX1). **Standard:** stores/ component for DownloadBar, and SettingsView calls `window.api` directly (L93, UX1). **Standard:** stores/
hooks are the view-models (own orchestration + all IPC); components stay presentational; no `window.api` in components. hooks are the view-models (own orchestration + all IPC); components stay presentational; no `window.api` in components.
*Partly closed / deferred: the substantive split largely holds — downloads/sources orchestration lives in *Closed with L93 (Batch 16): the settings cards, TerminalView, and LibraryView now reach IPC only through
stores, and DownloadBar's was extracted into the `useDownloadBar` hook (H1), a view-model in all but name. colocated view-model hooks or store actions; `grep window.api src/renderer/src/components` matches only
The concrete remaining leak is SettingsView calling `window.api` directly, filed as **L93** (Batch 13); hooks (`use*.ts`). The standard holds codebase-wide: stores/hooks own orchestration + IPC, components render.*
the broader "no `window.api` in any component" sweep rides with it. No new work needed here beyond L93.* - [x] **CC14 — State ownership is unprincipled.** State lives in Zustand stores, component `useState`,
- [ ] **CC14 — State ownership is unprincipled.** State lives in Zustand stores, component `useState`,
`localStorage`, the main `electron-store` (mirrored into a renderer store), and module-level vars `localStorage`, the main `electron-store` (mirrored into a renderer store), and module-level vars
(`idCounter`, `notifiedBackground`, `active`). Persisted state is optimistic-only and never reconciled (`idCounter`, `notifiedBackground`, `active`). Persisted state is optimistic-only and never reconciled
(M34/L142); two stores form a cycle (C2). **Standard:** main owns persisted state; renderer stores mirror (M34/L142); two stores form a cycle (C2). **Standard:** main owns persisted state; renderer stores mirror
it and **apply the authoritative value the IPC call returns**; UI-only state in stores; break store cycles via a coordinator. it and **apply the authoritative value the IPC call returns**; UI-only state in stores; break store cycles via a coordinator.
*Largely closed / deferred: main owns persisted state and the renderer mirrors it; the store cycle is gone *Closed with L142 (Batch 16): every persisted-state mutation now applies main's authoritative return
(C2, via the coordinator event bus); settings now **apply the authoritative IPC return** (M34); and (history/templates/sources via the keyed reconciler; settings via M34). Main owns persisted state, the
`localStorage` was folded into the settings store (M19). The one remaining "optimistic-only, never renderer mirrors + reconciles, the store cycle is gone (C2), `localStorage` is folded in (M19). The
reconciled" case is the other IPC mutations (**L142**, Batch 11). The module-level vars called out module-level vars called out (`idCounter`/`notifiedBackground`/`active`) are legitimately module-scoped
(`idCounter`/`notifiedBackground`/`active`) are legitimately module-scoped singletons, not misplaced UI singletons, not misplaced UI state.*
state. Closes with L142.*
### Recommended single style (apply project-wide) ### Recommended single style (apply project-wide)
+10 -17
View File
@@ -26,12 +26,10 @@ import {
DismissRegular DismissRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { MediaItem, Source, MediaKind } from '@shared/ipc' import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { useSources } from '../store/sources' import { useSources } from '../store/sources'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { useNav } from '../store/nav' import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink' import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { logError } from '../reportError'
import { useDownloads, type DownloadStatus } from '../store/downloads' import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb' import { thumbUrl } from '../thumb'
import { relTime } from '../datetime' import { relTime } from '../datetime'
@@ -235,6 +233,10 @@ export function LibraryView(): React.JSX.Element {
const setWatched = useSources((s) => s.setWatched) const setWatched = useSources((s) => s.setWatched)
const syncWatched = useSources((s) => s.syncWatched) const syncWatched = useSources((s) => s.syncWatched)
const syncing = useSources((s) => s.syncing) const syncing = useSources((s) => s.syncing)
const scheduled = useSources((s) => s.scheduledSyncEnabled)
const loadScheduledSync = useSources((s) => s.loadScheduledSync)
const setScheduledSync = useSources((s) => s.setScheduledSync)
const commitSyncTime = useSources((s) => s.commitSyncTime)
const downloadItems = useDownloads((s) => s.items) const downloadItems = useDownloads((s) => s.items)
const autoDownloadNew = useSettings((s) => s.autoDownloadNew) const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
const syncTime = useSettings((s) => s.syncTime) const syncTime = useSettings((s) => s.syncTime)
@@ -273,18 +275,13 @@ export function LibraryView(): React.JSX.Element {
// Which playlist groups are expanded. Empty = all collapsed (the default), so // Which playlist groups are expanded. Empty = all collapsed (the default), so
// an expanded source shows just its group headers until one is opened. // an expanded source shows just its group headers until one is opened.
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()) const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const [scheduled, setScheduled] = useState(false)
const watchedCount = sources.filter((s) => s.watched).length const watchedCount = sources.filter((s) => s.watched).length
// Load the current scheduled-sync (Task Scheduler) state once. // Load the current scheduled-sync (Task Scheduler) state once.
useEffect(() => { useEffect(() => {
if (PREVIEW) return loadScheduledSync()
window.api }, [loadScheduledSync])
.getScheduledSync()
.then((s) => setScheduled(s.enabled))
.catch(logError('getScheduledSync'))
}, [])
async function onCheckNew(): Promise<void> { async function onCheckNew(): Promise<void> {
setSyncNote(null) setSyncNote(null)
@@ -293,11 +290,8 @@ export function LibraryView(): React.JSX.Element {
} }
async function toggleScheduled(next: boolean): Promise<void> { async function toggleScheduled(next: boolean): Promise<void> {
setScheduled(next) // optimistic const err = await setScheduledSync(next)
if (PREVIEW) return if (err) setError(err)
const res = await window.api.setScheduledSync(next, syncTime)
setScheduled(res.enabled)
if (res.error) setError(res.error)
} }
// The native time input emits a complete 'HH:MM' or '' (cleared/partial) — only // The native time input emits a complete 'HH:MM' or '' (cleared/partial) — only
@@ -310,9 +304,8 @@ export function LibraryView(): React.JSX.Element {
// Re-register the task with the new time once editing settles (blur), so typing // Re-register the task with the new time once editing settles (blur), so typing
// through the segments doesn't spawn schtasks per keystroke (L51). // through the segments doesn't spawn schtasks per keystroke (L51).
async function onSyncTimeCommit(): Promise<void> { async function onSyncTimeCommit(): Promise<void> {
if (!scheduled || PREVIEW) return const err = await commitSyncTime()
const res = await window.api.setScheduledSync(true, useSettings.getState().syncTime) if (err) setError(err)
if (res.error) setError(res.error)
} }
// Reset the selection (and any batch note) whenever the expanded source changes. // Reset the selection (and any batch note) whenever the expanded source changes.
+5 -70
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef } from 'react'
import { import {
Button, Button,
Textarea, Textarea,
@@ -11,24 +11,10 @@ import {
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons' import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { newId } from '../id'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState' import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens' import { SPACE } from './ui/tokens'
import { useTerminalRun, type LineKind } from './useTerminalRun'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
id: number
text: string
kind: LineKind
}
let lineSeq = 0
const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, kind })
// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
// lines in seconds. Cap the visible log to prevent unbounded React state growth
// and keep the <pre> scrollable (H6); older lines are dropped from the front.
const MAX_LOG_LINES = 2000
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' }, root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
@@ -81,66 +67,15 @@ export function TerminalView(): React.JSX.Element {
const customCommandEnabled = useSettings((s) => s.customCommandEnabled) const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const updateSettings = useSettings((s) => s.update) const updateSettings = useSettings((s) => s.update)
const [args, setArgs] = useState('') // View-model hook owns the run/stream/stop orchestration + IPC (L93/CC13).
const [lines, setLines] = useState<Line[]>([]) const { args, setArgs, lines, running, run, stop, clearLines } = useTerminalRun()
const [running, setRunning] = useState(false)
const runId = useRef<string | null>(null)
const logRef = useRef<HTMLPreElement>(null) const logRef = useRef<HTMLPreElement>(null)
// Stream terminal output for the current run into the log.
useEffect(
() =>
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
if (ev.type === 'output') {
setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
} else if (ev.type === 'error') {
setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
setRunning(false)
runId.current = null
} else {
setLines((ls) =>
[...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
)
setRunning(false)
runId.current = null
}
}),
[]
)
// Keep the log pinned to the latest output. // Keep the log pinned to the latest output.
useEffect(() => { useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [lines]) }, [lines])
function run(): void {
const a = args.trim()
if (!a || running) return
const id = newId('t')
runId.current = id
setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
setRunning(true)
window.api
.runTerminal(id, a)
.then((res) => {
if (!res.ok) {
setLines((ls) => [...ls, mkLine(res.error ?? 'Failed to start.', 'stderr')])
setRunning(false)
runId.current = null
}
})
.catch((e: unknown) => {
setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
setRunning(false)
runId.current = null
})
}
function stop(): void {
if (runId.current) window.api.cancelTerminal(runId.current)
}
const lineClass = (kind: LineKind): string | undefined => const lineClass = (kind: LineKind): string | undefined =>
kind === 'cmd' kind === 'cmd'
? styles.cmd ? styles.cmd
@@ -214,7 +149,7 @@ export function TerminalView(): React.JSX.Element {
<Button <Button
appearance="subtle" appearance="subtle"
icon={<DeleteRegular />} icon={<DeleteRegular />}
onClick={() => setLines([])} onClick={clearLines}
disabled={lines.length === 0} disabled={lines.length === 0}
aria-label="Clear output" aria-label="Clear output"
/> />
@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react'
import { import {
Field, Field,
Switch, Switch,
@@ -10,17 +9,12 @@ import {
Spinner Spinner
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons' import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import { import { type YtdlpUpdateChannel } from '@shared/ipc'
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type FfmpegVersionResult
} from '@shared/ipc'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { Select } from '../Select' import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../ui/errorText'
import { logError } from '../../reportError'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
import { useAboutCard } from './useAboutCard'
const UPDATE_CHANNEL_OPTIONS = [ const UPDATE_CHANNEL_OPTIONS = [
{ value: 'stable', label: 'Stable' }, { value: 'stable', label: 'Stable' },
@@ -35,59 +29,17 @@ export function AboutCard(): React.JSX.Element {
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck) const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
const update = useSettings((s) => s.update) const update = useSettings((s) => s.update)
const [checking, setChecking] = useState(false) // View-model hook owns the version/update state + IPC (L93/CC13).
const [version, setVersion] = useState<YtdlpVersionResult | null>(null) const {
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null) checking,
const [updating, setUpdating] = useState(false) version,
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null) ffmpeg,
updating,
useEffect(() => { updateResult,
// Show the current yt-dlp version without a manual click, and reflect a checkVersion,
// background auto-update (which may run on launch) live in this panel. runUpdate,
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion')) refreshFfmpeg
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once. } = useAboutCard()
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
return window.api.onYtdlpAutoUpdateStatus((s) => {
if (s.phase === 'checking') {
setChecking(true)
return
}
setChecking(false)
if (s.version) setVersion({ ok: true, version: s.version })
if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
if (s.phase === 'updated') {
setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
} else if (s.phase === 'error' && s.error) {
setUpdateResult({ ok: false, error: s.error })
}
})
}, [])
async function checkVersion(): Promise<void> {
setChecking(true)
setVersion(null)
try {
setVersion(await window.api.getYtdlpVersion())
} catch (e) {
setVersion({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setChecking(false)
}
}
async function runUpdate(): Promise<void> {
setUpdating(true)
setUpdateResult(null)
try {
const result = await window.api.updateYtdlp(ytdlpChannel)
setUpdateResult(result)
if (result.ok) setVersion(null) // stale -- prompt a re-check rather than show a wrong version
} catch (e) {
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setUpdating(false)
}
}
return ( return (
<Card className={styles.card}> <Card className={styles.card}>
@@ -127,9 +79,7 @@ export function AboutCard(): React.JSX.Element {
<Button <Button
appearance="subtle" appearance="subtle"
icon={<ArrowSyncRegular />} icon={<ArrowSyncRegular />}
onClick={() => onClick={refreshFfmpeg}
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
}
aria-label="Re-check ffmpeg versions" aria-label="Re-check ffmpeg versions"
/> />
</div> </div>
@@ -22,6 +22,8 @@ export function AppearanceCard(): React.JSX.Element {
const accentColor = useSettings((s) => s.accentColor) const accentColor = useSettings((s) => s.accentColor)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors) const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
const update = useSettings((s) => s.update) const update = useSettings((s) => s.update)
// Store action wraps the IPC call (L93/CC13: no window.api in components).
const openHighContrastSettings = useSettings((s) => s.openHighContrastSettings)
// Roving-tabindex arrow-key navigation for the accent radiogroup (L155), so the // Roving-tabindex arrow-key navigation for the accent radiogroup (L155), so the
// single-select swatches behave like the Theme radio pattern rather than a row // single-select swatches behave like the Theme radio pattern rather than a row
@@ -96,11 +98,7 @@ export function AppearanceCard(): React.JSX.Element {
</Caption1> </Caption1>
{highContrast && ( {highContrast && (
<div className={styles.folderRow}> <div className={styles.folderRow}>
<Button <Button size="small" icon={<AccessibilityRegular />} onClick={openHighContrastSettings}>
size="small"
icon={<AccessibilityRegular />}
onClick={() => window.api.openHighContrastSettings()}
>
Open accessibility settings Open accessibility settings
</Button> </Button>
</div> </div>
@@ -1,51 +1,16 @@
import { useState } from 'react'
import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components' import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons' import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons'
import { type BackupExportResult, type BackupImportResult } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useTemplates } from '../../store/templates'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../ui/errorText'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
import { useBackupCard } from './useBackupCard'
export function BackupCard(): React.JSX.Element { export function BackupCard(): React.JSX.Element {
const styles = useSettingsStyles() const styles = useSettingsStyles()
const errText = useErrorTextStyles() const errText = useErrorTextStyles()
const [exporting, setExporting] = useState(false) // View-model hook owns the export/import state + IPC (L93/CC13).
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null) const { exporting, exportResult, importing, importResult, exportBackup, importBackup } =
const [importing, setImporting] = useState(false) useBackupCard()
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
async function exportBackup(): Promise<void> {
setExporting(true)
setExportResult(null)
try {
setExportResult(await window.api.exportBackup())
} catch (e) {
setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setExporting(false)
}
}
async function importBackup(): Promise<void> {
setImporting(true)
setImportResult(null)
try {
const result = await window.api.importBackup()
setImportResult(result)
if (result.ok) {
// Settings + templates changed underneath the stores -- reload both.
const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
useSettings.setState({ ...s, loaded: true })
useTemplates.setState({ templates: t })
}
} catch (e) {
setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setImporting(false)
}
}
return ( return (
<Card className={styles.card}> <Card className={styles.card}>
@@ -1,16 +1,11 @@
import { useEffect, useState } from 'react'
import { Field, Input, Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components' import { Field, Input, Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { CookiesRegular } from '@fluentui/react-icons' import { CookiesRegular } from '@fluentui/react-icons'
import { import { COOKIE_BROWSERS, type CookieSource, type CookieBrowser } from '@shared/ipc'
COOKIE_BROWSERS,
type CookieSource,
type CookieBrowser,
type CookiesStatus
} from '@shared/ipc'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { Select } from '../Select' import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../ui/errorText'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
import { useCookiesCard } from './useCookiesCard'
const COOKIE_SOURCE_OPTIONS = [ const COOKIE_SOURCE_OPTIONS = [
{ value: 'none', label: 'None' }, { value: 'none', label: 'None' },
@@ -30,39 +25,9 @@ export function CookiesCard(): React.JSX.Element {
const cookiesBrowser = useSettings((s) => s.cookiesBrowser) const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const update = useSettings((s) => s.update) const update = useSettings((s) => s.update)
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null) // View-model hook owns the cookies status + sign-in orchestration (L93/CC13).
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com') const { cookiesStatus, loginUrl, setLoginUrl, signingIn, loginError, signIn, clearSavedCookies } =
const [signingIn, setSigningIn] = useState(false) useCookiesCard()
const [loginError, setLoginError] = useState<string | null>(null)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
async function signIn(): Promise<void> {
setSigningIn(true)
setLoginError(null)
try {
const result = await window.api.cookiesLogin(loginUrl)
if (result.ok && result.cookieCount === 0) {
// Window closed without capturing anything -- don't imply success (L50).
setLoginError('No cookies were captured -- did you sign in before closing the window?')
} else if (result.ok) {
setCookiesStatus(await window.api.cookiesStatus())
} else {
setLoginError(result.error ?? 'Sign-in failed.')
}
} catch (e) {
setLoginError(e instanceof Error ? e.message : String(e))
} finally {
setSigningIn(false)
}
}
async function clearSavedCookies(): Promise<void> {
await window.api.cookiesClear()
setCookiesStatus({ exists: false })
}
return ( return (
<Card className={styles.card}> <Card className={styles.card}>
@@ -1,4 +1,3 @@
import { useState } from 'react'
import { import {
Field, Field,
Input, Input,
@@ -12,6 +11,7 @@ import {
import { GlobeRegular } from '@fluentui/react-icons' import { GlobeRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
import { useNetworkCard } from './useNetworkCard'
export function NetworkCard(): React.JSX.Element { export function NetworkCard(): React.JSX.Element {
const styles = useSettingsStyles() const styles = useSettingsStyles()
@@ -23,8 +23,8 @@ export function NetworkCard(): React.JSX.Element {
const youtubePoToken = useSettings((s) => s.youtubePoToken) const youtubePoToken = useSettings((s) => s.youtubePoToken)
const update = useSettings((s) => s.update) const update = useSettings((s) => s.update)
const [mintingPot, setMintingPot] = useState(false) // View-model hook owns the PO-token minting state + IPC (L93/CC13).
const [potHint, setPotHint] = useState<string | null>(null) const { mintingPot, potHint, clearPotHint, mintPoToken } = useNetworkCard()
return ( return (
<Card className={styles.card}> <Card className={styles.card}>
@@ -114,29 +114,14 @@ export function NetworkCard(): React.JSX.Element {
placeholder="Optional -- paste a token" placeholder="Optional -- paste a token"
onChange={(_, d) => { onChange={(_, d) => {
update({ youtubePoToken: d.value }) update({ youtubePoToken: d.value })
setPotHint(null) clearPotHint()
}} }}
/> />
<Button <Button
size="small" size="small"
disabled={mintingPot} disabled={mintingPot}
icon={mintingPot ? <Spinner size="tiny" /> : undefined} icon={mintingPot ? <Spinner size="tiny" /> : undefined}
onClick={async () => { onClick={mintPoToken}
setMintingPot(true)
setPotHint(null)
try {
const token = await window.api.mintPoToken()
if (token) {
setPotHint('Token saved.')
} else {
setPotHint('Token not found -- try signing into YouTube first via Cookies above.')
}
} catch {
setPotHint('Failed to open YouTube window.')
} finally {
setMintingPot(false)
}
}}
> >
{mintingPot ? 'Fetching…' : 'Fetch'} {mintingPot ? 'Fetching…' : 'Fetch'}
</Button> </Button>
@@ -1,4 +1,3 @@
import { useEffect, useRef, useState } from 'react'
import { import {
Field, Field,
Input, Input,
@@ -17,11 +16,10 @@ import {
OpenRegular, OpenRegular,
DismissRegular DismissRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import { type AppUpdateInfo } from '@shared/ipc'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../ui/errorText'
import { logError } from '../../reportError'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
export function SoftwareUpdateCard(): React.JSX.Element { export function SoftwareUpdateCard(): React.JSX.Element {
const styles = useSettingsStyles() const styles = useSettingsStyles()
@@ -29,67 +27,19 @@ export function SoftwareUpdateCard(): React.JSX.Element {
const updateToken = useSettings((s) => s.updateToken) const updateToken = useSettings((s) => s.updateToken)
const update = useSettings((s) => s.update) const update = useSettings((s) => s.update)
const [appVersion, setAppVersion] = useState('') // View-model hook owns the check → download → install orchestration (L93/CC13).
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null) const {
const [appChecking, setAppChecking] = useState(false) appVersion,
const [appDownloading, setAppDownloading] = useState(false) appUpd,
const [appFraction, setAppFraction] = useState<number | undefined>(undefined) appChecking,
const [appUpdError, setAppUpdError] = useState<string | null>(null) appDownloading,
// Set when the user cancels the download, so the awaited result (which comes back appFraction,
// not-ok) is treated as canceled rather than surfaced as an error (B6). appUpdError,
const canceledRef = useRef(false) checkAppUpdate,
installAppUpdate,
useEffect(() => { cancelDownload,
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion')) openReleasePage
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction)) } = useSoftwareUpdateCard()
}, [])
async function checkAppUpdate(): Promise<void> {
setAppChecking(true)
setAppUpd(null)
setAppUpdError(null)
try {
const info = await window.api.checkForAppUpdate()
// Funnel a failed check into the single error slot rather than storing a
// second not-ok AppUpdateInfo, so only one error message can ever render.
if (info.ok) setAppUpd(info)
else setAppUpdError(info.error ?? 'Update check failed.')
} catch (e) {
setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppChecking(false)
}
}
async function installAppUpdate(): Promise<void> {
if (!appUpd?.downloadUrl) return
canceledRef.current = false
setAppDownloading(true)
setAppFraction(undefined)
setAppUpdError(null)
try {
const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
// The user hit Cancel: main aborted the download and cleaned up the partial —
// don't surface its not-ok result as an error or try to install (B6).
if (canceledRef.current) return
if (!dl.ok || !dl.filePath) {
setAppUpdError(dl.error ?? 'Download failed.')
return
}
const run = await window.api.runAppUpdate(dl.filePath)
// On success the app quits as the installer launches; only errors return here.
if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
} catch (e) {
if (!canceledRef.current) setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppDownloading(false)
}
}
function cancelDownload(): void {
canceledRef.current = true
window.api.cancelAppUpdate?.().catch(logError('cancelAppUpdate'))
}
return ( return (
<Card className={styles.card}> <Card className={styles.card}>
@@ -144,10 +94,7 @@ export function SoftwareUpdateCard(): React.JSX.Element {
{appDownloading ? 'Downloading…' : 'Update now'} {appDownloading ? 'Downloading…' : 'Update now'}
</Button> </Button>
{appUpd.htmlUrl && ( {appUpd.htmlUrl && (
<Button <Button icon={<OpenRegular />} onClick={openReleasePage}>
icon={<OpenRegular />}
onClick={() => void window.api?.openUrl?.(appUpd.htmlUrl ?? '')}
>
View release View release
</Button> </Button>
)} )}
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
import type { YtdlpVersionResult, YtdlpUpdateResult, FfmpegVersionResult } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { logError } from '../../reportError'
export interface AboutCardController {
checking: boolean
version: YtdlpVersionResult | null
ffmpeg: FfmpegVersionResult | null
updating: boolean
updateResult: YtdlpUpdateResult | null
checkVersion: () => Promise<void>
runUpdate: () => Promise<void>
refreshFfmpeg: () => void
}
/**
* View-model for the About card (L93/CC13): owns the yt-dlp/ffmpeg version state
* and the update orchestration so the card stays presentational — no `window.api`
* in the component (the `useDownloadBar` pattern).
*/
export function useAboutCard(): AboutCardController {
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
useEffect(() => {
// Show the current yt-dlp version without a manual click, and reflect a
// background auto-update (which may run on launch) live in this panel.
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
return window.api.onYtdlpAutoUpdateStatus((s) => {
if (s.phase === 'checking') {
setChecking(true)
return
}
setChecking(false)
if (s.version) setVersion({ ok: true, version: s.version })
if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
if (s.phase === 'updated') {
setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
} else if (s.phase === 'error' && s.error) {
setUpdateResult({ ok: false, error: s.error })
}
})
}, [])
async function checkVersion(): Promise<void> {
setChecking(true)
setVersion(null)
try {
setVersion(await window.api.getYtdlpVersion())
} catch (e) {
setVersion({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setChecking(false)
}
}
async function runUpdate(): Promise<void> {
setUpdating(true)
setUpdateResult(null)
try {
const result = await window.api.updateYtdlp(ytdlpChannel)
setUpdateResult(result)
if (result.ok) setVersion(null) // stale -- prompt a re-check rather than show a wrong version
} catch (e) {
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setUpdating(false)
}
}
function refreshFfmpeg(): void {
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
}
return {
checking,
version,
ffmpeg,
updating,
updateResult,
checkVersion,
runUpdate,
refreshFfmpeg
}
}
@@ -0,0 +1,57 @@
import { useState } from 'react'
import type { BackupExportResult, BackupImportResult } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useTemplates } from '../../store/templates'
export interface BackupCardController {
exporting: boolean
exportResult: BackupExportResult | null
importing: boolean
importResult: BackupImportResult | null
exportBackup: () => Promise<void>
importBackup: () => Promise<void>
}
/**
* View-model for the Backup & restore card (L93/CC13): owns the export/import
* orchestration — including reloading the settings/templates stores after a
* restore changes both underneath — so the card stays presentational.
*/
export function useBackupCard(): BackupCardController {
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
async function exportBackup(): Promise<void> {
setExporting(true)
setExportResult(null)
try {
setExportResult(await window.api.exportBackup())
} catch (e) {
setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setExporting(false)
}
}
async function importBackup(): Promise<void> {
setImporting(true)
setImportResult(null)
try {
const result = await window.api.importBackup()
setImportResult(result)
if (result.ok) {
// Settings + templates changed underneath the stores -- reload both.
useSettings.getState().reload()
useTemplates.getState().reload()
}
} catch (e) {
setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setImporting(false)
}
}
return { exporting, exportResult, importing, importResult, exportBackup, importBackup }
}
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react'
import type { CookiesStatus } from '@shared/ipc'
import { logError } from '../../reportError'
export interface CookiesCardController {
cookiesStatus: CookiesStatus | null
loginUrl: string
setLoginUrl: (url: string) => void
signingIn: boolean
loginError: string | null
signIn: () => Promise<void>
clearSavedCookies: () => Promise<void>
}
/**
* View-model for the Cookies card (L93/CC13): owns the saved-cookies status and
* the sign-in-window orchestration so the card stays presentational.
*/
export function useCookiesCard(): CookiesCardController {
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
const [signingIn, setSigningIn] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus).catch(logError('cookiesStatus'))
}, [])
async function signIn(): Promise<void> {
setSigningIn(true)
setLoginError(null)
try {
const result = await window.api.cookiesLogin(loginUrl)
if (result.ok && result.cookieCount === 0) {
// Window closed without capturing anything -- don't imply success (L50).
setLoginError('No cookies were captured -- did you sign in before closing the window?')
} else if (result.ok) {
setCookiesStatus(await window.api.cookiesStatus())
} else {
setLoginError(result.error ?? 'Sign-in failed.')
}
} catch (e) {
setLoginError(e instanceof Error ? e.message : String(e))
} finally {
setSigningIn(false)
}
}
async function clearSavedCookies(): Promise<void> {
await window.api.cookiesClear()
setCookiesStatus({ exists: false })
}
return { cookiesStatus, loginUrl, setLoginUrl, signingIn, loginError, signIn, clearSavedCookies }
}
@@ -0,0 +1,36 @@
import { useState } from 'react'
export interface NetworkCardController {
mintingPot: boolean
potHint: string | null
clearPotHint: () => void
mintPoToken: () => Promise<void>
}
/**
* View-model for the Network card's PO-token minting (L93/CC13): owns the fetch
* state and hint copy so the card stays presentational.
*/
export function useNetworkCard(): NetworkCardController {
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
async function mintPoToken(): Promise<void> {
setMintingPot(true)
setPotHint(null)
try {
const token = await window.api.mintPoToken()
if (token) {
setPotHint('Token saved.')
} else {
setPotHint('Token not found -- try signing into YouTube first via Cookies above.')
}
} catch {
setPotHint('Failed to open YouTube window.')
} finally {
setMintingPot(false)
}
}
return { mintingPot, potHint, clearPotHint: () => setPotHint(null), mintPoToken }
}
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react'
import type { AppUpdateInfo } from '@shared/ipc'
import { logError } from '../../reportError'
export interface SoftwareUpdateCardController {
appVersion: string
appUpd: AppUpdateInfo | null
appChecking: boolean
appDownloading: boolean
appFraction: number | undefined
appUpdError: string | null
checkAppUpdate: () => Promise<void>
installAppUpdate: () => Promise<void>
cancelDownload: () => void
openReleasePage: () => void
}
/**
* View-model for the Software update card (L93/CC13): owns the check → download
* → run-installer orchestration (including the cancel race, B6) so the card
* stays presentational.
*/
export function useSoftwareUpdateCard(): SoftwareUpdateCardController {
const [appVersion, setAppVersion] = useState('')
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null)
const [appChecking, setAppChecking] = useState(false)
const [appDownloading, setAppDownloading] = useState(false)
const [appFraction, setAppFraction] = useState<number | undefined>(undefined)
const [appUpdError, setAppUpdError] = useState<string | null>(null)
// Set when the user cancels the download, so the awaited result (which comes back
// not-ok) is treated as canceled rather than surfaced as an error (B6).
const canceledRef = useRef(false)
useEffect(() => {
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
}, [])
async function checkAppUpdate(): Promise<void> {
setAppChecking(true)
setAppUpd(null)
setAppUpdError(null)
try {
const info = await window.api.checkForAppUpdate()
// Funnel a failed check into the single error slot rather than storing a
// second not-ok AppUpdateInfo, so only one error message can ever render.
if (info.ok) setAppUpd(info)
else setAppUpdError(info.error ?? 'Update check failed.')
} catch (e) {
setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppChecking(false)
}
}
async function installAppUpdate(): Promise<void> {
if (!appUpd?.downloadUrl) return
canceledRef.current = false
setAppDownloading(true)
setAppFraction(undefined)
setAppUpdError(null)
try {
const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
// The user hit Cancel: main aborted the download and cleaned up the partial —
// don't surface its not-ok result as an error or try to install (B6).
if (canceledRef.current) return
if (!dl.ok || !dl.filePath) {
setAppUpdError(dl.error ?? 'Download failed.')
return
}
const run = await window.api.runAppUpdate(dl.filePath)
// On success the app quits as the installer launches; only errors return here.
if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
} catch (e) {
if (!canceledRef.current) setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppDownloading(false)
}
}
function cancelDownload(): void {
canceledRef.current = true
window.api.cancelAppUpdate?.().catch(logError('cancelAppUpdate'))
}
function openReleasePage(): void {
void window.api?.openUrl?.(appUpd?.htmlUrl ?? '')
}
return {
appVersion,
appUpd,
appChecking,
appDownloading,
appFraction,
appUpdError,
checkAppUpdate,
installAppUpdate,
cancelDownload,
openReleasePage
}
}
@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from 'react'
import { newId } from '../id'
export type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
export interface Line {
id: number
text: string
kind: LineKind
}
let lineSeq = 0
const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, kind })
// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
// lines in seconds. Cap the visible log to prevent unbounded React state growth
// and keep the <pre> scrollable (H6); older lines are dropped from the front.
const MAX_LOG_LINES = 2000
export interface TerminalRunController {
args: string
setArgs: (args: string) => void
lines: Line[]
running: boolean
run: () => void
stop: () => void
clearLines: () => void
}
/**
* View-model for the built-in yt-dlp terminal (L93/CC13): owns the run/stream/
* stop orchestration and the capped output log so TerminalView stays
* presentational — no `window.api` in the component.
*/
export function useTerminalRun(): TerminalRunController {
const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([])
const [running, setRunning] = useState(false)
const runId = useRef<string | null>(null)
// Stream terminal output for the current run into the log.
useEffect(
() =>
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
if (ev.type === 'output') {
setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
} else if (ev.type === 'error') {
setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
setRunning(false)
runId.current = null
} else {
setLines((ls) =>
[...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
)
setRunning(false)
runId.current = null
}
}),
[]
)
function run(): void {
const a = args.trim()
if (!a || running) return
const id = newId('t')
runId.current = id
setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
setRunning(true)
window.api
.runTerminal(id, a)
.then((res) => {
if (!res.ok) {
setLines((ls) => [...ls, mkLine(res.error ?? 'Failed to start.', 'stderr')])
setRunning(false)
runId.current = null
}
})
.catch((e: unknown) => {
setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
setRunning(false)
runId.current = null
})
}
function stop(): void {
if (runId.current) window.api.cancelTerminal(runId.current)
}
return { args, setArgs, lines, running, run, stop, clearLines: () => setLines([]) }
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Latest-wins reconciliation for optimistic store mutations (L142).
*
* The main-process mutation handlers (history/templates/sources) return the
* authoritative persisted state — validated, capped, sanitized — but the stores
* update optimistically first, so applying every response verbatim can briefly
* roll back a newer optimistic update when calls overlap (add A, add B, response
* A arrives without B). Each mutation therefore registers under a key; a
* response is applied only if no newer call on that key has been issued since.
* Responses for one key resolve in invoke order (main handles them
* sequentially), so the applied response is always the newest state.
*/
export type Reconcile = <T>(
key: string,
call: Promise<T>,
apply: (value: T) => void,
onError: (e: unknown) => void
) => void
export function createReconciler(): Reconcile {
const latest = new Map<string, number>()
return (key, call, apply, onError) => {
const seq = (latest.get(key) ?? 0) + 1
latest.set(key, seq)
call.then((value) => {
if (latest.get(key) === seq) apply(value)
}, onError)
}
}
+34 -4
View File
@@ -3,6 +3,7 @@ import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview' import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError' import { logError } from '../reportError'
import { revealFile } from '../reveal' import { revealFile } from '../reveal'
import { createReconciler } from '../lib/reconcile'
// Mock history so the History tab has content during UI design. Gated on // Mock history so the History tab has content during UI design. Gated on
// `import.meta.env.DEV` too so Rollup drops it from the production build (L128). // `import.meta.env.DEV` too so Rollup drops it from the production build (L128).
@@ -59,6 +60,11 @@ interface HistoryState {
showInFolder: (id: string) => void showInFolder: (id: string) => void
} }
// Every mutation applies main's authoritative return (validated/capped/de-duped)
// through a latest-wins reconciler, so the optimistic list can't silently diverge
// from what a reload would show (L142, the M34 pattern extended off settings).
const reconcile = createReconciler()
export const useHistory = create<HistoryState>((set, get) => ({ export const useHistory = create<HistoryState>((set, get) => ({
entries: seed, entries: seed,
@@ -69,23 +75,47 @@ export const useHistory = create<HistoryState>((set, get) => ({
HISTORY_MAX_ENTRIES HISTORY_MAX_ENTRIES
) )
})) }))
if (!PREVIEW) window.api.addHistory(entry).catch(logError('addHistory')) if (!PREVIEW)
reconcile(
'entries',
window.api.addHistory(entry),
(entries) => set({ entries }),
logError('addHistory')
)
}, },
remove: (id) => { remove: (id) => {
set((s) => ({ entries: s.entries.filter((e) => e.id !== id) })) set((s) => ({ entries: s.entries.filter((e) => e.id !== id) }))
if (!PREVIEW) window.api.removeHistory(id).catch(logError('removeHistory')) if (!PREVIEW)
reconcile(
'entries',
window.api.removeHistory(id),
(entries) => set({ entries }),
logError('removeHistory')
)
}, },
removeMany: (ids) => { removeMany: (ids) => {
const remove = new Set(ids) const remove = new Set(ids)
set((s) => ({ entries: s.entries.filter((e) => !remove.has(e.id)) })) set((s) => ({ entries: s.entries.filter((e) => !remove.has(e.id)) }))
if (!PREVIEW) window.api.removeManyHistory(ids).catch(logError('removeManyHistory')) if (!PREVIEW)
reconcile(
'entries',
window.api.removeManyHistory(ids),
(entries) => set({ entries }),
logError('removeManyHistory')
)
}, },
clear: () => { clear: () => {
set({ entries: [] }) set({ entries: [] })
if (!PREVIEW) window.api.clearHistory().catch(logError('clearHistory')) if (!PREVIEW)
reconcile(
'entries',
window.api.clearHistory(),
(entries) => set({ entries }),
logError('clearHistory')
)
}, },
openFile: (id) => { openFile: (id) => {
+18 -4
View File
@@ -22,10 +22,14 @@ interface SettingsState extends Settings {
/** true once persisted settings have loaded (always true in preview) */ /** true once persisted settings have loaded (always true in preview) */
loaded: boolean loaded: boolean
update: (partial: Partial<Settings>) => void update: (partial: Partial<Settings>) => void
/** re-fetch persisted settings from main (a backup restore changes them underneath) */
reload: () => void
/** open the OS folder picker and store the result as the video or audio folder */ /** open the OS folder picker and store the result as the video or audio folder */
chooseDir: (target: 'videoDir' | 'audioDir') => void chooseDir: (target: 'videoDir' | 'audioDir') => void
/** clear a per-kind folder override, restoring its Documents\… default */ /** clear a per-kind folder override, restoring its Documents\… default */
clearDir: (target: 'videoDir' | 'audioDir') => void clearDir: (target: 'videoDir' | 'audioDir') => void
/** open the Windows High Contrast settings page (AppearanceCard) */
openHighContrastSettings: () => void
} }
export const useSettings = create<SettingsState>((set, get) => ({ export const useSettings = create<SettingsState>((set, get) => ({
@@ -50,6 +54,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
.catch(logError('setSettings')) .catch(logError('setSettings'))
}, },
reload: () => {
if (PREVIEW) return
window.api
.getSettings()
.then((s) => set({ ...s, loaded: true }))
.catch(logError('getSettings'))
},
chooseDir: (target) => { chooseDir: (target) => {
if (PREVIEW) return if (PREVIEW) return
// Seed the OS picker with the folder this target currently points at (W5). // Seed the OS picker with the folder this target currently points at (W5).
@@ -61,10 +73,12 @@ export const useSettings = create<SettingsState>((set, get) => ({
.catch(logError('chooseFolder')) .catch(logError('chooseFolder'))
}, },
clearDir: (target) => get().update({ [target]: '' }) clearDir: (target) => get().update({ [target]: '' }),
openHighContrastSettings: () => {
if (!PREVIEW) window.api.openHighContrastSettings().catch(logError('openHighContrastSettings'))
}
})) }))
// Load persisted settings on startup. // Load persisted settings on startup.
if (!PREVIEW) { if (!PREVIEW) useSettings.getState().reload()
window.api.getSettings().then((s) => useSettings.setState({ ...s, loaded: true }))
}
+76 -7
View File
@@ -4,6 +4,7 @@ import { isPreview as PREVIEW } from '../isPreview'
import { useSettings } from './settings' import { useSettings } from './settings'
import { emit, on } from './coordinator' import { emit, on } from './coordinator'
import { logError } from '../reportError' import { logError } from '../reportError'
import { createReconciler } from '../lib/reconcile'
// --- Preview seed data ------------------------------------------------------ // --- Preview seed data ------------------------------------------------------
@@ -105,12 +106,25 @@ interface SourcesState {
* videos. Resolves with how many new videos were found. * videos. Resolves with how many new videos were found.
*/ */
syncWatched: () => Promise<number> syncWatched: () => Promise<number>
/** whether the OS-scheduled daily sync task is registered (Phase J) */
scheduledSyncEnabled: boolean
/** load the Task Scheduler state (Library view mount) */
loadScheduledSync: () => void
/** register/unregister the daily sync task; resolves with an error message or null */
setScheduledSync: (enabled: boolean) => Promise<string | null>
/** re-register the task after the sync time changed; error message or null (L51) */
commitSyncTime: () => Promise<string | null>
} }
// Set by cancelIndexing() so the awaiting index/reindex call knows the user // Set by cancelIndexing() so the awaiting index/reindex call knows the user
// aborted and can resolve as canceled (no error UI) rather than as a failure (B2). // aborted and can resolve as canceled (no error UI) rather than as a failure (B2).
let indexCanceledRequested = false let indexCanceledRequested = false
// Mutations apply main's authoritative return via a latest-wins reconciler so the
// optimistic state can't diverge from disk until reload (L142). The source list
// and each source's item list are independent keys.
const reconcile = createReconciler()
export const useSources = create<SourcesState>((set, get) => ({ export const useSources = create<SourcesState>((set, get) => ({
sources: seedSources, sources: seedSources,
itemsBySource: seedItemsBySource, itemsBySource: seedItemsBySource,
@@ -120,10 +134,14 @@ export const useSources = create<SourcesState>((set, get) => ({
loadSources: () => { loadSources: () => {
if (PREVIEW) return if (PREVIEW) return
window.api // Shares the mutations' 'sources' key so a slow list response can't clobber
.listSources() // a newer setWatched/removeSource authoritative result (L142).
.then((sources) => set({ sources })) reconcile(
.catch(logError('listSources')) 'sources',
window.api.listSources(),
(sources) => set({ sources }),
logError('listSources')
)
}, },
selectSource: (id) => { selectSource: (id) => {
@@ -205,7 +223,13 @@ export const useSources = create<SourcesState>((set, get) => ({
// stops its videos from finishing into now-orphaned folders (L157). Routed via // stops its videos from finishing into now-orphaned folders (L157). Routed via
// the event bus (C2) so sources doesn't import downloads. // the event bus (C2) so sources doesn't import downloads.
emit('sourceRemoved', id) emit('sourceRemoved', id)
if (!PREVIEW) window.api.removeSource(id).catch(logError('removeSource')) if (!PREVIEW)
reconcile(
'sources',
window.api.removeSource(id),
(sources) => set({ sources }),
logError('removeSource')
)
}, },
enqueueItems: (sourceId, items, kindOverride) => { enqueueItems: (sourceId, items, kindOverride) => {
@@ -240,6 +264,11 @@ export const useSources = create<SourcesState>((set, get) => ({
}, },
markDownloaded: (itemId, filePath) => { markDownloaded: (itemId, filePath) => {
// Which loaded source holds this item — also the reconcile key, so overlapping
// completions in different sources never suppress each other's response.
const owner = Object.entries(get().itemsBySource).find(([, list]) =>
list.some((m) => m.id === itemId)
)?.[0]
set((s) => { set((s) => {
const next: Record<string, MediaItem[]> = {} const next: Record<string, MediaItem[]> = {}
let changed = false let changed = false
@@ -256,12 +285,28 @@ export const useSources = create<SourcesState>((set, get) => ({
return changed ? { itemsBySource: next } : {} return changed ? { itemsBySource: next } : {}
}) })
if (!PREVIEW) if (!PREVIEW)
window.api.setMediaItemDownloaded(itemId, filePath).catch(logError('setMediaItemDownloaded')) reconcile(
`items:${owner ?? itemId}`,
window.api.setMediaItemDownloaded(itemId, filePath),
(items) => {
// Main returns the updated list for the item's source, or [] when the id
// is unknown there (e.g. a queue item without a persisted MediaItem).
const sid = items[0]?.sourceId
if (sid) set((s) => ({ itemsBySource: { ...s.itemsBySource, [sid]: items } }))
},
logError('setMediaItemDownloaded')
)
}, },
setWatched: (id, watched) => { setWatched: (id, watched) => {
set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) })) set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(logError('setSourceWatched')) if (!PREVIEW)
reconcile(
'sources',
window.api.setSourceWatched(id, watched),
(sources) => set({ sources }),
logError('setSourceWatched')
)
}, },
syncWatched: async () => { syncWatched: async () => {
@@ -295,6 +340,30 @@ export const useSources = create<SourcesState>((set, get) => ({
} finally { } finally {
set({ syncing: false }) set({ syncing: false })
} }
},
scheduledSyncEnabled: false,
loadScheduledSync: () => {
if (PREVIEW) return
window.api
.getScheduledSync()
.then((s) => set({ scheduledSyncEnabled: s.enabled }))
.catch(logError('getScheduledSync'))
},
setScheduledSync: async (enabled) => {
set({ scheduledSyncEnabled: enabled }) // optimistic
if (PREVIEW) return null
const res = await window.api.setScheduledSync(enabled, useSettings.getState().syncTime)
set({ scheduledSyncEnabled: res.enabled })
return res.error ?? null
},
commitSyncTime: async () => {
if (!get().scheduledSyncEnabled || PREVIEW) return null
const res = await window.api.setScheduledSync(true, useSettings.getState().syncTime)
return res.error ?? null
} }
})) }))
+30 -5
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import type { CommandTemplate } from '@shared/ipc' import type { CommandTemplate } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview' import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError' import { logError } from '../reportError'
import { createReconciler } from '../lib/reconcile'
// Sample templates so the Settings tab has content during UI design. // Sample templates so the Settings tab has content during UI design.
const seed: CommandTemplate[] = PREVIEW const seed: CommandTemplate[] = PREVIEW
@@ -16,23 +17,47 @@ interface TemplatesState {
/** add a new template or update an existing one (matched by id) */ /** add a new template or update an existing one (matched by id) */
save: (template: CommandTemplate) => void save: (template: CommandTemplate) => void
remove: (id: string) => void remove: (id: string) => void
/** re-fetch persisted templates from main (a backup restore replaces them underneath) */
reload: () => void
} }
// Mutations apply main's authoritative return (sanitized names/args, capped list)
// via a latest-wins reconciler so the optimistic list matches disk (L142).
const reconcile = createReconciler()
export const useTemplates = create<TemplatesState>((set) => ({ export const useTemplates = create<TemplatesState>((set) => ({
templates: seed, templates: seed,
save: (template) => { save: (template) => {
set((s) => ({ templates: [template, ...s.templates.filter((t) => t.id !== template.id)] })) set((s) => ({ templates: [template, ...s.templates.filter((t) => t.id !== template.id)] }))
if (!PREVIEW) window.api.saveTemplate(template).catch(logError('saveTemplate')) if (!PREVIEW)
reconcile(
'templates',
window.api.saveTemplate(template),
(templates) => set({ templates }),
logError('saveTemplate')
)
}, },
remove: (id) => { remove: (id) => {
set((s) => ({ templates: s.templates.filter((t) => t.id !== id) })) set((s) => ({ templates: s.templates.filter((t) => t.id !== id) }))
if (!PREVIEW) window.api.removeTemplate(id).catch(logError('removeTemplate')) if (!PREVIEW)
reconcile(
'templates',
window.api.removeTemplate(id),
(templates) => set({ templates }),
logError('removeTemplate')
)
},
reload: () => {
if (PREVIEW) return
window.api
.listTemplates()
.then((templates) => set({ templates }))
.catch(logError('listTemplates'))
} }
})) }))
// Load persisted templates on startup. // Load persisted templates on startup.
if (!PREVIEW) { if (!PREVIEW) useTemplates.getState().reload()
window.api.listTemplates().then((templates) => useTemplates.setState({ templates }))
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from 'vitest'
import { createReconciler } from '../src/renderer/src/lib/reconcile'
/** A promise whose resolution the test controls. */
function deferred<T>(): {
promise: Promise<T>
resolve: (v: T) => void
reject: (e: unknown) => void
} {
let resolve!: (v: T) => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const flush = () => new Promise((r) => setTimeout(r, 0))
describe('createReconciler (L142)', () => {
it('applies the response of a single call', async () => {
const reconcile = createReconciler()
const apply = vi.fn()
const d = deferred<string[]>()
reconcile('k', d.promise, apply, vi.fn())
d.resolve(['a'])
await flush()
expect(apply).toHaveBeenCalledWith(['a'])
})
it('drops a stale response when a newer call was issued on the same key', async () => {
const reconcile = createReconciler()
const applied: string[][] = []
const apply = (v: string[]) => applied.push(v)
const first = deferred<string[]>()
const second = deferred<string[]>()
reconcile('k', first.promise, apply, vi.fn())
reconcile('k', second.promise, apply, vi.fn())
// Even resolving in order, only the newest call's response is applied —
// the first would transiently roll back the second's optimistic update.
first.resolve(['a'])
second.resolve(['a', 'b'])
await flush()
expect(applied).toEqual([['a', 'b']])
})
it('drops an out-of-order late response entirely', async () => {
const reconcile = createReconciler()
const applied: string[][] = []
const first = deferred<string[]>()
const second = deferred<string[]>()
reconcile('k', first.promise, (v) => applied.push(v), vi.fn())
reconcile('k', second.promise, (v) => applied.push(v), vi.fn())
second.resolve(['a', 'b'])
await flush()
first.resolve(['a']) // resolves after the newer response was applied
await flush()
expect(applied).toEqual([['a', 'b']])
})
it('keys are independent — a call on one key never suppresses another', async () => {
const reconcile = createReconciler()
const applied: Record<string, number[]> = {}
const a = deferred<number[]>()
const b = deferred<number[]>()
reconcile('a', a.promise, (v) => (applied.a = v), vi.fn())
reconcile('b', b.promise, (v) => (applied.b = v), vi.fn())
a.resolve([1])
b.resolve([2])
await flush()
expect(applied).toEqual({ a: [1], b: [2] })
})
it('routes rejection to onError and never to apply', async () => {
const reconcile = createReconciler()
const apply = vi.fn()
const onError = vi.fn()
const d = deferred<string[]>()
reconcile('k', d.promise, apply, onError)
d.reject(new Error('ipc failed'))
await flush()
expect(apply).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'ipc failed' }))
})
it('a rejected older call does not block applying the newer response', async () => {
const reconcile = createReconciler()
const applied: string[][] = []
const onError = vi.fn()
const first = deferred<string[]>()
const second = deferred<string[]>()
reconcile('k', first.promise, (v) => applied.push(v), onError)
reconcile('k', second.promise, (v) => applied.push(v), onError)
first.reject(new Error('boom'))
second.resolve(['b'])
await flush()
expect(applied).toEqual([['b']])
expect(onError).toHaveBeenCalledTimes(1)
})
})