feat(audit): Batch 15 live-verify — download-engine lifecycle (R4, L65, L139, B2, B6, L143)

The watched live session for the six deferred lifecycle items, each verified
against real yt-dlp/Electron behavior on Windows:

- R4: cancel now deletes the download's orphaned partials. The engine captures
  the final output path via a new `--print before_dl:dest|%(filename)s`
  (empirically %(filepath)s is still 'NA' that early) and the close handler
  sweeps only unambiguous intermediates (.part / .part-Frag* / .ytdl /
  <stem>.fNNN.<ext>) via the pure, unit-tested lib/partials.ts — never a bare
  <stem>.<ext> or another download's files. Live-verified with decoys.
- L65: verified-acceptable, no code change — a taskkill /F pause leaves the
  .part byte-intact and yt-dlp --continue resumes at the exact byte offset.
- L139: spawn↔self-update interlock (new ytdlpLock.ts). The updater refuses
  while any yt-dlp spawn is live; download/terminal IPC handlers hold (await)
  behind an in-progress exe rewrite instead of failing.
- B2: source indexing is cancellable — AbortSignal through the probe walk
  (kills the in-flight child, live-verified via tasklist), sources:index-cancel
  IPC, and a Cancel button in the Library add-source row.
- B6: app-update download is cancellable — app:update-cancel routes through
  the existing finish() teardown (abort + destroy + unlink, live-verified on
  Electron net.request); Cancel button under the update progress bar. The
  checksum phase is cancelable too.
- L143: closing the window mid-download (non-tray mode) now offers a native
  tray-independent "Quit anyway / Keep downloading" dialog, replacing the
  passive "use the tray to quit" notification.

Peer-review pass caught and fixed: a non-reentrant update lock (concurrent
begin clobbered the settle promise), a dead Cancel window during the B6
checksum fetch + a canceller slot-ownership bug (L140 shape), and a
persist-after-cancel hole in the index walk.

