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