feat(audit): Batch 16 — store discipline (L142 reconciliation, L93 view-model hooks)
L142 (+CC14): keyed latest-wins reconciler (lib/reconcile.ts, unit-tested) applies main's authoritative IPC returns in the history/templates/sources stores, extending the M34 settings pattern; loadSources shares the key so a slow list read can't clobber a newer mutation result. L93 (+CC13): settings cards, TerminalView, and LibraryView now reach IPC only through colocated view-model hooks (useAboutCard/useBackupCard/ useCookiesCard/useSoftwareUpdateCard/useNetworkCard/useTerminalRun) or store actions (openHighContrastSettings, scheduled-sync on sources) — no window.api left in components. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+35
-23
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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([]) }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 }))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -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 }))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user