typecheck + 304 tests + eslint + production build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:36:22 -04:00
parent b3e5e4f309
commit e19f988c72
20 changed files with 737 additions and 98 deletions
+13 -2
View File
@@ -22,7 +22,8 @@ import {
AppsListRegular,
VideoClipMultipleRegular,
AlertRegular,
LibraryRegular
LibraryRegular,
DismissRegular
} from '@fluentui/react-icons'
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
@@ -228,6 +229,7 @@ export function LibraryView(): React.JSX.Element {
const selectSource = useSources((s) => s.selectSource)
const indexSource = useSources((s) => s.indexSource)
const reindexSource = useSources((s) => s.reindexSource)
const cancelIndexing = useSources((s) => s.cancelIndexing)
const removeSource = useSources((s) => s.removeSource)
const enqueueItems = useSources((s) => s.enqueueItems)
const setWatched = useSources((s) => s.setWatched)
@@ -358,7 +360,7 @@ export function LibraryView(): React.JSX.Element {
if (!u || indexing.active) return
const res = await indexSource(u)
if (res.ok) setUrl('')
else setError(res.error ?? 'Could not add that link.')
else if (!res.canceled) setError(res.error ?? 'Could not add that link.')
}
function toggle(id: string, on: boolean): void {
@@ -586,6 +588,15 @@ export function LibraryView(): React.JSX.Element {
{indexing.message ?? 'Adding…'}
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
</Text>
{/* B2: a big channel's index walk can take minutes — let the user abort it. */}
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancelIndexing}
>
Cancel
</Button>
</div>
)}
{error && <Caption1 className={styles.error}>{error}</Caption1>}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Field,
Input,
@@ -11,7 +11,12 @@ import {
ProgressBar,
tokens
} from '@fluentui/react-components'
import { ArrowSyncRegular, ArrowDownloadRegular, OpenRegular } from '@fluentui/react-icons'
import {
ArrowSyncRegular,
ArrowDownloadRegular,
OpenRegular,
DismissRegular
} from '@fluentui/react-icons'
import { type AppUpdateInfo } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../ui/errorText'
@@ -30,6 +35,9 @@ export function SoftwareUpdateCard(): React.JSX.Element {
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'))
@@ -55,11 +63,15 @@ export function SoftwareUpdateCard(): React.JSX.Element {
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
@@ -68,12 +80,17 @@ export function SoftwareUpdateCard(): React.JSX.Element {
// 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) {
setAppUpdError(e instanceof Error ? e.message : String(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 (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
@@ -141,7 +158,20 @@ export function SoftwareUpdateCard(): React.JSX.Element {
</Caption1>
)}
{appDownloading && (
<ProgressBar value={appFraction} aria-label="App update download progress" />
<>
<ProgressBar value={appFraction} aria-label="App update download progress" />
{/* B6: a ~300 MB installer download can take a while — let the user abort it. */}
<div className={styles.folderRow}>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancelDownload}
>
Cancel download
</Button>
</div>
</>
)}
</>
)}
+2
View File
@@ -60,6 +60,7 @@ export const mockApi: Api = {
ok: false,
error: 'Downloading updates is disabled in the browser preview.'
}),
cancelAppUpdate: async () => {},
runAppUpdate: async () => ({ ok: true }),
onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
@@ -237,6 +238,7 @@ export const mockApi: Api = {
}
},
reindexSource: async () => ({ ok: true, itemCount: 7, newCount: 0 }),
cancelIndex: async () => {},
removeSource: async () => [],
setMediaItemDownloaded: async () => [],
setSourceWatched: async () => [],
+26 -3
View File
@@ -80,9 +80,12 @@ interface SourcesState {
indexing: IndexingState
loadSources: () => void
selectSource: (id: string | null) => void
/** index a pasted channel/playlist URL; resolves with ok + an error message */
indexSource: (url: string) => Promise<{ ok: boolean; error?: string }>
/** index a pasted channel/playlist URL; resolves with ok + an error message.
* `canceled` is set when the user aborted, so the caller shows no error (B2). */
indexSource: (url: string) => Promise<{ ok: boolean; error?: string; canceled?: boolean }>
reindexSource: (id: string) => Promise<void>
/** abort an in-flight index/reindex walk (the add-source "Cancel" button, B2) */
cancelIndexing: () => void
removeSource: (id: string) => void
/**
* Enqueue the given media items into the download queue with folder context.
@@ -104,6 +107,10 @@ interface SourcesState {
syncWatched: () => Promise<number>
}
// 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
export const useSources = create<SourcesState>((set, get) => ({
sources: seedSources,
itemsBySource: seedItemsBySource,
@@ -131,15 +138,19 @@ export const useSources = create<SourcesState>((set, get) => ({
},
indexSource: async (url) => {
indexCanceledRequested = false
set({ indexing: { active: true, url, message: 'Reading source…' } })
if (PREVIEW) {
await new Promise((r) => setTimeout(r, 900)) // simulate the index walk
set({ indexing: { active: false } })
return { ok: true }
return indexCanceledRequested ? { ok: false, canceled: true } : { ok: true }
}
try {
const res = await window.api.indexSource(url)
set({ indexing: { active: false } })
// The user hit Cancel: report canceled (no error UI) regardless of what main
// returned for the aborted walk (B2).
if (indexCanceledRequested) return { ok: false, canceled: true }
if (res.ok) {
get().loadSources()
if (res.source) get().selectSource(res.source.id)
@@ -147,11 +158,13 @@ export const useSources = create<SourcesState>((set, get) => ({
return { ok: res.ok, error: res.error }
} catch (e) {
set({ indexing: { active: false } })
if (indexCanceledRequested) return { ok: false, canceled: true }
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
},
reindexSource: async (id) => {
indexCanceledRequested = false
const src = get().sources.find((s) => s.id === id)
set({ indexing: { active: true, url: src?.url, message: 'Refreshing…' } })
if (PREVIEW) {
@@ -162,6 +175,7 @@ export const useSources = create<SourcesState>((set, get) => ({
try {
const res = await window.api.reindexSource(id)
set({ indexing: { active: false } })
if (indexCanceledRequested) return // user aborted — leave the source as-is
if (res.ok) {
get().loadSources()
const items = await window.api.listSourceItems(id)
@@ -173,6 +187,15 @@ export const useSources = create<SourcesState>((set, get) => ({
}
},
cancelIndexing: () => {
// Optimistically clear the indexing UI; main aborts the in-flight probe child.
// The pending index/reindex promise then resolves and is treated as canceled
// via indexCanceledRequested (B2).
indexCanceledRequested = true
set({ indexing: { active: false } })
if (!PREVIEW) window.api.cancelIndex().catch(logError('cancelIndex'))
},
removeSource: (id) => {
set((s) => ({
sources: s.sources.filter((x) => x.id !== id),