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))
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.*
- [ ] **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
data-access in one component. *Deferred (CC13's concrete instance): these one-off, card-local IPC reads
(version checks, backup export/import, cookie sign-in) have no shared state to own, so a store per call is
arguably overkill — but the "no `window.api` in components" convention argues for thin hooks. Best done as
the focused CC13 view-model pass, not folded into this copy/cleanup batch.*
data-access in one component. *Fixed (Batch 16, the CC13 view-model pass): each card's IPC + state machine
moved into a colocated view-model hook on the `useDownloadBar` pattern —
[useAboutCard](src/renderer/src/components/settings/useAboutCard.ts) /
[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)
(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
@@ -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
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).*
- [ ] **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
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
state" to history/templates/sources touches each store's mutation path and each corresponding IPC return
shape, and the divergence it guards against is rare (only when main caps/sanitizes differently than the
optimistic update) and self-heals on reload. Best done as one focused reconciliation PR with each store
verified, not folded into this UX batch. Standard already recorded under CC14.*
*Fixed (Batch 16, the focused reconciliation PR): a keyed latest-wins reconciler
([lib/reconcile.ts](src/renderer/src/lib/reconcile.ts), unit-tested in
[test/reconcile.test.ts](test/reconcile.test.ts)) applies main's authoritative return on every mutation —
history add/remove/removeMany/clear, template save/remove, and sources setWatched/removeSource/
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;
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).
@@ -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/`
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.*
- [ ] **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
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.
*Partly closed / deferred: the substantive split largely holds — downloads/sources orchestration lives in
stores, and DownloadBar's was extracted into the `useDownloadBar` hook (H1), a view-model in all but name.
The concrete remaining leak is SettingsView calling `window.api` directly, filed as **L93** (Batch 13);
the broader "no `window.api` in any component" sweep rides with it. No new work needed here beyond L93.*
- [ ] **CC14 — State ownership is unprincipled.** State lives in Zustand stores, component `useState`,
*Closed with L93 (Batch 16): the settings cards, TerminalView, and LibraryView now reach IPC only through
colocated view-model hooks or store actions; `grep window.api src/renderer/src/components` matches only
hooks (`use*.ts`). The standard holds codebase-wide: stores/hooks own orchestration + IPC, components render.*
- [x] **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
(`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
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
(C2, via the coordinator event bus); settings now **apply the authoritative IPC return** (M34); and
`localStorage` was folded into the settings store (M19). The one remaining "optimistic-only, never
reconciled" case is the other IPC mutations (**L142**, Batch 11). The module-level vars called out
(`idCounter`/`notifiedBackground`/`active`) are legitimately module-scoped singletons, not misplaced UI
state. Closes with L142.*
*Closed with L142 (Batch 16): every persisted-state mutation now applies main's authoritative return
(history/templates/sources via the keyed reconciler; settings via M34). Main owns persisted state, the
renderer mirrors + reconciles, the store cycle is gone (C2), `localStorage` is folded in (M19). The
module-level vars called out (`idCounter`/`notifiedBackground`/`active`) are legitimately module-scoped
singletons, not misplaced UI state.*
### Recommended single style (apply project-wide)
+10 -17
View File
@@ -26,12 +26,10 @@ import {
DismissRegular
} from '@fluentui/react-icons'
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { logError } from '../reportError'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { relTime } from '../datetime'
@@ -235,6 +233,10 @@ export function LibraryView(): React.JSX.Element {
const setWatched = useSources((s) => s.setWatched)
const syncWatched = useSources((s) => s.syncWatched)
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 autoDownloadNew = useSettings((s) => s.autoDownloadNew)
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
// an expanded source shows just its group headers until one is opened.
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const [scheduled, setScheduled] = useState(false)
const watchedCount = sources.filter((s) => s.watched).length
// Load the current scheduled-sync (Task Scheduler) state once.
useEffect(() => {
if (PREVIEW) return
window.api
.getScheduledSync()
.then((s) => setScheduled(s.enabled))
.catch(logError('getScheduledSync'))
}, [])
loadScheduledSync()
}, [loadScheduledSync])
async function onCheckNew(): Promise<void> {
setSyncNote(null)
@@ -293,11 +290,8 @@ export function LibraryView(): React.JSX.Element {
}
async function toggleScheduled(next: boolean): Promise<void> {
setScheduled(next) // optimistic
if (PREVIEW) return
const res = await window.api.setScheduledSync(next, syncTime)
setScheduled(res.enabled)
if (res.error) setError(res.error)
const err = await setScheduledSync(next)
if (err) setError(err)
}
// 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
// through the segments doesn't spawn schtasks per keystroke (L51).
async function onSyncTimeCommit(): Promise<void> {
if (!scheduled || PREVIEW) return
const res = await window.api.setScheduledSync(true, useSettings.getState().syncTime)
if (res.error) setError(res.error)
const err = await commitSyncTime()
if (err) setError(err)
}
// 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 {
Button,
Textarea,
@@ -11,24 +11,10 @@ import {
} from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { newId } from '../id'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens'
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
import { useTerminalRun, type LineKind } from './useTerminalRun'
const useStyles = makeStyles({
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 updateSettings = useSettings((s) => s.update)
const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([])
const [running, setRunning] = useState(false)
const runId = useRef<string | null>(null)
// View-model hook owns the run/stream/stop orchestration + IPC (L93/CC13).
const { args, setArgs, lines, running, run, stop, clearLines } = useTerminalRun()
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.
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [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 =>
kind === 'cmd'
? styles.cmd
@@ -214,7 +149,7 @@ export function TerminalView(): React.JSX.Element {
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setLines([])}
onClick={clearLines}
disabled={lines.length === 0}
aria-label="Clear output"
/>
@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react'
import {
Field,
Switch,
@@ -10,17 +9,12 @@ import {
Spinner
} from '@fluentui/react-components'
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import {
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type FfmpegVersionResult
} from '@shared/ipc'
import { type YtdlpUpdateChannel } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText'
import { logError } from '../../reportError'
import { useSettingsStyles } from './settingsStyles'
import { useAboutCard } from './useAboutCard'
const UPDATE_CHANNEL_OPTIONS = [
{ value: 'stable', label: 'Stable' },
@@ -35,59 +29,17 @@ export function AboutCard(): React.JSX.Element {
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
const update = useSettings((s) => s.update)
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)
}
}
// View-model hook owns the version/update state + IPC (L93/CC13).
const {
checking,
version,
ffmpeg,
updating,
updateResult,
checkVersion,
runUpdate,
refreshFfmpeg
} = useAboutCard()
return (
<Card className={styles.card}>
@@ -127,9 +79,7 @@ export function AboutCard(): React.JSX.Element {
<Button
appearance="subtle"
icon={<ArrowSyncRegular />}
onClick={() =>
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
}
onClick={refreshFfmpeg}
aria-label="Re-check ffmpeg versions"
/>
</div>
@@ -22,6 +22,8 @@ export function AppearanceCard(): React.JSX.Element {
const accentColor = useSettings((s) => s.accentColor)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
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
// single-select swatches behave like the Theme radio pattern rather than a row
@@ -96,11 +98,7 @@ export function AppearanceCard(): React.JSX.Element {
</Caption1>
{highContrast && (
<div className={styles.folderRow}>
<Button
size="small"
icon={<AccessibilityRegular />}
onClick={() => window.api.openHighContrastSettings()}
>
<Button size="small" icon={<AccessibilityRegular />} onClick={openHighContrastSettings}>
Open accessibility settings
</Button>
</div>
@@ -1,51 +1,16 @@
import { useState } from 'react'
import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
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 { useSettingsStyles } from './settingsStyles'
import { useBackupCard } from './useBackupCard'
export function BackupCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
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.
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)
}
}
// View-model hook owns the export/import state + IPC (L93/CC13).
const { exporting, exportResult, importing, importResult, exportBackup, importBackup } =
useBackupCard()
return (
<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 { CookiesRegular } from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
type CookieSource,
type CookieBrowser,
type CookiesStatus
} from '@shared/ipc'
import { COOKIE_BROWSERS, type CookieSource, type CookieBrowser } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText'
import { useSettingsStyles } from './settingsStyles'
import { useCookiesCard } from './useCookiesCard'
const COOKIE_SOURCE_OPTIONS = [
{ value: 'none', label: 'None' },
@@ -30,39 +25,9 @@ export function CookiesCard(): React.JSX.Element {
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const update = useSettings((s) => s.update)
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)
}, [])
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 })
}
// View-model hook owns the cookies status + sign-in orchestration (L93/CC13).
const { cookiesStatus, loginUrl, setLoginUrl, signingIn, loginError, signIn, clearSavedCookies } =
useCookiesCard()
return (
<Card className={styles.card}>
@@ -1,4 +1,3 @@
import { useState } from 'react'
import {
Field,
Input,
@@ -12,6 +11,7 @@ import {
import { GlobeRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useSettingsStyles } from './settingsStyles'
import { useNetworkCard } from './useNetworkCard'
export function NetworkCard(): React.JSX.Element {
const styles = useSettingsStyles()
@@ -23,8 +23,8 @@ export function NetworkCard(): React.JSX.Element {
const youtubePoToken = useSettings((s) => s.youtubePoToken)
const update = useSettings((s) => s.update)
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
// View-model hook owns the PO-token minting state + IPC (L93/CC13).
const { mintingPot, potHint, clearPotHint, mintPoToken } = useNetworkCard()
return (
<Card className={styles.card}>
@@ -114,29 +114,14 @@ export function NetworkCard(): React.JSX.Element {
placeholder="Optional -- paste a token"
onChange={(_, d) => {
update({ youtubePoToken: d.value })
setPotHint(null)
clearPotHint()
}}
/>
<Button
size="small"
disabled={mintingPot}
icon={mintingPot ? <Spinner size="tiny" /> : undefined}
onClick={async () => {
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)
}
}}
onClick={mintPoToken}
>
{mintingPot ? 'Fetching…' : 'Fetch'}
</Button>
@@ -1,4 +1,3 @@
import { useEffect, useRef, useState } from 'react'
import {
Field,
Input,
@@ -17,11 +16,10 @@ import {
OpenRegular,
DismissRegular
} from '@fluentui/react-icons'
import { type AppUpdateInfo } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../ui/errorText'
import { logError } from '../../reportError'
import { useSettingsStyles } from './settingsStyles'
import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
export function SoftwareUpdateCard(): React.JSX.Element {
const styles = useSettingsStyles()
@@ -29,67 +27,19 @@ export function SoftwareUpdateCard(): React.JSX.Element {
const updateToken = useSettings((s) => s.updateToken)
const update = useSettings((s) => s.update)
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'))
}
// View-model hook owns the check → download → install orchestration (L93/CC13).
const {
appVersion,
appUpd,
appChecking,
appDownloading,
appFraction,
appUpdError,
checkAppUpdate,
installAppUpdate,
cancelDownload,
openReleasePage
} = useSoftwareUpdateCard()
return (
<Card className={styles.card}>
@@ -144,10 +94,7 @@ export function SoftwareUpdateCard(): React.JSX.Element {
{appDownloading ? 'Downloading…' : 'Update now'}
</Button>
{appUpd.htmlUrl && (
<Button
icon={<OpenRegular />}
onClick={() => void window.api?.openUrl?.(appUpd.htmlUrl ?? '')}
>
<Button icon={<OpenRegular />} onClick={openReleasePage}>
View release
</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 { logError } from '../reportError'
import { revealFile } from '../reveal'
import { createReconciler } from '../lib/reconcile'
// 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).
@@ -59,6 +60,11 @@ interface HistoryState {
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) => ({
entries: seed,
@@ -69,23 +75,47 @@ export const useHistory = create<HistoryState>((set, get) => ({
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) => {
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) => {
const remove = new Set(ids)
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: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearHistory().catch(logError('clearHistory'))
if (!PREVIEW)
reconcile(
'entries',
window.api.clearHistory(),
(entries) => set({ entries }),
logError('clearHistory')
)
},
openFile: (id) => {
+18 -4
View File
@@ -22,10 +22,14 @@ interface SettingsState extends Settings {
/** true once persisted settings have loaded (always true in preview) */
loaded: boolean
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 */
chooseDir: (target: 'videoDir' | 'audioDir') => void
/** clear a per-kind folder override, restoring its Documents\… default */
clearDir: (target: 'videoDir' | 'audioDir') => void
/** open the Windows High Contrast settings page (AppearanceCard) */
openHighContrastSettings: () => void
}
export const useSettings = create<SettingsState>((set, get) => ({
@@ -50,6 +54,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
.catch(logError('setSettings'))
},
reload: () => {
if (PREVIEW) return
window.api
.getSettings()
.then((s) => set({ ...s, loaded: true }))
.catch(logError('getSettings'))
},
chooseDir: (target) => {
if (PREVIEW) return
// 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'))
},
clearDir: (target) => get().update({ [target]: '' })
clearDir: (target) => get().update({ [target]: '' }),
openHighContrastSettings: () => {
if (!PREVIEW) window.api.openHighContrastSettings().catch(logError('openHighContrastSettings'))
}
}))
// Load persisted settings on startup.
if (!PREVIEW) {
window.api.getSettings().then((s) => useSettings.setState({ ...s, loaded: true }))
}
if (!PREVIEW) useSettings.getState().reload()
+76 -7
View File
@@ -4,6 +4,7 @@ import { isPreview as PREVIEW } from '../isPreview'
import { useSettings } from './settings'
import { emit, on } from './coordinator'
import { logError } from '../reportError'
import { createReconciler } from '../lib/reconcile'
// --- Preview seed data ------------------------------------------------------
@@ -105,12 +106,25 @@ interface SourcesState {
* videos. Resolves with how many new videos were found.
*/
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
// aborted and can resolve as canceled (no error UI) rather than as a failure (B2).
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) => ({
sources: seedSources,
itemsBySource: seedItemsBySource,
@@ -120,10 +134,14 @@ export const useSources = create<SourcesState>((set, get) => ({
loadSources: () => {
if (PREVIEW) return
window.api
.listSources()
.then((sources) => set({ sources }))
.catch(logError('listSources'))
// Shares the mutations' 'sources' key so a slow list response can't clobber
// a newer setWatched/removeSource authoritative result (L142).
reconcile(
'sources',
window.api.listSources(),
(sources) => set({ sources }),
logError('listSources')
)
},
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
// the event bus (C2) so sources doesn't import downloads.
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) => {
@@ -240,6 +264,11 @@ export const useSources = create<SourcesState>((set, get) => ({
},
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) => {
const next: Record<string, MediaItem[]> = {}
let changed = false
@@ -256,12 +285,28 @@ export const useSources = create<SourcesState>((set, get) => ({
return changed ? { itemsBySource: next } : {}
})
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) => {
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 () => {
@@ -295,6 +340,30 @@ export const useSources = create<SourcesState>((set, get) => ({
} finally {
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 { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError'
import { createReconciler } from '../lib/reconcile'
// Sample templates so the Settings tab has content during UI design.
const seed: CommandTemplate[] = PREVIEW
@@ -16,23 +17,47 @@ interface TemplatesState {
/** add a new template or update an existing one (matched by id) */
save: (template: CommandTemplate) => 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) => ({
templates: seed,
save: (template) => {
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) => {
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.
if (!PREVIEW) {
window.api.listTemplates().then((templates) => useTemplates.setState({ templates }))
}
if (!PREVIEW) useTemplates.getState().reload()
+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)
})
})