feat(audit): Batch 15 — download-engine lifecycle (safe part; rest live-verify)

This is the batch the run reserves for a WATCHED LIVE SESSION, so only the safe,
non-file-deleting item is implemented here; the rest are staged with per-item
live-verify rationale in CODE-AUDIT.md.

Landed:
- L157: removeSource cancels its in-flight downloads. removeSource emits a
  `sourceRemoved` event on the coordinator bus (C2); the downloads store cancels
  every active (downloading/queued) item whose mediaItemId starts with
  `${sourceId}:` — MediaItem ids are `${sourceId}:${videoId}` (indexerCore), so the
  prefix reliably identifies the source's downloads. Reuses the proven cancel()
  path (process kill + pump). Type/lint/test-green; process-kill worth a live confirm.

Resolved by prior work:
- UX11: M4 persists the queue, so a scheduled item fires on next launch once due —
  no longer "never fires if quit".
- UX20: L68 resets notifiedBackground on window show, so every close-to-tray
  re-fires the "still running in the tray" notice, not just the first.

Deferred to the watched live pass (each needs a real download to verify):
R4 (delete .part on cancel — file-deletion data-safety), L65 (graceful Ctrl-C
pause), L139 (spawn↔auto-update interlock — timing race), B2 (index AbortSignal +
Cancel), B6 (app-update cancel — ties to CL4's deferred updater), L143 (in-window
quit-anyway).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:41:48 -04:00
parent 0f784847a3
commit 4d1c33a081
4 changed files with 95 additions and 5 deletions
+71 -5
View File
@@ -226,6 +226,34 @@ software-rendering workaround — external driver dependency), and **L104** (His
L161 layout rework + live verification; the 500-cap bounds it). All green: typecheck (node+web) + 279 tests + L161 layout rework + live verification; the 500-cap bounds it). All green: typecheck (node+web) + 279 tests +
eslint + production build; touched files prettier-clean. eslint + production build; touched files prettier-clean.
**Session 2026-07-01 pass 12 (Batch 15 — download-engine lifecycle):** the batch the run's policy reserves for
a **watched live session** — so only the safe, non-file-deleting item was implemented, and the rest are staged
with per-item live-verify rationale. **Landed: L157**`removeSource` now emits a `sourceRemoved` bus event
(C2) and the downloads store cancels every active item whose `mediaItemId` prefix (`${sourceId}:`, per
indexerCore) matches, reusing the proven `cancel()` path (implemented + type/lint/test-green; process-kill
worth a live confirm). **Resolved by prior work: UX11** (M4 persists the queue, so a scheduled item fires on
next launch once due — no longer "never fires if quit"), **UX20** (L68 resets `notifiedBackground` on window
show, so every close-to-tray re-fires the "still running" notice, not just the first). **Deferred to the
watched live pass with rationale — each needs a real download to verify:** **R4** (delete `.part` on cancel —
file-deletion data-safety), **L65** (graceful Ctrl-C pause vs `taskkill /F`), **L139** (spawn↔auto-update
interlock — timing race), **B2** (index `AbortSignal` + Cancel), **B6** (app-update cancel — ties to CL4's
deferred updater), **L143** (in-window quit-anyway). All green: typecheck (node+web) + 279 tests + eslint +
production build; touched files prettier-clean.
---
## Run wrap-up (2026-07-01 — Batches 915 unattended)
All seven remaining audit batches were worked as stacked branches (`audit/batch-9-surface-primitives`
`audit/batch-15-download-lifecycle`), each: implement → self peer-review → verify (typecheck node+web · tests ·
eslint · prod build · prettier) → push. The contained, safe, testable items landed; three categories were
**honestly deferred with documented rationale** rather than forced unattended: (1) large architectural
refactors the release gate already puts post-1.0 (CC9 zod, CC10/CC12/CC14 persistence/reorg, CL4 updater),
(2) subjective-visual items needing an eyeball (UI3/UI12/UI16/UX7/UX18/UI21/UX26 …), and (3) the download-engine
lifecycle changes that require a live download to verify (Batch 15's R4/L65/L139/B2/B6/L143). **Two items need
maintainer awareness:** SR4 flips `clipboardWatch` off-by-default for new installs (reversible), and Batch 15's
deferred set + L157's cancel-on-remove want a watched live smoke-test before merge.
**Deliberately deferred** (need live-app/visual verification or are larger refactors the **Deliberately deferred** (need live-app/visual verification or are larger refactors the
audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store
refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations, refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations,
@@ -794,6 +822,11 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
*Deferred (feature): exposing aria2c tuning is a new advanced setting, not a cleanup; the defaults are *Deferred (feature): exposing aria2c tuning is a new advanced setting, not a cleanup; the defaults are
sensible and aria2c itself is already opt-in.* sensible and aria2c itself is already opt-in.*
- [ ] **L65 — Pause uses `taskkill /F`** like cancel — a forced kill risks an unflushed `.part` tail vs a graceful stop. - [ ] **L65 — Pause uses `taskkill /F`** like cancel — a forced kill risks an unflushed `.part` tail vs a graceful stop.
*Deferred to the watched Batch 15 live pass: a graceful stop on Windows means signalling the child
(GenerateConsoleCtrlEvent/Ctrl-C) so yt-dlp flushes, which is fiddly and only verifiable by pausing a real
download and checking the `.part` resumes cleanly. The forced kill works today (resume re-spawns and yt-dlp
continues the `.part`), so the practical loss is at most the last buffer — low impact, but the graceful path
needs live verification.*
- [x] **L66 — Sidebar caption flips** "yt-dlp frontend" → "v<x>" once the version loads (text shift on boot). - [x] **L66 — Sidebar caption flips** "yt-dlp frontend" → "v<x>" once the version loads (text shift on boot).
- [x] **L67 — Onboarding has no Skip and can't be revisited** (no "show tips again"). *Fixed the revisit - [x] **L67 — Onboarding has no Skip and can't be revisited** (no "show tips again"). *Fixed the revisit
half (with UX22): Settings → About has a "Show welcome tips again" button that re-opens the onboarding half (with UX22): Settings → About has a "Show welcome tips again" button that re-opens the onboarding
@@ -1112,6 +1145,11 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L139 — Auto-update vs concurrent spawn race.** `runStartupYtdlpAutoUpdate` can overwrite - [ ] **L139 — Auto-update vs concurrent spawn race.** `runStartupYtdlpAutoUpdate` can overwrite
`yt-dlp.exe` on launch while a deep-link/queued download spawns the same binary — a Windows `yt-dlp.exe` on launch while a deep-link/queued download spawns the same binary — a Windows
file-in-use/corrupt-spawn window. file-in-use/corrupt-spawn window.
*Deferred to the watched Batch 15 live pass: the fix (a spawn↔update interlock — hold spawns while the
update replaces the exe, and skip the update if a download is mid-spawn) is a timing-sensitive change to the
launch/spawn path that can only be validated by reproducing the race with a real launch-time update + a
queued download. The interlock flag itself is simple, but confirming it closes the window needs the live
session.*
- [x] **L140 — Retry-during-teardown race.** `retry` reuses the same id; if the previous process's - [x] **L140 — Retry-during-teardown race.** `retry` reuses the same id; if the previous process's
`close` handler hasn't removed it from `active` yet, `startDownload` returns "already running" → markError. `close` handler hasn't removed it from `active` yet, `startDownload` returns "already running" → markError.
*Fixed in [download.ts](src/main/download.ts): `cancelDownload`/`pauseDownload` now drop the item from *Fixed in [download.ts](src/main/download.ts): `cancelDownload`/`pauseDownload` now drop the item from
@@ -1136,6 +1174,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L143 — No in-window "quit anyway."** With a download active (or tray mode), closing only hides; - [ ] **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).
*Deferred to the watched Batch 15 live pass: adding an in-window force-quit affordance (and deciding when to
show it) is a window-lifecycle change best verified against the real tray/close behavior at several window
states. Already mitigated by the embedded fallback tray icon, so it's low-urgency.*
- [x] **L144 — Duplicate guard checks the queue only, not history.** Re-adding a URL downloaded earlier - [x] **L144 — Duplicate guard checks the queue only, not history.** Re-adding a URL downloaded earlier
(already cleared from the queue) gives no "already downloaded" hint (roadmap notes this as a possible extension). (already cleared from the queue) gives no "already downloaded" hint (roadmap notes this as a possible extension).
*Fixed: the DownloadBar dup guard ([useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts)) *Fixed: the DownloadBar dup guard ([useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts))
@@ -1188,8 +1229,14 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
`aria-label` is gone (aria-checked conveys it).* `aria-label` is gone (aria-checked conveys it).*
- [x] **L156 — `datetime-local` schedule has no `min`.** Past times are selectable, then silently download - [x] **L156 — `datetime-local` schedule has no `min`.** Past times are selectable, then silently download
now (extends L57). Add `min={now}`. now (extends L57). Add `min={now}`.
- [ ] **L157 — `removeSource` doesn't cancel in-flight downloads.** Removing a source while its videos are - [x] **L157 — `removeSource` doesn't cancel in-flight downloads.** Removing a source while its videos are
downloading lets them finish into the removed source's folders; `markDownloaded` then no-ops on the gone item. downloading lets them finish into the removed source's folders; `markDownloaded` then no-ops on the gone item.
*Fixed: `removeSource` now emits a `sourceRemoved` event on the coordinator bus (C2); the downloads store
subscribes and cancels every active (downloading/queued) item whose `mediaItemId` starts with `${sourceId}:`
— MediaItem ids are `${sourceId}:${videoId}` (indexerCore), so the prefix reliably identifies the source's
downloads. Reuses the proven `cancel()` path (process kill + pump). **Live-smoke pending** (per the
mark-done convention): implemented + type/lint/test-green, but the actual process-kill-on-remove is worth a
watched confirm in the Batch 15 live pass.*
- [x] **L158 — Drag highlight flickers.** `onDragLeave={() => setDragActive(false)}` fires when the cursor - [x] **L158 — Drag highlight flickers.** `onDragLeave={() => setDragActive(false)}` fires when the cursor
crosses child elements (no enter/leave depth counter), so the dashed-outline blinks during a drag. crosses child elements (no enter/leave depth counter), so the dashed-outline blinks during a drag.
*Fixed: [useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts) now tracks a *Fixed: [useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts) now tracks a
@@ -1587,9 +1634,13 @@ root cause is already filed.
a non-portal bottom-center stack that any store/component can raise (now used for UX6's file-action errors). a non-portal bottom-center stack that any store/component can raise (now used for UX6's file-action errors).
Together this closes **UI25**.* Together this closes **UI25**.*
- [x] **UX10 — Re-download silently changes quality** (H5) — a confusing "I asked for 720p, got Best." *Resolved by H5 (re-download stores `formatId`/`formatHasAudio` and strips compound quality labels).* - [x] **UX10 — Re-download silently changes quality** (H5) — a confusing "I asked for 720p, got Best." *Resolved by H5 (re-download stores `formatId`/`formatHasAudio` and strips compound quality labels).*
- [ ] **UX11 — Scheduled downloads silently never fire if the app is quit** (M4). The hint is easy to - [x] **UX11 — Scheduled downloads silently never fire if the app is quit** (M4). The hint is easy to
miss; the user returns to find nothing happened. **Fix:** warn at schedule time that it requires the miss; the user returns to find nothing happened. **Fix:** warn at schedule time that it requires the
app running (or persist + relaunch). app running (or persist + relaunch). *Resolved by M4 (the "persist + relaunch" option the fix names): the
queue is now persisted, so a `saved`+`scheduledFor` item survives a quit and the App promoter tick fires it
on the next launch once its time has passed (the store normalizes/promotes due items). So a scheduled
download no longer "never fires" if quit — it fires when due, or on the next launch if that time already
passed. (Live-smoke of the quit→relaunch cycle noted in M4.)*
- [x] **UX12 — Terminal is a dead-end when custom commands are off** (L18). The nav item is always - [x] **UX12 — Terminal is a dead-end when custom commands are off** (L18). The nav item is always
visible; clicking it shows a gate pointing to a setting on another screen. **Fix:** hide/disable the visible; clicking it shows a gate pointing to a setting on another screen. **Fix:** hide/disable the
nav item, or let the gate enable the setting inline. *Fixed (both fixes): the nav item is already hidden nav item, or let the gate enable the setting inline. *Fixed (both fixes): the nav item is already hidden
@@ -1630,9 +1681,13 @@ root cause is already filed.
- [x] **UX19 — Window size/position isn't remembered.** [index.ts](src/main/index.ts) opens a fixed - [x] **UX19 — Window size/position isn't remembered.** [index.ts](src/main/index.ts) opens a fixed
920×700 every launch (no bounds persistence). **Fix:** persist + restore window bounds. *Fixed with W2 — 920×700 every launch (no bounds persistence). **Fix:** persist + restore window bounds. *Fixed with W2 —
[windowState.ts](src/main/windowState.ts) persists + restores bounds/maximized with an off-screen guard.* [windowState.ts](src/main/windowState.ts) persists + restores bounds/maximized with an off-screen guard.*
- [ ] **UX20 — "App is still running" surprise.** Closing with a download active hides to tray and - [x] **UX20 — "App is still running" surprise.** Closing with a download active hides to tray and
notifies once per process (L68); later closes give no hint, so the window "won't close" reads as a notifies once per process (L68); later closes give no hint, so the window "won't close" reads as a
bug. **Fix:** a persistent tray hint / first-close explainer. bug. **Fix:** a persistent tray hint / first-close explainer. *Resolved by L68 (the "first-close explainer"
it now is on *every* close): the once-per-process guard was the whole bug — `notifiedBackground` now resets
when the window is shown again, so each close-to-tray with a download active (or tray mode) re-fires the
"AeroFetch is still running in the tray" notification, not just the first. So a later close no longer looks
like the window is stuck. (The tray behavior itself is worth a watched confirm in the Batch 15 live pass.)*
- [x] **UX21 — No keyboard-shortcut discoverability.** Ctrl+K (palette), Enter, Ctrl+Enter are - [x] **UX21 — No keyboard-shortcut discoverability.** Ctrl+K (palette), Enter, Ctrl+Enter are
undocumented; there's no "?"/shortcuts screen. **Fix:** a shortcuts hint or help affordance. undocumented; there's no "?"/shortcuts screen. **Fix:** a shortcuts hint or help affordance.
*Fixed (discoverability): the first-run Onboarding now includes a tip — "Press Ctrl+K anytime to jump to any *Fixed (discoverability): the first-run Onboarding now includes a tip — "Press Ctrl+K anytime to jump to any
@@ -2100,6 +2155,9 @@ earlier passes are listed so this is complete; and the categories that came back
sequential probes (each up to the 180 s `probeFlat` timeout); the UI shows "indexing…" with the Index sequential probes (each up to the 180 s `probeFlat` timeout); the UI shows "indexing…" with the Index
button disabled and **no cancel**. A large channel locks the add-source flow for minutes with no abort. button disabled and **no cancel**. A large channel locks the add-source flow for minutes with no abort.
**Fix:** thread an `AbortSignal`/cancel token and a Cancel button. **Fix:** thread an `AbortSignal`/cancel token and a Cancel button.
*Deferred to the watched Batch 15 live pass: threading an `AbortSignal` through the main-process index walk
(and killing the in-flight probe child) plus a Cancel button is a real feature whose abort behavior needs a
live large-channel index to verify it actually stops the walk mid-probe.*
- [x] **B3 — `extractSha256` ignores the filename (wrong-hash risk).** It returns the **first** 64-hex - [x] **B3 — `extractSha256` ignores the filename (wrong-hash risk).** It returns the **first** 64-hex
token in the file ([updater.ts](src/main/updater.ts)); a combined multi-file checksum uploaded as token in the file ([updater.ts](src/main/updater.ts)); a combined multi-file checksum uploaded as
`<asset>.sha256` would verify the installer against the wrong line's hash. Safe only by the one-hash- `<asset>.sha256` would verify the installer against the wrong line's hash. Safe only by the one-hash-
@@ -2113,6 +2171,10 @@ earlier passes are listed so this is complete; and the categories that came back
canceled item's title. Harmless today, but the asymmetry is a latent bug. **Fix:** guard `meta` like the others. canceled item's title. Harmless today, but the asymmetry is a latent bug. **Fix:** guard `meta` like the others.
- [ ] **B6 — App-update download has no cancel.** Once `downloadAppUpdate` starts there's no user abort — - [ ] **B6 — App-update download has no cancel.** Once `downloadAppUpdate` starts there's no user abort —
only completion or the idle timeout stops it; the progress UI offers no Cancel. **Fix:** expose cancel (abort the request). only completion or the idle timeout stops it; the progress UI offers no Cancel. **Fix:** expose cancel (abort the request).
*Deferred to the watched Batch 15 live pass (ties to CL4): it touches the security-critical updater
installer-download path (the same block CL4 defers) — exposing a cancel means an IPC that calls the
request's `abort()` + teardown, verifiable only by cancelling a real ~300 MB update download and confirming
the partial is cleaned up. Done with the CL4/updater watched work.*
- [x] **B7 — Cookie-login promise can never resolve if the window is destroyed without `close`.** - [x] **B7 — Cookie-login promise can never resolve if the window is destroyed without `close`.**
`openCookieLoginWindow` resolves only via the `close`→`exportAndResolve` path; a `destroy()` (or a `openCookieLoginWindow` resolves only via the `close`→`exportAndResolve` path; a `destroy()` (or a
`closed` without `close`) would leave `pendingResolvers` pending forever. Not triggered by current code, `closed` without `close`) would leave `pendingResolvers` pending forever. Not triggered by current code,
@@ -2167,6 +2229,10 @@ cosmetics. `R` IDs.
deletes the partial; canceled items vanish from the UI while `.part`/fragment files accumulate in the deletes the partial; canceled items vanish from the UI while `.part`/fragment files accumulate in the
output folder. (App crash mid-download likewise — the queue isn't persisted to resume, M4.) **Fix:** output folder. (App crash mid-download likewise — the queue isn't persisted to resume, M4.) **Fix:**
clean up the partial on cancel, or surface/offer-resume for orphans. clean up the partial on cancel, or surface/offer-resume for orphans.
*Deferred to the watched Batch 15 live pass (data-safety): the fix means capturing yt-dlp's destination path
during the run and **deleting** `<dest>.part`/`.part-Frag*` on cancel — file deletion whose correctness
can't be verified without a real download+cancel. Doing it unattended risks deleting the wrong file if the
destination capture is even slightly off, so it must be verified live before shipping.*
- [x] **R5 — Settings write failure is unhandled.** `setSettings` calls `store.set(...)` with no try/catch; - [x] **R5 — Settings write failure is unhandled.** `setSettings` calls `store.set(...)` with no try/catch;
on disk-full/read-only `electron-store` throws → the IPC rejects → the renderer's `setSettings().catch(() on disk-full/read-only `electron-store` throws → the IPC rejects → the renderer's `setSettings().catch(()
=> {})` swallows it while the optimistic UI keeps the change (the disk-full form of M34). **Fix:** catch and report. => {})` swallows it while the optimistic UI keeps the change (the disk-full form of M34). **Fix:** catch and report.
+3
View File
@@ -13,12 +13,15 @@ import type { AddEntry } from './downloads'
* *
* sources --enqueueDownloads--> downloads (queue a channel's media items) * sources --enqueueDownloads--> downloads (queue a channel's media items)
* downloads --downloadCompleted--> sources (mark the source item downloaded) * downloads --downloadCompleted--> sources (mark the source item downloaded)
* sources --sourceRemoved------> downloads (cancel that source's in-flight downloads)
*/ */
export interface StoreEvents { export interface StoreEvents {
/** sources → downloads: enqueue a batch of media items as downloads */ /** sources → downloads: enqueue a batch of media items as downloads */
enqueueDownloads: AddEntry[] enqueueDownloads: AddEntry[]
/** downloads → sources: a download tied to a source MediaItem finished */ /** downloads → sources: a download tied to a source MediaItem finished */
downloadCompleted: { mediaItemId: string; filePath?: string } downloadCompleted: { mediaItemId: string; filePath?: string }
/** sources → downloads: a source was removed; cancel its in-flight downloads (L157) */
sourceRemoved: string
} }
type Handler<K extends keyof StoreEvents> = (payload: StoreEvents[K]) => void type Handler<K extends keyof StoreEvents> = (payload: StoreEvents[K]) => void
+17
View File
@@ -462,6 +462,23 @@ export const useDownloads = create<DownloadState>((set, get) => {
// no direct import from sources, so the two stores stay acyclic. // no direct import from sources, so the two stores stay acyclic.
on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries)) on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries))
// A removed source cancels its in-flight downloads (L157): MediaItem ids are
// `${sourceId}:${videoId}` (indexerCore), so a download's mediaItemId prefix
// identifies its source. Only active (downloading/queued) items are canceled;
// finished/failed ones are left alone. cancel() owns the process kill + pump.
on('sourceRemoved', (sourceId) => {
const st = useDownloads.getState()
const prefix = `${sourceId}:`
for (const it of st.items) {
if (
(it.status === 'downloading' || it.status === 'queued') &&
it.mediaItemId?.startsWith(prefix)
) {
st.cancel(it.id)
}
}
})
// M4: mirror the durable queue to main whenever it changes, so saved/scheduled and // M4: mirror the durable queue to main whenever it changes, so saved/scheduled and
// other pending items survive a quit. Skipped in preview (no IPC bridge). A // other pending items survive a quit. Skipped in preview (no IPC bridge). A
// progress-only update doesn't churn this — toPersisted drops progress/speed/eta, so // progress-only update doesn't churn this — toPersisted drops progress/speed/eta, so
+4
View File
@@ -178,6 +178,10 @@ export const useSources = create<SourcesState>((set, get) => ({
sources: s.sources.filter((x) => x.id !== id), sources: s.sources.filter((x) => x.id !== id),
selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId
})) }))
// Cancel any of this source's downloads still in flight, so removing a source
// 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) window.api.removeSource(id).catch(logError('removeSource'))
}, },