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 +
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
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,
@@ -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
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.
*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] **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
@@ -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
`yt-dlp.exe` on launch while a deep-link/queued download spawns the same binary — a Windows
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
`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
@@ -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;
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).
*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
(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))
@@ -1188,8 +1229,14 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
`aria-label` is gone (aria-checked conveys it).*
- [x] **L156 — `datetime-local` schedule has no `min`.** Past times are selectable, then silently download
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.
*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
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
@@ -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).
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).*
- [ ] **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
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
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
@@ -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
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.*
- [ ] **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
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
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
@@ -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
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.
*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
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-
@@ -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.
- [ ] **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).
*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`.**
`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,
@@ -2167,6 +2229,10 @@ cosmetics. `R` IDs.
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:**
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;
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.
+3
View File
@@ -13,12 +13,15 @@ import type { AddEntry } from './downloads'
*
* sources --enqueueDownloads--> downloads (queue a channel's media items)
* downloads --downloadCompleted--> sources (mark the source item downloaded)
* sources --sourceRemoved------> downloads (cancel that source's in-flight downloads)
*/
export interface StoreEvents {
/** sources → downloads: enqueue a batch of media items as downloads */
enqueueDownloads: AddEntry[]
/** downloads → sources: a download tied to a source MediaItem finished */
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
+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.
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
// 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
+4
View File
@@ -178,6 +178,10 @@ export const useSources = create<SourcesState>((set, get) => ({
sources: s.sources.filter((x) => x.id !== id),
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'))
},