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:
@@ -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.
|
||||
|
||||
@@ -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([]) }
|
||||
}
|
||||
@@ -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 { 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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user