Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified — typecheck + 234 tests + eslint + prettier green). Correctness / bugs: - B3: match the release checksum to the asset's filename line (no wrong-hash verify) - B4: newline-safe metadata probe (one --print with a unit-separator delimiter) - B5 / L88: guard the meta event against canceled items; progress no longer promotes a queued item outside pump() - B7: cookie-login promise always resolves (handles destroy-without-close) - L146: trim parser rejects >2 colon-group times; M36: Library selection counts only actionable rows - L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message, schedule picker min, dead-code/comment cleanup Type safety: - Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed) Resilience / Windows / metadata: - R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth) - W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window; L147 dead macOS branches removed - CL1: shared stdout markers; package/builder metadata (license, homepage, repository, copyright, tsbuildinfo glob) Copy / docs / tests: - M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled - New unit tests for L35 (isValidMediaItem) and L36 (compareVersions) This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard work it builds on: background running + auto-download, library clipboard detection, tray, binary management & library scale, credential encryption at rest, the shared jsonStore and ui/ primitives, and the eslint/prettier tooling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 KiB
Code Audit
Living checklist of audit findings for AeroFetch. Security & correctness were reviewed 2026-06-23 (all fixed — see Completed). The architectural review (2026-06-29) added the structural items; a second polish pass (2026-06-29) added the smaller inconsistencies. Items carry stable IDs so we can check them off as they land this session.
Severity = structural leverage / risk of future bugs, not "app is broken" (it isn't).
Session 2026-06-30 (correctness/safety/strictness pass): landed B3 B4 B5 B7 (checksum-
filename match, newline-safe meta probe, canceled-event guards, cookie-login never-resolve),
L88 L146 (queue/trim edge cases), L168 + L169 (noUncheckedIndexedAccess +
noFallthroughCasesInSwitch now on — 0 errors after 8 real edge-case fixes), R5 (settings
write failure handled), W1 W5 W6 (min window size, seeded folder picker, parented sign-in
window), CL1 (shared stdout markers), L147 (dead macOS branches removed), M8 (StatusChip
already unified the status→label map), M36 (Library selection only counts actionable rows),
L11 (Queue header counts the live queue), L50 (no "saved" for 0 cookies), L156 + L57
(schedule picker min), L159 L15 L3 (dead-code/comment cleanup), package/builder metadata
(L4 L41 L42 L43 L45 L58), user-facing copy (L66 L154; partial M37/SR9 jargon), doc
reconciliation (M25 M26 L80 L81), and new unit tests (L35 isValidMediaItem, L36
compareVersions). All verified: typecheck + 234 tests + eslint + prettier green.
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, PERF8 code-splitting, and the visual UI/UX polish long-tail. These are best done as their own PRs with the app running so the UI can actually be checked.
1.0 Release-Readiness Audit (lead-engineer synthesis)
A pre-release synthesis of the ~385 catalogued findings below — root causes, a release gate, and the consequential issues per dimension (severity · reasoning · fix · benefit). The detailed, ID'd backlog follows. No rewrite required; everything here is incremental.
Verdict
Not 1.0-ready as-is, but close — roughly 3–4 focused PRs away. The foundation is genuinely strong: disciplined security model (context isolation, sandbox, argv-injection defense, host-pinned signed-checksum updater), a clean pure/impure architecture, and good unit coverage of the pure logic. What blocks a commercial 1.0 is a thin layer of (a) real reliability bugs, (b) naive persistence, (c) built-but-unwired features, (d) first-run/perceived-quality gaps, and (e) the absence of enforcement/observability tooling. None require rearchitecting.
Root-cause themes (the 385 findings collapse to 8)
- Built-but-unwired features — command preview (M5), incognito (M6), per-download options/extraArgs (UX1) are plumbed end-to-end with no UI; the ROADMAP even marks them ✅ (M25). Decide wire-or-cut.
- "Two ways to do X" with no enforcement — persistence (M1), validation (CC9), async (CC5), errors (CC6), UI primitives (UI14/UI18/UI19), formatters (M9) — and no ESLint/Prettier to hold any line (CC2).
- Renderer-optimistic state never reconciles + a store cycle — M34/L142 (settings show unsaved values), C2 (downloads↔sources circular import).
- Naive JSON persistence — non-atomic writes (R1), corrupt→silent total data loss (R2), O(n²) full-file rewrites per completion (R3/PERF7), no cache.
- Download-engine reliability gaps — no stall timeout (B1), reverse-order batches (M32), orphan
.parton cancel (R4), retry-during-teardown race (L140). - First-run / perceived quality — light-default theme (SR1), nightly-default yt-dlp (SR2), placeholder icon (W14), stuck "Resolving…" (SR6), progress bar visibly restarting (SR7), unsigned build (SIGNING.md).
- Accessibility & Windows-native feel — focus rings (UI28/29), no list keyboard nav (W7), title-bar
theme (W3), text-field context menu (W4), no
aria-live(W17), input labels (M28). - No enforcement / observability — no lint (CC2),
noImplicitAny:false(M38), no logging (CC8/M29), no production source maps (L170).
Release gate
MUST fix before 1.0 (blockers) — correctness, data safety, security, trust: B1 · M32 · M35 · M34 · R1 · R2 · R3 · H7 · H8 · SR1 · SR6 · SR7 · W14 — all resolved as of this session. Code signing was dropped from the gate: it's a deliberate non-goal (no certificate will be purchased; the SmartScreen prompt on the unsigned build is accepted).
SHOULD fix for 1.0 (high): wire-or-cut the dead features (M5/M6/UX1) · a11y cluster (UI28/29, W4, W7,
W17, M28) · W3 native theming · lint + noImplicitAny (CC2/M38) · dev-jargon copy (M37/SR9) · destructive
confirmations (UX4) · PERF1/PERF2 (per-download redundant work).
DEFER to 1.x: the ~150 Low items · the simplification refactors (SIMP*, do incrementally) · the UI token system + shared primitives (UI/SIMP — high value, larger effort) · i18n · sqlite migration.
By dimension (severity · reasoning · fix · benefit)
Reliability
- [Critical] No download stall timeout (B1). Reasoning:
spawnhas no timeout + no--socket-timeout; a dead connection hangs forever and permanently consumes a concurrency slot. Fix:--socket-timeout+ an idle watchdog that kills+errors via the existingkillTree. Benefit: downloads self-recover on flaky networks. - [Critical] Non-atomic writes + silent corruption→data-loss (R1/R2). Reasoning:
writeFileSyncisn't atomic; a crash mid-write corrupts the store and the next read returns[], silently wiping history/ sources. Fix: atomic write (temp+rename) + back up a corrupt file before resetting, in onejsonStore. Benefit: user data survives crashes; no silent loss. - [High] Reverse-order batch downloads (M32) & history duplicates (M35). Reasoning:
addMany+pumppromote highest-index first;redownloadmints a new id defeating dedup. Fix: reverse the batch enqueue; dedup history by URL. Benefit: playlists download 1→N; clean history. - [High] Optimistic state never reconciles (M34). Reasoning: renderer keeps a value main rejected.
Fix: apply the validated
Settingsthe IPC returns. Benefit: the UI never lies about what's saved.
Performance
- [High] O(n²) media-items rewrite (R3/PERF7). Reasoning: every completion re-reads+rewrites the
whole (≤20k-item) file synchronously. Fix: in-memory cache + batched atomic writes (the same
jsonStore). Benefit: channel downloads stop hitching the main process. - [Medium] Per-download redundant work (PERF1/PERF2). Reasoning:
templates.jsonread + settings decrypt on every spawn even when unused. Fix: gatelistTemplates()on the consent flag; cache decrypted settings. Benefit: lower latency/IO per download. - [Low]
summarizeQueue/pumpO(n) per tick (PERF3/PERF4). Fix: memoize + count-based pump. Benefit: scales to large queues.
Maintainability
- [High] Triplicated Settings/
Api+ the preview mock (C1). Reasoning: a 4th touch-point per IPC method; drift-prone. Fix:DEFAULT_SETTINGSin@shared; one typedmockApi. Benefit: one place to change. - [High] Store circular dependency (C2). Fix: a coordinator/event bus. Benefit: removes a latent init crash.
- [Medium] Duplicated persistence/validation/async/formatters (M1/CC9/CC5/M9). Fix: the
lib/helpers in SIMP1–SIMP5. Benefit: ~−500 LOC and fewer divergence bugs. - [Medium] God files (H1). Fix: SettingsView via
<SettingsCard>/<ToggleField>(SIMP10). Benefit: 1104→~600 lines, reviewable.
Consistency
- [Medium] Two ways to do X across persistence, status chips, segmented controls, buttons, errors (M1/UI18/UI14/UI15/CC6). Fix: one shared primitive/standard each (see "Recommended single style"). Benefit: the app reads as one product.
- [Low] Naming/copy drift (CC1, L95/L150, separators L164). Fix: the conventions in CC1. Benefit: professional finish.
Readability
- [Medium] Magic strings/numbers + long signatures + deep nesting (CL1/CL2/CL4, L10). Fix:
constants.ts, options-objects, extracted helpers. Benefit: faster comprehension, fewer transpose bugs (CL2).
Polish (perceived quality)
- [High] First-run defaults (SR1/SR2/SR3). Reasoning: light-on-dark first launch, nightly yt-dlp by
default, auto-download-new on. Fix:
theme:'system',ytdlpChannel:'stable',autoDownloadNew:false. Benefit: the first 10 seconds feel native and safe. - [High] Stuck "Resolving…" (SR6) & restarting progress bar (SR7). Fix: clear placeholder on error; weight the two merge phases. Benefit: nothing looks hung/glitchy.
- [High] Placeholder icon (W14). Fixed: a designed mark (teal gradient square + top sheen, bold
rounded download glyph over a landing shelf) replaces the flat placeholder; multi-size
.icoregenerated. Benefit: brand credibility. (Unsigned build is a non-goal — no cert; the SmartScreen prompt is accepted.)
User experience
- [High] Per-download options are unreachable (UX1). Reasoning: must change global settings to tweak
one download. Fix: wire the existing
DownloadOptionsForminto the bar (plumbing exists). Benefit: reclaims a whole feature. - [High] Destructive actions have no confirmation/undo (UX4). Fix: confirm Clear/Remove/Delete. Benefit: prevents data loss.
- [Medium] Silent failures on Open/Show (UX6) and no global download status off the Downloads tab (UX9). Fix: surface errors; sidebar badge. Benefit: the app feels responsive and honest.
Accessibility & Windows-native (UX-critical for a Windows product)
- [High] Fragmented/invisible focus + no list keyboard nav (UI28/29, W7). Fix: one focus-ring + roving list focus + Delete/Ctrl+A. Benefit: keyboard- and Narrator-usable.
- [Medium] Title bar ignores in-app theme (W3); text fields have no context menu (W4); no
aria-live(W17); inputs lack accessible names (M28). Fix: syncthemeSource; add an editing context menu; live regions;aria-labels. Benefit: feels like a native, accessible Windows app.
Developer experience
- [High] No enforcement tooling (CC2) +
noImplicitAny:false(M38). Reasoning: style/type safety rely on discipline; implicitanyis allowed. Fix: Prettier + typescript-eslint + CI; re-enablenoImplicitAny. Benefit: the conventions stay true; regressions caught pre-merge. - [Medium] No logging + no prod source maps (CC8/M29/L170). Fix: one leveled file logger at every catch; hidden source maps. Benefit: field crashes become diagnosable instead of invisible.
- [Low] Stale roadmap/docs (M25/M26, L80–L82); release checksum is manual (H8). Fix: reconcile docs;
generate
.sha256inbuild:win. Benefit: trustworthy docs; updates don't silently fail to install.
Suggested PR sequence to 1.0
- Correctness & data safety: B1, M32, M35, M34, and the cached/atomic
jsonStore(R1/R2/R3/PERF7). - Security & trust: H7 (encrypt cookies), H8 (auto-generate checksums). (Code signing is a non-goal — no cert.)
- First-run polish: SR1/SR2/SR3 defaults, SR6/SR7 status, W14 icon, W3 title-bar theme.
- Wire-or-cut + a11y + tooling: UX1 (per-download panel) or remove M5/M6; focus/keyboard (UI28/29, W7,
W4); Prettier+ESLint+
noImplicitAny(CC2/M38). - Incremental cleanup (post-1.0): the SIMP refactors and the Low/UI/UX long tail, behind the new lint gate.
Critical
- C1 — Single source of truth for the IPC mock + Settings defaults.
const PREVIEWredeclared in 8 files;main.tsxreimplements the entireApi(~180 lines); the fullSettingsobject is hand-maintained in 3 places (main/settings.tsDEFAULTS,renderer/store/settings.tsFALLBACK,main.tsxMOCK_SETTINGS). AddDEFAULT_SETTINGStoshared/ipc.ts, extract the preview mock into onemockApi.tstyped asApi, centralizeisPreview. - C2 — Break the
downloads ↔ sourcesstore circular dependency.downloads.tsimportsuseSources;sources.tsimportsuseDownloads. Works only via lazy.getState(). Introduce a coordinator/event bus that owns cross-store reactions.
High
- H1 — Decompose god files.
SettingsView.tsx(1104, ~11 cards, ~30 selector subs + ~20 useState),DownloadBar.tsx(858, ~17 state hooks),store/downloads.ts(614). - H2 — Consolidate scattered utilities.
youtubeId×2; 4 YouTube-URL-parsing variants; byte/speed/eta/duration formatting across 4 modules. - H3 — Fix
probe.ts → download.tsdependency direction (fmtBytesimport); resolves with H2's shared formatter. - H4 — Carry raw numbers across the progress boundary.
DownloadProgressships formattedspeed/etastrings;queueStats.tsre-parses them back to numbers. - H5 — History re-download silently changes quality.
HistoryView.redownloadpasses the storedquality(for format-picker downloads this is a label like "720p · mp4 · 184 MB") back asqualitywith noformatId;buildArgs.videoFormat()has no matching case and falls back tobv*+ba/b(Best). The queued item shows "720p…" while actually fetching Best. - H6 — TerminalView log grows unbounded.
setLines((ls) => [...ls, …])with no cap; a verbose run (-F,--verbose) streams thousands of lines into React state. Every other log/list in the app is capped — cap this too (and/or virtualize). - H7 —
cookies.txtwritten in plaintext with no restrictive perms. cookies.tswriteFileSync(getCookiesFilePath(), …)stores live auth/session cookies unencrypted underuserData. In the portable build that'sAeroFetch-data/next to the exe (USB stick / shared Downloads folder), so anyone with folder access can read a logged-in session. Settings secrets are DPAPI-encrypted (settings.ts) but cookies are not. Encrypt at rest or document the exposure for the portable/shared-PC scenario the app explicitly targets. - H8 — Release checksum is a manual step the updater hard-requires. updater.ts
sets
REQUIRE_CHECKSUM = trueand refuses any update lacking a<asset>.sha256, butbuild:win(electron-vite build && electron-builder --win) never generates one. Evidence indist/: only0.4.1has.sha256files (hand-made); the current0.5.0build does not. If a release is published without manually adding the checksum, every client's in-app update fails ("This release has no checksum … refusing to install"). Generate the.sha256in the build/release script.
Medium
- M1 — Unify JSON persistence.
sources.tshas genericreadJsonArray/writeJson;history.ts/errorlog.ts/templates.tsreimplement it inline. - M2 — Remove dead code:
getDefaultFolder/download:default-folder(channel + preload + handler + mock, zero callers). - M3 — Remove duplicated completion side-effect in
store/downloads.ts(history write +markDownloadedin bothapplyEvent('done')and the preview ticker). Subsumed by C2. - M4 — Decide queue persistence explicitly. Scheduler/queue is renderer-memory only;
saved/scheduled items don't survive a quit. Persist or document as a non-goal in code. - M5 — Command-preview feature is fully built but unwired.
CommandPreviewResulttype +command:previewchannel +previewCommand(download.ts) +formatCommandLine/quoteForDisplay(buildArgs) + preload method all exist, but no renderer component callswindow.api.previewCommand(only themain.tsxmock references it). Wire it into the DownloadBar Options panel, or remove the dead chain. - M6 — Private/incognito mode is plumbed but unreachable.
incognitoflows throughAddOptions→buildItem→ history-skip → the QueueItem "Private" badge, but nothing in the UI ever setsincognito: true. Add the toggle or remove the dead plumbing. - M7 —
newId()duplicated 3× (store/downloads.ts,TerminalView.tsx,TemplateManager.tsx) with divergent fallback prefixes (item-/t-/tpl-). Extract one helper. Fixed: onenewId(prefix)helper; the three copies import it and pass their prefix. The fallback gained a monotonic counter so same-millisecond ids can't collide (subsumes L33/L70). Unit-tested intest/id.test.ts. - M8 — Two download-status→label maps.
STATUS_BADGE(QueueItem) andSTATUS_LABEL(LibraryView) independently map the same statuses (completed = "Completed" vs "Downloaded"). One shared map. - M9 — Three time formatters.
relTime(LibraryView),formatWhen(HistoryView),fmtSchedule(QueueItem) — consolidate into a date util. Fixed: all three moved todatetime.tsand imported by their views; unit-tested intest/datetime.test.ts. - M10 —
.urlshortcut parsing duplicated —parseUrlFile(DownloadBar) vsreadUrlShortcut(main/deeplink). DifferentURL=extractors for the same file format. - M11 — Inconsistent clipboard access. Reads use
navigator.clipboard.readText(paste button) andwindow.api.readClipboard(suggestion watcher); writes usenavigator.clipboard.writeText(copy report). Pick one strategy. - M12 — Shared
errorTextstyle.tokens.colorPaletteRedForeground1is applied inline 12× across 5 files instead of one class. Partially fixed: addeduseErrorTextStyles(error/errorPrefor pre-wrap multi-line output) and adopted it inSettingsView.tsx— the biggest offender — replacing all 6 inlinestyle={{ color: … }}error spans + its localerrorRowTextclass. The remaining red references live in per-componentmakeStylesclasses (DownloadBar/LibraryView/QueueItem/TerminalView), not inline; migrate those to the shared hook incrementally. - M13 — Inconsistent secret-field masking.
updateTokenusestype="password";proxy(may carryuser:pass@) andyoutubePoToken(a token) are plain-text Inputs. - M14 — Settings search mutates React-owned DOM. The search toggles each card's
el.style.displaydirectly; fragile (breaks if a card ever gets a conditionalstyle) and matches ontextContentincl. hidden text. Prefer state-driven filtering. - M15 — Nested interactive controls in
role="button"(a11y). LibraryView's group header is arole="button"div containing<Button>s (All / Download) — invalid ARIA / keyboard semantics. Make the header a real element with sibling buttons. - M16 — No renderer error boundary. An exception in any view unmounts the whole shell to
a blank window. Add a top-level boundary with a recover/reload affordance. Fixed: added
ErrorBoundarywrapping<App/>inmain.tsx. The fallback is dependency-free (no Fluent/theme provider — the crash may have come from that tree), logs viacomponentDidCatch, shows the error message, and offers a Reload button (window.location.reload(); persisted data lives in main, so nothing is lost). - M17 —
statusByUrlcollapses duplicate URLs. LibraryView maps URL→status into oneMap; with "Download anyway" duplicates, a row can reflect the wrong item's state. - M18 — Audio "quality" presets hard-code MP3.
QUALITY_OPTIONS.audiolabels ("Best (MP3)", "320 kbps") are shown even whendownloadOptions.audioFormatis opus/flac/wav, so the label contradicts the actual output format;--audio-qualityis also meaningless for lossless. - M19 — Sidebar-collapsed pref uses
localStoragewhile every other preference uses electron-store — won't back up/restore and is lost in the portable build across machines. - M20 — ProgressBar / native
<select>accessibility. QueueItem/DownloadsViewProgressBars have no accessible name; severalSelects carry both a<Field label>and anaria-label(double announcement). - M21 —
--audio-qualityalways emitted.buildArgspasses--audio-qualityeven for losslessaudioFormat(flac/wav), where it's meaningless; pair with M18's preset/format mismatch. - M22 — Backup export writes secrets in cleartext, silently. backup.ts
exportBackupserializes the decrypted settings (proxy creds, PO token, update token) to a plain JSON file; the UI caption only says "Does not include download history" — no warning that the file contains credentials. Fixed:proxy/youtubePoToken/updateTokenare stripped (set to'') before writing; SettingsView caption updated to say credentials are not included. - M23 —
MAX_ITEMStruncation can silently drop a source's items. sources.tsreplaceMediaItemsdoes[...new, ...others].slice(0, 20000); once the global total exceeds the cap, the tail (another source's items) is dropped on write and only reappears when that source is re-indexed. Cap per-source, or surface it. - M24 —
setSettingsaccepts unvalidated free strings. settings.ts storesrateLimit/proxy/defaultVideoQuality/defaultAudioQuality/youtubePlayerClientas any string. A badrateLimit("abc") only fails at download time; adefaultQualitynot inQUALITY_OPTIONSleaves the Settings dropdown's controlled value blank. Fixed:VIDEO_QUALITY_OPTIONS/AUDIO_QUALITY_OPTIONSadded toshared/ipc.tsand imported in bothsettings.ts(allowlist validation) andstore/downloads.ts(single source of truth).rateLimitvalidated against yt-dlp rate format regex;youtubePlayerClienttrimmed. - M25 — ROADMAP marks unwired features as ✅ shipped. ROADMAP.md Phase C
("Command preview … Surfaced as a Preview command button in the download bar") and Phase D
("Private / incognito mode — a download bar toggle") both carry
[x], but neither is wired in the UI (corroborates M5/M6). The roadmap overstates completion — reconcile the docs or finish the wiring. - M26 — ROADMAP accent palette is wholesale stale. ROADMAP.md Phase E describes "four accent presets — Toffee (original), Slate, Evergreen, Lavender" with a default of Toffee; the shipped theme.ts is rose / coral / amber / teal ("Sunset-to-sea") with a default of teal. The whole section documents a palette that no longer exists.
- M27 — Library batch-enqueue ignores per-item kind. sources.ts
enqueueItemsforcessettings.defaultKind(+ its quality) for every item, so a channel can't be downloaded as audio without flipping the global default — inconsistent with the DownloadBar playlist panel, which offers a per-item video/audio toggle. - M28 — Primary text inputs have no accessible name. The URL field (DownloadBar), add-source
(Library), search (History/Settings), and the Terminal args
Textarearely onplaceholderonly — which is not an accessible name. Addaria-label/<label>to each. (a11y; distinct from M20's Select/ProgressBar.) - M29 — Failures are swallowed everywhere. 29
.catch(() => {})sites across 15 files: nearly every IPC write/read discards its error with no log, no user feedback, and no telemetry. A failed settings write, history add, oropenPathsimply vanishes. Fixed: a sharedlogError(op)helper replaces every swallowed renderer catch with one consistentconsole.error('[AeroFetch] <op> failed:', e)(settings, history, templates, errorlog, sources, App, DownloadBar drag-drop, SettingsView version reads, LibraryView scheduled-sync); main-process sites (ytdlp auto-update, cookies loadURL) log inline. Four genuinely-silent catches left intentionally:pauseDownload/cancelDownload(process kill, state already applied),unlink(cleanup), andclipboard.writeText(browser permission — no user action available). Scope note: this delivers the "log" half — failures are no longer silent. The user-facing toast + a persistent log sink (rendererconsole.errorisn't captured in a packaged build) are deferred to CC8 (electron-log), which the audit already cross-references here. - M30 — A broken contextBridge degrades silently to mock mode. preload/index.ts
only
console.errors ifexposeInMainWorldthrows; the renderer then sees nowindow.electron, flipsPREVIEWtrue, and runs the browser mock (no real IPC) with no visible error. A real bridge failure looks like "preview." Fixed: preload sendsIpcChannels.preloadBridgeFailureon failure; main registersipcMain.once(IpcChannels.preloadBridgeFailure, …)inregisterIpcHandlers()(run before the window is created) and shows an error dialog + quits, so the failure is impossible to miss. - M31 — Default Electron menu (incl. Toggle DevTools/Reload) ships in production. No
Menu.setApplicationMenu(null)is called; withautoHideMenuBarthe default role menu is still Alt-accessible, exposing DevTools/reload to end users. Fixed:Menu.setApplicationMenu(null)called inapp.whenReady()when!is.dev; dev builds keep the menu for DevTools access. - M32 — Playlist/channel batches download in reverse order.
addManyprepends the whole batch in entry order ([entry1…entryN, …old]), butpump()promotes the highest-index queued item first (it reverses, assuming one-at-a-time prepends). So a selected playlist/channel downloads N → 1. Files are still named001…NNNcorrectly (byplaylistIndex), so a partial run leaves the last videos on disk — surprising. Enqueue batches in reverse, or havepump()respect insertion order. - M33 — Documented enqueue batch cap doesn't exist. ipc.ts:629 and
ROADMAP-PINCHFLAT claim the queue pulls "a batch at a time (e.g. 50)" via
MAX_ENQUEUE_BATCHso it "never holds the whole collection at once," but no such cap exists —enqueueItemsaddManys every selected item, so "Download all pending" on a 5,000-video channel puts 5,000 items in the store/queue at once. Implement the cap or fix the comment. - M34 — Optimistic settings updates never reconcile with main's validation. The settings store
does
set(partial)thensetSettings(partial).catch(() => {}), discarding the validatedSettingsmain returns. A value main rejects (e.g. an unsafefilenameTemplate, a clampedmaxConcurrent, a malformedrateLimit) still shows as accepted in the UI until restart — the user believes a setting saved that didn't. Apply the returned authoritative state. - M35 — History re-download creates duplicate rows.
redownloadre-queues viaaddFromUrl, which mints a new id; on completionaddHistorydedups by id (no match) and prepends a second row for the same video. Re-downloading from History accumulates duplicates. Dedup by URL, or reuse the entry id. - M36 — Library checkbox count ≠ "Download N selected". Every item row has a checkbox
(LibraryView.tsx), but
selectedActionablefilters to pending/error/canceled only, so selecting 5 rows (incl. 2 already-downloaded) shows "Download 3 selected." The checkbox count and the button count silently disagree. Fix: only show checkboxes on actionable rows, or count all selected. - M37 — End-user hints leak developer/internal references. Settings hints surface dev-facing
detail: "Requires aria2c.exe in resources/bin (see the README there)", "paste a read-only Gitea
token", "sent via
--extractor-args", "breaking downloads with 403 errors", "open a locked cookie database", and roadmap status ("automatic minting is planned"). These read as code comments, not product copy. Fix: rewrite for end users (hide repo/flag/HTTP-status jargon and roadmap notes). - M38 —
noImplicitAny: falsepartially defeatsstrict. The inherited@electron-toolkit/tsconfigsetsstrict: truebut explicitly"noImplicitAny": false", and neither project tsconfig re-enables it — so a parameter/variable with no inferrable type silently becomesanyproject-wide (the one real hole in an otherwise-strict setup;noUnusedLocals/noUnusedParameters/noImplicitReturnsare on, which is why dead locals don't accumulate). Fix: re-enablenoImplicitAnyintsconfig.node.json/tsconfig.web.json.
Low
- L1 — Module-load
setIntervalinstore/downloads.tsruns on import (incl. tests/preview). - L2 —
index.tsflat IPC registration (~150 lines) — let each main module export its ownregister(ipcMain). - L3 — Stale comments.
getYtdlpVersionJSDoc still calls it the "Step-1 spike";vitest.config.tsclaims tests "only exercise buildArgs.ts" (9 test files now exist). - L4 —
electron-builder.ymlnits.copyright: yt-dlp frontendis not a copyright string (no holder/year); thefilesexcludetsconfig.tsbuildinfonever matches the realtsconfig.web.tsbuildinfo. - L5 — Inline
style={{}}vs makeStyles (25× across 8 files; SettingsView 14×). Notably Sidebar's active-nav inset shadow and DownloadOptionsForm's hint caption use inline styles where a class exists elsewhere. - L6 — Control height mismatch.
Selectis a fixed 32px sitting besidesize="large"(~40px) Input/Buttons in DownloadBar. - L7 —
canceledandpausedshare the 'warning' badge color (QueueItem) — visually ambiguous. - L8 — Index/compound list keys. TerminalView keys log lines by array index; Settings
Diagnostics keys by
id + occurredAtwhile every other list keys byidalone. - L9 — Thumbnail box sizes not shared. Four hand-tuned 16:9 boxes (120×68, 108×64, 72×44, 60×34) with no shared aspect/size constant.
- L10 — Scattered magic numbers. Timeouts/caps spread across modules (probe 60s, indexer 180s, probeMeta 30s, update-idle 60s; MAX_ENTRIES 500/200, MAX_TEMPLATES 100, MAX_ITEMS 20000). Consider a central constants module.
- L11 — "Queue (N)" overcounts. DownloadsView's header count is
items.length(includes completed/error/canceled), not the active queue. - L12 — Command palette polish. No scroll-into-view for keyboard selection in the 50vh
list;
role="dialog"withoutaria-modal/focus-trap; Esc handled only on the input. - L13 — Destructive actions lack confirmation. "Clear history", "Clear log", "Remove source" are one-click — inconsistent with the careful confirm on backup import.
- L14 —
base.cssis bare — no globalbox-sizing,:focus-visible, or font-smoothing baseline, so custom native elements (Select, Hint, segmented controls) get inconsistent focus rings;box-sizingis then set ad hoc on the SettingsView swatch. - L15 —
MediaThumbredundant ternarykind === 'audio' ? 'audio' : 'video'(kind is already that union). - L16 —
relTime/formatWhenverbosity.relTimereturns unbounded "N d ago"; History'sformatWhenalways appends the year, even for the current year. - L17 — TemplateManager regex not validated. An invalid
urlPatternis accepted with no feedback and silently never matches (main'smatchesUrlswallows the error). - L18 — Terminal nav item shown when custom commands are off — leads to a gated dead-end view; consider hiding/disabling it.
- L19 —
App.tsxfocus hack —setTimeout(() => …focus(), 60)after a tab switch. - L20 —
youtubePlayerClientis free text with no allowlist/validation; a typo passes straight to yt-dlp's--extractor-args. - L21 — Inconsistent in-component error handling. SettingsView funnels the app-update
not-ok result into a dedicated
appUpdErrorslot but keeps not-ok duals for version/update/export/import — two patterns in one file. - L22 — DownloadOptionsForm shows both audio + video controls regardless of the chosen kind in the per-download override panel (audio format shown for a video download, etc.).
- L23 — Diagnostics shows first 20 errors with no "showing 20 of N" hint (the copied report includes all).
- L24 —
window.open(htmlUrl, '_blank')in SettingsView is the onlywindow.openin the app (relies on the window-open handler); elsewhere external opens go through IPC/shell. - L25 — Taskbar effect over-fires.
App.tsxsubscribes to the whole downloads store and recomputessummarizeQueue+ sends the taskbar IPC on every store change (each progress tick); throttle or dedupe by computed value. - L26 — Third URL-sniffing path. DownloadBar's drag-drop
firstUrlis a separate "first http(s) line" extractor alongsideuseClipboardLink.looksLikeUrland deeplink's scan. - L27 — DownloadBar Enter probes, not downloads. Enter in the URL field triggers
fetchFormats; there's no keyboard path to actually start the download (LibraryView's Enter runs its primary action). Inconsistent. - L28 — Default kind/quality applied once. DownloadBar seeds from settings via a one-shot ref; changing the default in Settings while on the Downloads tab isn't reflected until reload.
- L29 — UI constant in the store.
QUALITY_OPTIONSlives instore/downloads.tsyet is a presentational constant imported by views — couples UI to the store module. - L30 — Redundant
loading="lazy"onMediaThumbimages already inside a virtualized list (the virtualizer only mounts visible rows). - L31 — Two theme switchers. Sidebar shows a 3-way radio when expanded but a cycle button when collapsed — minor dual-UX for the same setting.
- L32 —
paletteActionsrebuilt every render inApp.tsx(not memoized); harmless today, but it's passed straight into a child. - L33 —
idCounterfallback resets per launch. The non-cryptonewIdfallback (item-${++idCounter}) restarts at 1 each run; theDate.now()-based fallbacks elsewhere can collide within a millisecond. Unify on one robust id helper (see M7). Fixed with M7: the singlenewIdfallback is${prefix}-${Date.now()}-${++counter}, so same-ms ids stay distinct.
Round 3 (2026-06-29) — tests, build metadata, error copy, edge cases:
- L34 — Integration test off by default.
real-download.integration.test.tsisdescribe.skipIf(!RUN);npm testnever exercises a real download, so there's no automated regression for the actual yt-dlp argv path (only the pure builder). - L35 —
isValidMediaItemhas no test — the one persisted-JSON validator of six with no spec. - L36 —
compareVersionsdiffering-length components untested (e.g.1.2vs1.2.0). - L37 —
download.tsformatters untested (parseProgress/fmtBytes/fmtEta, incl. theNApaths). - L38 —
buildArgswebm-thumbnail exclusion branch untested (embedThumbnail && container!=='webm'). - L39 —
CROP_SQUARE_PPAexact-string assertion is a change-detector test (breaks on harmless reformat). - L40 —
clipboardLink.test.tsimports the zustand settings store to test two pure helpers (looksLikeUrl/looksLikeSingleVideo) — couples a pure-fn test to store init (see H2). - L41 —
package.jsonhomepagepoints at yt-dlp's GitHub, not AeroFetch — wrong product URL (surfaced by the NSIS installer). - L42 —
package.jsonhas nolicensefield (ships LGPL ffmpeg + GPL aria2c). - L43 —
package.jsonhas norepositoryfield (real repo is the Gitea instance). - L44 — Vestigial lint excludes.
electron-builder.ymlexcludes.eslintrc/.prettierrcbut there's no ESLint/Prettier config, script, or dependency — no enforced style tooling. - L45 — Three self-descriptions drift: pkg
description"A yt-dlp frontend for Windows" vs buildercopyright"yt-dlp frontend" vs Sidebar caption "yt-dlp frontend". - L46 — yt-dlp-missing error copy differs across 4 modules (download/ytdlp/probe/indexer): "Reinstall AeroFetch, or drop…" vs "Download it into resources/bin/…" vs "Drop it into resources/bin/."
- L47 —
probeMeta30s timeout returns null silently — no surfaced message, unlike every other yt-dlp timeout ("Timed out …"). - L48 —
cleanErrorstrips a leadingerror:for live failures, while rawERROR:text shows elsewhere (errorlog seed / terminal) — inconsistent error normalization. - L49 — Newlines in user-facing error strings. download.ts missing-binary messages embed
\n, which renders awkwardly in inline/Caption error spans. - L50 — "Cookies saved" shown for 0 cookies. Closing the sign-in window without logging in
still writes the file and reports
okwithcookieCount: 0. - L51 — Scheduled daily sync time hardcoded to 09:00 (schedule.ts) — on/off toggle only, no time picker.
- L52 — Installer handoff is a fixed
setTimeout(app.quit, 1500)(updater.ts) — a race on slow machines. - L53 —
dialog.show*Dialog(win!, …)non-null assertions on a possibly-null window (chooseFolder, backup export/import) — can throw if the sender window is gone. - L54 — DownloadBar preview
<img>has noonErrorfallback (inconsistent withMediaThumb's retry). - L55 — QueueItem meta line can show size twice — a probed-format
qualitylabel already contains the size, thensizeLabelis appended again. - L56 — SponsorBlock ON with zero categories silently no-ops (
buildArgsguards onlength > 0) with no UI warning. - L57 — Scheduling a past datetime silently downloads now (
buildItemfuture-only guard), no feedback that the schedule was ignored. - L58 —
chooseFolderpasses the macOS-onlycreateDirectoryproperty — dead option on Windows. - L59 —
THEME_BACKGROUND(index.ts) duplicatespageBackground(theme.ts) — two hand-synced color constants (a "keep in sync" comment guards them). - L60 —
'external-url'channel name lacks thecategory:prefix every other IPC channel uses. - L61 —
taskbarProgressis the loneipcMain.on(fire-and-forget) amid all-invokechannels. - L62 — "Best available" synthetic format hardcodes
ext:'mp4'/hasAudio:true(probe.ts) — mislabels when the chosen container is mkv/webm. - L63 —
audioQuality()unknown label → silent'0'(best) — the audio analog of H5. - L64 —
ARIA2C_ARGSconnection params hardcoded (-x16 -s16 -k1M), no user control. - L65 — Pause uses
taskkill /Flike cancel — a forced kill risks an unflushed.parttail vs a graceful stop. - L66 — Sidebar caption flips "yt-dlp frontend" → "v" once the version loads (text shift on boot).
- L67 — Onboarding has no Skip and can't be revisited (no "show tips again").
- L68 — Background-running notification fires once per process (
notifiedBackgroundnever resets) — won't remind on later window closes. - L69 —
settings.tsmixes path helpers with persistence (getDefaultMediaDir/ensureMediaDirs/getDownloadArchivePathalongside the store) — split apaths.ts. - L70 —
crypto.randomUUIDfallback is effectively unreachable in Electron/Node 26 (typeof crypto !== 'undefined'is always true) — dead defensive branch (see M7). Fixed with M7: the branch now lives once innewId(kept so the fn is total in any host) and is covered by a test that stubscryptoto exercise it. - L71 — Settings folder inputs are
readOnlywith only a Browse button — no way to paste a known path. - L72 — History re-download drops the thumbnail (passes only title/channel), so the re-queued row loses its thumbnail until re-probed.
- L73 — Two verbs for the same probe action — DownloadBar "Fetch" vs LibraryView "Index".
- L74 — Accent swatches are triple-labeled (
aria-pressed+aria-label+title). - L75 — Update-token field always visible in the Software-update card, even when no update is pending — buries an advanced/rarely-needed input.
- L76 — Duplicate-warning may show a placeholder title —
setDup(existing.title)can be thetitleFromUrlplaceholder before metadata resolves ('YouTube video (id)'). - L77 — ffmpeg/ffprobe versions have no re-check affordance (load once; "not found" sticks with no retry) unlike the yt-dlp "Check version" button.
- L78 —
DownloadsViewempty-state copy ("Paste a URL above to get started") doesn't match the actual two-step Fetch→Download affordance. - L79 — SponsorBlock default categories =
['sponsor']only — enabling SB silently covers just sponsors until the user expands categories (expectation gap).
Round 4 (2026-06-29) — doc/code drift, build artifacts, edge cases:
- L80 — ROADMAP wrong file path. ROADMAP.md Phase A says the flags are
emitted "in
buildArgs(src/main/download.ts)" —buildArgslives insrc/main/buildArgs.ts. - L81 — ROADMAP internal contradiction. Phase A: "
--split-chaptersstill TODO"; Phase L:[x]split-chapters done. Phase A wasn't updated when L landed. - L82 — PINCHFLAT roadmap interface sketches are stale. ROADMAP-PINCHFLAT.md
Phase F's
Source/MediaItemcode blocks omit shipped fields (videoId,itemCount,watched,feedUrl) — design sketches that drifted fromshared/ipc.ts. - L83 — Dead build artifacts. electron-builder emits
.blockmapdifferential-update files for each NSIS installer, but the custom updater.ts does a full download and never consumes them. - L84 —
dist/accumulates unboundedly. ~620 MB per build with no cleanup; it currently holds 0.1.0–0.5.0 Setup + portable artifacts (~3 GB). Add a clean step or prune. - L85 —
.claude/launch.jsonis committed and misplaced. A VS Code launch config lives in.claude/(VS Code reads.vscode/launch.json), so no tool consumes it; it's also tracked (not gitignored). - L86 — History select-all + filter change deletes hidden rows.
selectedpersists across a filter change, so "Delete selected" can remove entries no longer visible (surprising). - L87 — History re-download ignores original options.
redownloadre-queues url/kind/quality only; the post-processingDownloadOptionsused originally are lost (current defaults apply). - L88 —
applyEvent('progress')can promote aqueueditem todownloadingoutsidepump()— a latent concurrency edge if a progress event ever arrives before/without the launch transition. - L89 — Inconsistent release artifacts. Only
0.4.1carries.sha256files (hand-made) and the version sequence skips 0.3.x — symptomatic of the manual release process behind H8.
Round 5 (2026-06-29) — code-level micro-inconsistencies & polish:
- L90 — Two idioms for "value in const array." settings.ts
sanitizeOptionsusesARR.includes(x as never)(L116–120) whilesetSettingsuses(ARR as readonly string[]).includes(x as string)(L275/323). Pick one. - L91 — Unsafe tuple cast / fragile encoding.
value.split('|') as [MediaKind, string](SettingsView.tsx:411) trusts a'|'-joinedkind|qualitystring; a value without'|'yieldsundefinedquality. Use a typed pair, not a string. - L92 — Preload names diverge from main fn names.
markSourceItemDownloaded↔setMediaItemDownloaded,syncSources↔syncWatchedSources. Align the names across the boundary. - L93 — SettingsView bypasses the store layer. It calls
window.apidirectly for cookies/ffmpeg/yt-dlp/app-update/backup while using stores for settings/templates/errorlog — mixed data-access in one component. - L94 —
summarizeQueuerecomputed every render in DownloadsView.tsx (nouseMemo) — and again on every store change in App's taskbar effect. Memoize/share. - L95 — "Sign in" hyphenation varies in the Cookies card ("Sign in…", "Site to sign in to", "Sign-in window", "Signing in…").
- L96 — Ellipsis-on-dialog-buttons inconsistent. "Export backup…/Import backup…/Sign in…" use
…(opens a dialog), but "Browse" / "Check for updates" / "Update now" don't. Adopt the…= opens-a-dialog convention. - L97 — Advanced-field placeholders inconsistent. Adjacent fields use an example (
web_safari), a literal(none), andOptional — Gitea access token. Pick one placeholder style. - L98 — "Embed chapters" has no hint while every sibling toggle in DownloadOptionsForm does.
- L99 — URL input has an
idbut no label.input={{ id: 'aerofetch-url' }}with no<label for>/aria-label(placeholder only) — see M28. - L100 — Inconsistent elevation. DownloadBar (
shadow4) and CommandPalette (shadow28) float; every other card (Settings, QueueItem, History, Library, Templates) is flat (border-only). Define an elevation scale. - L101 — No z-index scale. CommandPalette and Hint both hardcode
zIndex: 1000. - L102 — Two mental models for "format." Settings sets it via one combined Video/Audio dropdown
(
FORMAT_OPTIONS); the DownloadBar uses a Video/Audio segmented control + a separate quality dropdown. - L103 — Two busy-indicator patterns. Fetch/Index swap the button icon to a
Spinner; the Settings check/update buttons keep their icon and render a separate adjacentSpinner. - L104 — History isn't virtualized. It renders all filtered rows, while Downloads and Library use
VirtualList. - L105 —
MediaKinddeclared twice. In both ipc.ts and store/downloads.ts; components import it from both. Single source it. Fixed: the store now importsMediaKindfrom@shared/ipcand re-exports it, so the duplicate localtype MediaKindis gone while existingimport { MediaKind } from '../store/downloads'sites still resolve. - L106 — Refresh icons used interchangeably.
ArrowClockwiseRegular(retry, re-download, check-for-new, update-yt-dlp) vsArrowSyncRegular(re-index, check-for-updates) for the same "redo/refresh" idea. - L107 — Spinner sizes mixed —
"tiny"almost everywhere,"extra-tiny"for the QueueItem queued row. - L108 — Main window sets no explicit
title(relies on the document<title>). - L109 — Versions auto-load with no indicator. yt-dlp/ffmpeg versions in About just appear; only the manual "Check" button shows a spinner.
- L110 — One muted-text token, many class aliases.
colorNeutralForeground3is re-declared ashint/sub/meta/srcSub/stats/count/emptyHint/previewMetaacross components. - L111 — Two helper-text mechanisms — Fluent
Fieldhintvs manual<Caption1 className={hint}>; SettingsView uses both. - L112 —
Switchlabeling inconsistent. Settings/DownloadOptionsForm show "On"/"Off" text; Library switches usearia-labelonly (no visible state text). - L113 — Tooltip ≠ aria-label. The Fetch button's Hint says "Fetch formats / playlist" but its
aria-labelsays "Fetch formats or playlist" (DownloadBar). - L114 — Row titles use different components —
Text(QueueItem/History/Templates) vsspan(Library) vsCaption1elsewhere. - L115 — Equivalent row actions labeled inconsistently — "Re-index" is icon+text; the comparable "Retry"/"Remove"/"Re-download" are icon-only.
- L116 — Empty-state structure varies — Downloads (icon + line), History (colored badge + line + sub-hint), Library (icon + line, no sub-hint).
- L117 — Terminal/error-log "empty" aren't centered blocks like the other empty states (inline text instead).
- L118 — Cross-component focus via hardcoded id. App focuses the URL field with
document.getElementById('aerofetch-url')— brittle coupling into DownloadBar's markup. - L119 — "No items" copy varies — "No … yet" (most) vs "Output will appear here." (Terminal) vs "No errors logged." (Diagnostics).
- L120 — Fluent
Cardvs hand-styleddivcards. Only Settings/Onboarding use<Card>; every other card surface is a bespoke styled<div>. - L121 — Raw scrim literal. CommandPalette backdrop is
rgba(0,0,0,0.32)(the only raw rgba in the renderer), identical in light/dark — low contrast over a dark UI. - L122 — "Checking…" indicated twice — the Settings update button changes its label to "Checking…" and a separate Spinner renders beside it.
- L123 —
Caption1is overloaded for hints, metadata, counts, and timestamps — one size doing four jobs. - L124 —
Body1vsTextrole overlap —Body1appears only in empty states/onboarding/gate;Textcarries titles; the split isn't principled. - L125 — Icon sizing has two syntaxes — px strings (
fontSize: '20px') on icon containers vs numericfontSize={28}on inline icons. - L126 — Uneven text truncation — some titles/metas use
nowrap+ellipsis, others wrap; no shared truncation utility. - L127 —
Notificationsets no icon — completion toasts used the default Electron icon thoughgetAppIconPath()existed. Fixed with W13 (cachedgetAppIconImage()passed asiconat both notification sites). - L128 — Preview seed/mock data ships in production. The
PREVIEWruntime check can't be tree-shaken, so each store's seed arrays + fake ticker are bundled into the Electron renderer. - L129 —
Hint alignis silently ignored for left/right placements yet callers still pass it. - L130 — Error-string style is mixed — internal errors are lowercase/no-period ("timed out", "interrupted"); user-facing ones are capitalized with periods. Fine when interpolated, jarring if shown raw.
- L131 — Taskbar
errorstate is ephemeral — it's driven by liveerroritems, so "Clear finished" flips the red taskbar bar back to normal even though failures occurred (out of sync with the persisted error log). - L132 —
clearProbe+ form-reset logic is duplicated in DownloadBar (onUrlChange,download,addPlaylist). - L133 —
Fieldcontrols mixaria-label+ visiblelabel. ManyField label="X"wrap aSelect aria-label="X", so AT announces the name twice (extends M20 across DownloadOptionsForm). - L134 — Inconsistent disabled affordance for busy buttons. Some stay enabled-looking with a
spinner icon (Fetch/Index) while others are
disabledwith adjacent text (Settings) — no single "busy" convention. - L135 —
TextareavsInputfor similar fields. Trim uses aTextarea; the visually-similar single-line fields useInput— fine, but the trim/schedule panels and the template form mix both with no shared field style.
Round 6 (2026-06-29) — behavioral/logic edge cases:
- L136 — Incognito leaks outside history. Even if the (unreachable, M6) private flag were set,
download.tsstill writes failures toerrorlog.json(title + URL) and shows the title in completion notifications — the "private" promise covers only history. - L137 — Stuck 0% for unknown-size downloads.
parseProgressreturnsprogress: 0whentotal_bytes/_estimateare absent (livestreams, some sites), so the bar reads 0% the whole time instead of an indeterminate state. Fixed:parseProgressnow setssizeUnknown: !totalBytesonDownloadProgress; the store threads it onto the item andQueueItemrenders an indeterminate bar + "Downloading…" (instead of a frozen 0%) when it's set, reusing the existing SR7 indeterminate path. - L138 — Clipboard read on every window focus. useClipboardLink.ts
reads the full clipboard on each
focus(and once on mount, possibly beforeclipboardWatchhas loaded — FALLBACK istrue). Reading all clipboard text whenever focused may surprise privacy-conscious users. - L139 — Auto-update vs concurrent spawn race.
runStartupYtdlpAutoUpdatecan overwriteyt-dlp.exeon launch while a deep-link/queued download spawns the same binary — a Windows file-in-use/corrupt-spawn window. - L140 — Retry-during-teardown race.
retryreuses the same id; if the previous process'sclosehandler hasn't removed it fromactiveyet,startDownloadreturns "already running" → markError. - L141 — Renderer history grows unbounded in memory.
useHistory.addprepends without a cap while main capshistory.jsonat 500; in a long session the in-memory list exceeds the persisted cap (resets on reload). - L142 — IPC mutation return values are discarded everywhere. history/templates/sources/settings IPC calls return the authoritative (validated/capped/sanitized) state, but every renderer caller does optimistic-only updates and ignores it — client and persisted state can silently diverge until reload (generalizes M34).
- 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).
- 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).
- L145 —
useClipboardLinkre-offers a dismissed link after a tab switch.lastSeenis per-hook-instance; switching Downloads↔Library remounts it, so a previously-dismissed clipboard link is offered again. - L146 —
parseTrimSectionsaccepts malformed multi-colon times. The\d+(?::\d{1,2})*pattern passes tokens like1:2:3:4-5:6:7:8, which then reach yt-dlp's--download-sectionsand fail there rather than being rejected up front. - L147 — macOS branches in a Windows-only app.
app.on('activate')and theprocess.platform !== 'darwin'guard inwindow-all-closedare dead on Windows — boilerplate that implies multi-platform support the app doesn't ship. - L148 —
clearFinished/remove can free a slot before the killed process exits. Removing a just-canceled item letspump()launch into the "freed" slot whiletaskkillis still tearing down the prior tree — a brief window over the concurrency cap.
Round 7 (2026-06-29) — copy, a11y patterns & micro-behavior:
- L149 — Hint length wildly inconsistent. Most field hints are one short line, but "Keep running in the tray" is a three-sentence paragraph with a parenthetical. Normalize hint length/voice.
- L150 — "PO token" capitalization varies —
PO Token(ipc.ts/ROADMAP) vsPO token/Proof-of-Origin token(SettingsView). Pick one. - L151 — Mixed range dashes. En-dash in prose ("2–3 is a good balance") vs hyphen in time-range placeholders ("1:30-2:00"). Choose one convention.
- L152 — Search-field sizing differs per screen. History
minWidth 180 / maxWidth 320, Settings inlinewidth: 100%, DownloadBarflexGrow. No shared search-input width. - L153 — Generic "Dismiss" aria-labels. Three close buttons (DownloadBar suggestion + dup, Library suggestion) all read just "Dismiss" — ambiguous to a screen reader. Name what's dismissed.
- L154 — Instructional text in an aria-label. Sidebar theme-cycle button is
aria-label="Theme: Dark. Click to change."— "Click to change" is UI instruction, not a name. - L155 — Accent swatches aren't a
radiogroup. They're a single-select set rendered as plain buttons witharia-pressed, while the Theme controls (also single-select) userole="radiogroup"/radio. Inconsistent single-select a11y pattern. - L156 —
datetime-localschedule has nomin. Past times are selectable, then silently download now (extends L57). Addmin={now}. - L157 —
removeSourcedoesn't cancel in-flight downloads. Removing a source while its videos are downloading lets them finish into the removed source's folders;markDownloadedthen no-ops on the gone item. - 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. - L159 — Dead fallback branch.
copyErrorReportfalls back to "No errors logged." but its button is disabled when there are no entries, so the fallback is unreachable. - L160 — One-off inline margins.
marginTopliterals (Onboarding 2px, QueueItem 4px, SettingsView 8px) instead of style classes (extends L5). - L161 —
height: 100%screens inside a padded scroll container. Downloads/Terminal setheight: 100%while App's<main>isoverflowY: auto+ 24/28px padding — a fragile coupling that can double-scroll or misalign the virtualized list's viewport. - L162 — Per-thumbnail store subscription. Every
MediaThumbcallsuseResolvedDark()(two store subscriptions) instead of receiving a resolved theme prop — N subscriptions per list. - L163 — QueueItem makes 9 separate
useDownloadsselector calls instead of one destructured selection — repeated boilerplate per row.
Round 8 (2026-06-29) — formatting micro-inconsistencies:
- L164 — Metadata separator glyph/spacing is inconsistent. Meta lines join with
' • '(2-space bullet) in QueueItem/History/DownloadBar/DownloadsView, with' · '(1-space midd·dot) in LibraryView, and with' • '(1-space bullet) in QueueItem's "Paused • 42%". Three styles for one role. Standard: one separator constant (glyph + spacing). - L165 — The two byte/speed formatters round differently.
download.ts fmtBytesusestoFixed(v >= 100 ? 0 : 1)("512 MB") whilequeueStats formatSpeedusestoFixed(1)("512.0 MB/s") — visibly different precision for the same magnitudes (compounds the duplicate-formatter issue H2/M9). - L166 — ETA has no hour rollover.
fmtEta(download.ts/downloads.ts) andformatEta(queueStats) emitM:SSonly, so a 2-hour ETA renders as "120:00", whereasfmtDuration(indexerCore) correctly rolls toH:MM:SS. Unify on the hour-aware format. - L167 —
PROGRESS_TEMPLATEis exported but used only insidebuildArgs.ts— a superfluous public export (download.ts parses theprog|lines independently). Make it module-private.
Round 9 (2026-06-29) — type-safety & build config:
- L168 —
noUncheckedIndexedAccessis off. Array/index/tuple access is typed as always-defined, so the exact edge cases already filed compile clean: thesplit('|') as [MediaKind, string]cast (L91/CL2) andparseProgress's positional destructure of a possibly-shortsplit('|'). Enabling it would surface them at build time. - L169 —
noFallthroughCasesInSwitchis off. The largesetSettingsswitch andapplyEventswitch aren't fallthrough-guarded; a missingbreak/returnwouldn't be caught. - L170 — No source maps in production.
sourceMap: false(toolkit) and nobuild.sourcemapinelectron.vite.config.ts, so field crash stack traces are unmapped — combined with no logging (CC8/M29), diagnosing a user-reported crash is very hard. Consider hidden/external source maps. - L171 —
main.tsxmock drift. The preview mock's version strings disagree (getAppVersion→'0.4.0-preview' vscheckForAppUpdate.currentVersion→'0.4.0'), and itssetSettingsskips the real validation/sanitization — so the browser preview can accept settings the app rejects, masking M34-class issues during design. - L172 —
skipLibCheck: true(toolkit) hides type errors in dependency.d.tsfiles — standard practice, low risk, noted for awareness (a dep type regression won't fail typecheck).
UI consistency
Cross-screen review (Downloads, Library, History, Terminal, Settings, Onboarding, Command palette,
Sidebar, cookie sign-in window). Each item: what's inconsistent → Standard: the single rule to
adopt. Items prefixed UI; where a dimension was already filed, the existing ID is referenced
instead of re-filing. The app uses Fluent tokens well (theme-awareness is mostly solid); the gaps
are an undefined spacing/radius/type scale and a drift between Fluent components and hand-rolled ones.
Layout, spacing & width
- UI1 — No shared content width. SettingsView is
maxWidth: 640px(SettingsView.tsx), but Downloads/Library/History/Terminal are full-width; Onboarding card is 460px, CommandPalette 560px. On a wide window Settings is a narrow column while siblings stretch edge-to-edge. Standard: one reading-width token (e.g. 720px) applied by a sharedScreenwrapper, or commit to full-width everywhere. - UI2 — Per-screen vertical rhythm differs. Root section gap is 16 (Settings/Terminal), 20 (Downloads), 18 (Library), 12 (History). Standard: a single section-gap (16px) via a shared screen wrapper.
- UI3 — Card padding has no scale. 14 (QueueItem) / 16 (DownloadBar) / 20 (Settings card) /
32 (Onboarding) /
10px 12px(History row) /12px 14px(Library head) /8px 10px(template row). Standard: a padding scale (e.g. control 8–10, list-row 12, card 16, hero 24) keyed to surface tier. - UI4 — Asymmetric app content padding.
padding: '24px 28px'(App.tsx) — 28px horizontal appears nowhere else. Standard: symmetric or scale-based padding. - UI5 — Action-cluster gaps split 4px vs 8px. Icon-button rows use 4px (QueueItem/History/
TemplateManager) but DownloadBar/Library action rows use 8px. Standard: 4px for icon-button groups, 8px for labelled-control rows. (Good baseline already: every empty-state uses
56px 16px— keep it.)
Corner radius
- UI6 — Thumbnail radius varies for the same element. QueueItem
Large, HistoryMedium, Library rowThumbSmall, DownloadBar previewThumbMedium. Standard: one thumbnail radius (Medium/10px). (Distinct from L9, which is thumbnail pixel dimensions.) - UI7 — Card radius (XLarge vs Large) has no stated rule. Top-level cards use XLarge (16), list rows Large (12), but it's applied by feel. Standard: document surface tiers — page-card XLarge, list-item Large, control Medium — and apply uniformly.
- UI8 — Circular shapes mix token and literal.
borderRadiusCircular(Library pill/watchBadge) vs literal'50%'(SettingsView swatch, History emptyBadge). Standard: always the token.
Typography
- UI9 — Screen titles are inconsistent. Most screens use
<Subtitle2>(Downloads "Queue", Library, Terminal, Settings cards); Onboarding uses<Title2>; History has no title at all (just a count). Standard: one page-title style on every screen. - UI10 — Only Library has a screen subtitle/description. Others jump straight to content. Standard: a consistent header block (title + optional one-line description).
- UI11 — Semantic type ramp vs ad-hoc px. Sidebar
brandName/navItemand LibrarywatchBadge/pillset rawfontSizeBaseXXX;sectionIcon/markuse literal'20px'/'26px'. Standard: Fluent type components/tokens; no literal px font sizes.
Icons
- UI12 — No icon-size scale. Inline
fontSizeof 11/14/16/18/20/22/26/28/40 is scattered across components. Standard: a small set (16 inline / 20 control / 24 section / 40 empty-state). - UI13 — Regular vs Filled weight mixed for one concept.
ArrowDownloadRegular(nav, buttons) vsArrowDownloadFilled(brand mark). Standard: one weight per concept (Filled only for brand/emphasis).
Buttons & controls
- UI14 — Two hand-rolled segmented controls. DownloadBar kind toggle (
segment, padding7px 16px) and Sidebar theme toggle (themeSeg, padding7px 4px) are separate implementations with different padding/markup. Standard: one sharedSegmentedControl. - UI15 — Raw
<button>s alongside Fluent<Button>. Sidebar navItem/iconBtn/themeSeg, DownloadBar segment/plKindBtn, CommandPalette item, and LibrarygroupHead(arole="button"div) are bespoke. Standard: wrap recurring patterns so hover/focus/disabled are uniform (ties to L5/M15). - UI16 — Button size hierarchy isn't applied uniformly. DownloadBar
large; Library "Index"largebut its toolbarsmall; History allsmall; Settings mostly default. Standard: size-by-role (screen primary = large; secondary = medium; row actions = small). - UI17 —
appearance="secondary"used in only two spots (Library "Check for new", Terminal "Stop") while every other non-primary button issubtle. Standard: pick subtle or secondary as the standard non-primary appearance. - UI18 — Two status-chip systems. QueueItem uses Fluent
Badge; LibraryView uses custom colorpillspans for the same item-status concept (and labels differ — see M8). Standard: one shared status-chip component + label map. - UI19 — Suggestion-banner CSS duplicated. Identical
suggestion/suggestionTextstyles in DownloadBar and LibraryView. Standard: a sharedLinkSuggestioncomponent (also kills drift).
Colors & selection
- UI20 — "Selected/active" styling is split. Solid brand (
colorBrandBackground+ on-brand text) for DownloadBar/Sidebar segments, but brand-tint (colorBrandBackground2+colorBrandForeground2) for Sidebar nav, CommandPalette item, Library pill. Standard: one active treatment per control class. - UI21 — Sidebar active-nav rail is expanded-only. The inset brand box-shadow shows only when expanded; collapsed relies on tint alone. Standard: a consistent active indicator in both states.
- UI22 — Brand-icon tiles differ.
mark(on-brand on solid brand) vssrcIcon(brandForeground2 on brand-tint) vssectionIcon(compoundBrand, no tile). Standard: a defined icon-emphasis set.
Dialogs, menus, status, navigation
- UI23 — Fragmented overlay/dialog system. Native OS dialogs (pickers, backup save/open,
backup warning
messageBox), one custom React overlay (CommandPalette), a full-screen view (Onboarding), and an unstyled separateBrowserWindow(cookie sign-in) that shows none of the app's theme/chrome. Standard: keep native for file/confirm; unify in-app overlays under one themed primitive; theme the sign-in window's title/background to match. - UI24 — No context menus anywhere. Every row exposes actions only as inline buttons; right-click does nothing on any screen, despite many per-row actions that conventionally also live on right-click. Standard: add consistent right-click menus mirroring row actions, or note "none by design."
- UI25 — No in-app global status surface. Queue progress shows only on the Downloads tab's summary strip; on any other tab there's no in-app sign downloads are running (only the OS taskbar). Standard: a persistent affordance (e.g. a sidebar "Downloads" badge with the active count).
Animations & transitions
- UI26 — Lone animation. Only the sidebar width transitions (
0.15s ease); tab switches, panel expand/collapse, banners, and Library card expansion are all instant. Standard: one motion policy — either add subtle, consistent transitions (gated onprefers-reduced-motion) or drop the sidebar one.
Hover, focus, disabled, keyboard & accessibility
- UI27 — Command palette uses JS hover, not CSS. Items highlight via
onMouseEntersettingitemActive(no:hover), unlike every other list, and expose noaria-selected/active-descendant, so the highlight is invisible to screen readers. Standard: CSS:hover+ listbox/option semantics. - UI28 — Command palette input has no focus ring. The search
<input>setsoutline: 'none'with no replacement. Standard: a visible focus ring (Fluent stroke token). (a11y) - UI29 — Fragmented focus treatment. Fluent ring (Fluent controls) vs custom border (Select)
vs removed (palette input) vs UA default (Sidebar buttons, segments, and Library
cardHead/groupHeadwhich arerole="button" tabIndex=0with no:focus-visible→ invisible keyboard focus). Standard: one focus-ring style on all interactive elements, custom and native. (a11y; extends L14) - UI30 — Hand-rolled radiogroups aren't arrow-navigable. DownloadBar kind and Sidebar theme
use
role="radiogroup"/radiobut implement click only — no ←/→ roving focus a radiogroup implies. Standard: roving-tabindex arrow keys, or Fluent's RadioGroup. (a11y) - UI31 —
Selectcan't be disabled. The native-<select>wrapper exposes nodisabledprop, so that control can't show a disabled state while Fluent controls can. Standard: adddisabled+ styling. - UI32 — Native-control dark mode is uneven. The DownloadBar
datetime-localsetscolorScheme: 'light dark'explicitly; theSelectrelies on the rootcolorScheme. Standard: setcolorSchemeconsistently on both. - UI33 — No semantic headings. Titles render as
Subtitle2/Title2(styled spans), so there's no h1–h6 hierarchy for screen-reader heading navigation (landmarks<nav>/<main>exist; headings don't). Standard: render titles as real headings (as="h1"/"h2"orrole="heading" aria-level). (a11y)
Top cohesion-breakers (the "not one product" shortlist)
- Status chips — Fluent
Badge(Downloads) vs custom color pills (Library) for the same concept (UI18, M8). - Segmented controls — two separate hand-rolled versions (UI14).
- Buttons — Fluent
<Button>vs many bespoke<button>s with divergent hover/focus (UI15, UI29). - Content width — Settings is a 640px column; every other screen is full-width (UI1).
- Focus rings — four different focus treatments incl. one removed and several invisible (UI28–UI29).
- Screen headers — Subtitle2 / Title2 / none (UI9–UI10).
UX review (first-run perspective)
Walking the app as a new user. Ranked by severity. UX IDs; existing IDs referenced where the
root cause is already filed.
High — core-flow friction
- UX1 — Per-download options are unreachable; you must change global Settings for one video.
The DownloadBar exposes only URL · Video/Audio · quality · Trim · Schedule. There is no
per-download post-processing panel, custom-command panel, command preview, or incognito toggle —
so
DownloadItem.options/extraArgsand the whole Phase A/C per-download-override plumbing are unreachable; every download usessettings.downloadOptions. To grab one video as MP3-with-subs a user must edit global Settings, download, then revert. (Supersedes/expands M5, M6.) Fix: add a collapsible per-download Options panel (reuseDownloadOptionsForm) — the plumbing already exists. - UX2 — "Fetch" vs "Download" is ambiguous, and Enter does the wrong thing. Two unlabeled icon buttons (magnifier = "Fetch", clipboard = "Paste") sit next to "Download"; a first-timer can't tell that Download works without Fetch. Pressing Enter in the URL field runs a probe, not the download (L27) — violating "type URL + Enter = go". Fix: label the action, make Enter download (or fetch-then-download), and present Fetch as optional ("Preview formats").
- UX3 — Library "Index" is jargon and a hidden two-stage workflow. Pasting a channel URL in the Downloads bar tries to treat it as one item; whole channels belong in the Library tab, but nothing signposts that. "Index" reads like "Download" but only catalogs — you must then expand, select, and Download. Fix: rename to "Add channel/playlist," and after indexing surface a clear "Download N videos" next step; detect channel URLs in the Downloads bar and suggest the Library.
- UX4 — Destructive actions have no confirmation and no undo (L13). "Clear history," "Clear log," "Remove source" (deletes an entire indexed channel + items), and "Delete selected" are all one click. A user exploring can wipe data irreversibly. Fix: confirm destructive/bulk deletes (or offer an undo toast).
- UX5 — First run can't choose the download folder. Onboarding.tsx only describes Documents\Video/Audio — there's no picker (the ROADMAP claims one). The user must later discover Settings → Downloads. Fix: put the folder picker in onboarding as documented.
Medium — confusion, feedback & organization
- UX6 — Silent failures on file actions.
openFile/showInFolderin the stores callwindow.api.openPath(...)and ignore the returned error string; reveal.ts refuses missing/moved files or disallowed types and returns an error nobody surfaces. Clicking "Open file" on a moved download does nothing, with no message. Fix: surface open/reveal errors. - UX7 — Settings is one long unsegmented scroll of ~11 cards with no section nav/anchors; the only finder is the hide-cards search (M14). Order is arbitrary and related items are split. Fix: group into sub-sections (or a left rail) with a stable, logical order.
- UX8 — "Default format" vs "Format & post-processing" are two places for one concept. Kind + quality live under Downloads; container/codec/subs/SponsorBlock live in a separate card. Fix: co-locate, or clearly label one "defaults" and the other "advanced post-processing."
- UX9 — No global completion feedback off the Downloads tab (UI25). If OS notifications are off/suppressed (focus assist) and you're on another tab, a finished download gives no in-app sign. Fix: a sidebar Downloads badge / lightweight in-app toast.
- UX10 — Re-download silently changes quality (H5) — a confusing "I asked for 720p, got Best."
- 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).
- 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.
- UX13 — Backup export warns of nothing; it contains secrets (M22). A user "backs up settings" and ships proxy creds / tokens in cleartext. Fix: warn, or mask/omit secrets.
- UX14 — Duplicate warning can show a placeholder title (L76): 'Already in your queue: "YouTube video (abc123)"' before metadata resolves looks broken. Fix: fall back to the URL.
- UX15 — No bulk actions in the Downloads queue. History has a select-mode + bulk delete; Downloads has only per-row remove + "Clear finished." Inconsistent. Fix: mirror select/bulk, or add "Cancel all."
Low — polish & smaller friction
- UX16 — URL field isn't focused on launch.
aerofetch-urlis only focused via the command palette; opening the app or the Downloads tab requires a click to start typing. Fix: autofocus it. - UX17 — Icon-only Fetch/Paste buttons rely on hover tooltips for meaning (UI a11y; new users on touch/keyboard miss them). Fix: labels or
aria-described affordances. - UX18 — The quality control morphs after Fetch (preset list → real formats; label "Quality" → "Quality / format") in place — a surprising transform. Fix: keep a stable control with a clear "formats loaded" state.
- UX19 — Window size/position isn't remembered. index.ts opens a fixed 920×700 every launch (no bounds persistence). Fix: persist + restore window bounds.
- 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.
- 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.
- UX22 — Onboarding is shallow and one-shot (L67): three tips, no folder choice, not revisitable; the clipboard tip requires a focus event a first-timer won't trigger.
- UX23 — Settings folder paths are read-only (L71) — no paste; Browse-dialog only.
- UX24 — "Check N watched for new" is disabled with no explanation when no source is watched — a user with indexed (but unwatched) sources sees a dead button. Fix: tooltip / enable with a hint to watch a source.
- UX25 — Empty-state copy mismatches the flow (L78): Downloads says "Paste a URL above to get started," but the actual path is Fetch/Download.
- UX26 — yt-dlp/ffmpeg version + initial settings load have no skeleton/spinner — brief blank states on boot (plus the documented one-frame theme flash). Fix: lightweight loading states.
Windows platform conventions
Review against Windows 11 / Fluent desktop conventions. W IDs are new; existing IDs are referenced
where a dimension is already filed. The app gets the big things right (native dialogs, per-user
no-admin install, AppUserModelID, tray, taskbar progress, jump list, nativeTheme dark-mode follow,
DPI handled by Chromium). The deviations:
Window behavior & resizing
- W1 — No minimum window size.
createWindowsetswidth/heightbut nominWidth/minHeight(index.ts), so the window can be dragged down to a few pixels and the layout (212px sidebar + content) breaks. Standard: set a sensible min (e.g. 640×480). - W2 — Window placement isn't persisted (extends UX19): size, position, maximized state, and
which monitor are all forgotten — it reopens 920×700 on the primary display every launch.
Standard: persist & restore window placement per Windows app convention (e.g.
electron-window-state). - W3 — Title bar doesn't follow the in-app theme.
nativeTheme.themeSourceis deliberately never set (index.ts:97), so the OS-drawn caption follows the OS theme. Choosing an explicit in-app dark theme on a light OS leaves a light title bar on a dark app (and vice-versa). Standard: setnativeTheme.themeSourceto the resolved mode, or usetitleBarOverlaywith themed colors.
Dialogs & file pickers
- W4 — Text fields have no Cut/Copy/Paste context menu. Electron adds no default editing menu and
the app registers no
context-menuhandler, so right-clicking any input/textarea (URL, search, terminal args, template fields) shows nothing — a basic Windows text-editing affordance is missing. Standard: wire a standard editing context menu (and spellcheck suggestions for textareas). - W5 — File pickers don't open at the current value.
chooseFoldersets nodefaultPath, so the folder dialog opens at a default location instead of the currently-configured Video/Audio folder. Standard: seed the picker with the current path. (Native dialogs themselves are correct — good.) - W6 — Cookie sign-in is a separate taskbar window.
openCookieLoginWindowcreates aBrowserWindowwith noparent/modal(cookies.ts), so it appears as a second AeroFetch taskbar button rather than a child/modal dialog. Standard:parent: mainWindow(+modalif appropriate) so it groups under the app.
Keyboard, context menus & focus
- W7 — Lists have no keyboard navigation. The queue, history, and library lists can't be arrowed through, Delete doesn't remove the focused/selected item, and there's no Ctrl+A select-all — all standard Windows list behaviors. Standard: roving focus + Delete/Ctrl+A on list surfaces. (a11y; see also UI30)
- W8 — No standard accelerators. No Ctrl+, (Settings), F1 (help), or F5 (refresh); Ctrl+K (palette) is the only global shortcut and is undiscoverable (UX21). Standard: add the conventional accelerators + a discoverable shortcut list.
- W9 — Focus isn't restored or advanced. Closing the command palette doesn't return focus to the trigger; finishing onboarding and switching tabs don't move focus into the new content. Standard: restore focus on overlay close; move focus to the activated panel. (extends UI28–UI29)
- (ref) Context menus absent app-wide — UI24; the text-field case is W4.
System tray, taskbar & icons
- W10 — No taskbar attention flash. When a background download completes while the window is
minimized/hidden, the app doesn't
flashFramethe taskbar button. Standard: flash for attention on background completion (pairs with the existing notification). - W11 — No taskbar overlay badge (deferred in roadmap): a download manager conventionally shows an
active-count / error overlay icon via
setOverlayIcon. Standard: add it (the progress bar's error mode is not a substitute). - W12 — Fallback tray icon is single-resolution. The embedded fallback is a 32×32 PNG, so on
150%/200% DPI the tray glyph is blurry when the real multi-size
.icois absent. Standard: a multi-size fallback (or rely only on the.ico). - W13 — Notifications set no icon.
new Notification({title, body})omittedicon; on the portable build (no installed AUMID shortcut) Windows toasts showed a generic icon. Fixed: both notification sites (download.ts completion/failure toast + index.ts background-running toast) now passicon: getAppIconImage()— a new cachedNativeImagehelper in binaries.ts that loads the app.icoonce and falls back to an empty image (OS default) if it's missing. (refines L127) - W14 — App/notification icon is a placeholder (M3-orig) — Fixed: redesigned
icon.svg into a proper mark (teal brand square with a top-lit gradient + soft sheen and a
bold rounded download glyph over a landing shelf), legible down to 16px; regenerated the multi-size
build/icon.ico(256/128/64/48/32/16) via ImageMagick. The notification-icon wiring (passing the asset tonew Notification) is also done — see W13/L127.
High DPI, multiple monitors, touch
- W15 — Software rendering is global. Hardware acceleration is disabled for a documented GPU workaround; on high-DPI/large windows this trades GPU compositing for CPU, which can be sluggish. Standard: re-enable HW accel where the target GPU allows (the memo already flags revisiting this).
- W16 — Touch targets too small / hover-only labels. Icon-only row actions are ~32px, 4px apart,
4 per row (below the ~40px touch guideline), and their only labels are hover tooltips (
Hint) that never appear on touch. Standard: ≥40px touch targets and non-hover labels. (extends UX17)
Dark mode & accessibility (Windows-specific)
- W17 — No
aria-livefor status changes. Narrator doesn't announce download progress, completion, or errors — there are no live regions. Standard: polite live regions for queue/status updates. - W18 — Custom colors unverified under High Contrast. The status pills, segmented controls, accent
swatches, and thumbnail tints use explicit background colors that Windows forced-colors mode may not
adapt (nothing sets
forced-color-adjust). Standard: test under each Contrast theme; let system colors win. - (ref) No semantic headings / radiogroup arrow-nav / focus rings — UI33, UI30, UI28–29 (all bear on Narrator + keyboard users).
Settings & standard conventions (mostly OK)
- W19 — Window/taskbar don't reflect state. No window title at all (L108), so the taskbar button never shows "3 downloading" or similar. Standard: reflect activity in the title/tooltip.
- (OK) Per-user no-admin install, NSIS uninstaller,
aerofetch://registration, AppUserModelID, tray tooltip + left-click restore + right-click menu, taskbar progress, and jump list all follow Windows conventions. (Non-goal) unsigned binary → SmartScreen prompt — accepted; no certificate will be purchased (the build stays signing-ready via env vars if that ever changes — see SIGNING.md).
Code consistency & conventions
A cross-cutting consistency pass over the dimensions requested. Many specifics are already filed; this
section consolidates them by theme and — the point of the exercise — recommends one standard each.
CC IDs are the consolidated items; existing IDs are referenced, not re-filed. The codebase is actually
stylistically consistent (no-semicolon, single-quote, 2-space) and has a genuinely good pure/impure
split — but that style is unenforced and several "do the same thing two ways" seams have crept in.
- CC1 — Naming conventions. Boolean settings have no convention:
useAria2c(verb-prefix),autoUpdateYtdlp(auto-prefix),customCommandEnabled(suffix),downloadArchive/restrictFilenames(bare). Keys sayvideoDir/audioDirwhile the UI says "folder." Preload names diverge from main (L92);MediaKindis declared twice (L105). Standard: booleans asis/has/should/<verb>consistently; one term ("folder") across keys + UI; align preload↔main names; single-source shared types in@shared. - CC2 — Coding style is consistent but unenforced. No ESLint/Prettier config, script, or dep
(L44), so the (good) house style drifts only by discipline;
??vs||is occasionally misused for null checks. Standard: add Prettier + typescript-eslint withlint/formatscripts in CI; codify the existing style. - CC3 — Different patterns for the same problem. JSON persistence (M1), status chips (UI18),
segmented controls (UI14), buttons (UI15), overlays/dialogs (UI23), three yt-dlp probe spawners
(
probeMedia/probeMeta/probeFlat), and duplicated stdout line-buffering indownload.tsandterminal.ts. Standard: one helper per concern (ajsonStore, aStatusChip, aSegmentedControl, one spawn-and-stream helper). - CC4 — Duplicate utilities. Formatting (M9),
youtubeId(H2),newId(M7), JSON I/O (M1),MediaKind(L105) — plusexecFileis hand-wrapped in anew Promisein five places (probe/ytdlp/ffmpeg/download.probeMeta/indexer.probeFlat) instead of onepromisifyd helper, and the stdout newline-split loop is copied in two. Standard:src/main/lib/+src/renderer/src/lib/with one each of:format,youtube,jsonStore,ytdlpExec(promisified spawn + line stream). - CC5 — Async patterns are mixed. Three I/O idioms: callback-wrapped
new Promise(execFile ×5),async/await+fetch(updater check, sync), and event-emitter-wrapped Promise (net.requestin updater). Renderer mixes.then().catch()(stores) withasync/await+try/catch (components). Standard:async/awaiteverywhere; a sharedexecFileAsync; wrap event-emitter APIs once in a helper. - CC6 — Five failure-signaling conventions.
{ ok, error }result objects (download/updater/ ytdlp/backup/IPC),throw(assertHttpUrl),null(indexerCore),[](history/sources read errors), and a bare error string (reveal.safeOpenPath).cleanErroris applied to some spawn stderr but not ytdlp/ffmpeg. Standard: aResult<T>({ ok, value?/error? }) across every service/IPC boundary;throwonly inside pure helpers, caught at the boundary; always run yt-dlp stderr throughcleanError. - CC7 — Dependency-injection styles. Pure modules take deps as params (good —
binDir,now); the impure shell uses module singletons (getSettings(),getYtdlpPath(), lazygetStore()); progress is callback-injected; theWebContentssender is a param in some handlers and a closure in others. Standard: keep pure-core param injection; in the shell, passwc/onProgressconsistently as the leading argument and keep singleton access for config/binaries. - CC8 — No logging strategy. Effectively no diagnostics — a single stray
console.errorin preload, and ~29 swallowed catches (M29);errorlog.tsis domain data, not logging. Standard: one small leveled logger (e.g.electron-log) written to userData, called at every catch; keeperrorlog.tsfor user-facing download failures only. - CC9 — Four validation styles. Type-guard predicates (
isValid*in validation.ts), coerce-with-fallback (sanitizeOptions,templates.sanitize), an imperative per-keyswitch(setSettings, M24), andthrow(assertHttpUrl). Standard: one schema layer (e.g.zod) that both validates and coerces;setSettingsruns values through the same schema instead of a bespoke switch. - CC10 — Mixed serialization/persistence.
electron-store(settings) and hand-rolled pretty-JSON files (history/errorlog/templates/sources/media-items, M1) for the same job, plus yt-dlp-mandated formats (Netscape cookies, plaintext archive) and base64enc:v1:secrets. Standard: onejsonStore<T>()abstraction for the app's own records; pick either electron-store or the JSON stores for everything, not both; leave the yt-dlp-format files alone. - CC11 — Configuration is scattered.
electron-store+localStorage(sidebar, M19) + env vars (PORTABLE_EXECUTABLE_DIR,CSC_LINK,AEROFETCH_REAL_DOWNLOAD) + hardcoded module consts (update host/owner/repo, timeouts, caps,09:00,ARIA2C_ARGS, L10). Standard: aconfig.tsfor build/host constants; foldlocalStorageUI prefs into the settings store so there's one persisted-prefs source. - CC12 — Project organization. Renderer
components/mixes screens (views) with reusable widgets; helpers (theme/thumb/useClipboardLink) sit at src root; the purequeueStatslives instore/; main mixes pure (buildArgs/validation/indexerCore/ytdlpPolicy) and impure modules in one flat dir. Standard: rendererviews/+components/+lib/; maincore/(pure) + services; movequeueStatstolib. - CC13 — View/state boundary (the project's "MVVM"). It's React+Zustand, but the container/
presentational split is inconsistent: orchestration lives in stores for downloads/sources yet inside the
component for DownloadBar, and SettingsView calls
window.apidirectly (L93, UX1). Standard: stores/ hooks are the view-models (own orchestration + all IPC); components stay presentational; nowindow.apiin components. - CC14 — State ownership is unprincipled. State lives in Zustand stores, component
useState,localStorage, the mainelectron-store(mirrored into a renderer store), and module-level vars (idCounter,notifiedBackground,active). Persisted state is optimistic-only and never reconciled (M34/L142); two stores form a cycle (C2). Standard: main owns persisted state; renderer stores mirror it and apply the authoritative value the IPC call returns; UI-only state in stores; break store cycles via a coordinator.
Recommended single style (apply project-wide)
- Errors: one
Result<T> = { ok: true; value } | { ok: false; error }across every service/IPC boundary;throwonly in pure helpers; onelogger(leveled, file-backed) invoked at every catch. - Async:
async/awaitonly; oneexecFileAsync+ one spawn/stream helper; wrap event-emitter APIs once. - Validation: one schema lib (zod) that validates and coerces; reuse it in
setSettings, backup import, and JSON-store reads. - Persistence/serialization: one
jsonStore<T>()(or electron-store) for app records — not both; one config module for host/build constants; one persisted-prefs store (nolocalStorage). - Shared code:
lib/for pure utils (format, youtube, ids, jsonStore);@sharedis the only home for cross-process types (no re-declaredMediaKind). - UI: design tokens (spacing/radius/type/icon scales) + shared primitives (
Screen,StatusChip,SegmentedControl,LinkSuggestion, button/focus); stores are view-models, components presentational. - Naming: booleans
is/has/should/<verb>; "folder" not "dir"; preload methods match main names;verbNounfor store actions,onXfor component handlers. - Tooling: Prettier + typescript-eslint with
lint/format/CI gates so all of the above stays enforced rather than aspirational.
Code cleanliness
A dead-code / clutter review. First, the good news — the codebase is genuinely clean on most axes:
no TODO/FIXME/HACK/XXX in source (only in roadmap docs), no commented-out code, no
debugger/debug logging (the lone @ts-ignore ×2 in preload is the legitimate browser-fallback
window assignment), every source file is imported, and the dead IPC surface is exactly the two already
filed. Several listed categories are N/A to this stack and worth recording so they're not re-asked:
- Unused XAML / WPF — N/A (Electron + React/HTML, no XAML).
- Unused strings / localization — N/A (no i18n/string-resource files; strings are hard-coded, i18n deferred), so nothing to be "unused."
- Unused images / icons — none: the only assets are
build/icon.{ico,svg}(used) and the inline base64 fallback tray PNG (used); thumbnails are remote. (Clutter, not shipped:dist/~3 GB of old installers — L84;.frames/19 gitignored captures.) - Unused files / resources — none in
srcorresources/bin(yt-dlp/ffmpeg/ffprobe all used; aria2c optional).
Dead / unreachable code (the real list):
- Safe to remove now —
getDefaultFolder(M2): channel + preload method + main handler + mock, zero callers. Pure deletion, no behavior change. - Decide "wire or remove" — command preview (M5): channel + preload
previewCommand+ mainpreviewCommand()+formatCommandLine/quoteForDisplay(buildArgs, unit-tested) +CommandPreviewResult+ mock. Either surface it (UX1's per-download panel) or delete the whole chain (and its tests). - Decide "wire or remove" — incognito/private (M6):
DownloadItem.incognito+AddOptions.incognitobuildItemhandling + two history-skip checks + the QueueItem "Private" badge. No UI sets it. Add a toggle or strip the plumbing.
- Decide "wire or remove" — per-download options/extraArgs (UX1):
DownloadItem.options/extraArgs,StartDownloadOptions.options/extraArgs,AddOptions.options/extraArgsare plumbed end-to-end but never set by any UI. Largest block of built-but-unreachable code — wire the panel or remove the fields. - Comment, not code —
MAX_ENQUEUE_BATCH(M33): referenced by ipc.ts:629 + the roadmap but never implemented; fix the comment (nothing to delete).
New cleanliness findings:
- CL1 — Magic-string protocol markers duplicated across the emit/parse boundary.
'prog|'and'path|'are hard-coded inbuildArgs.ts(PROGRESS_TEMPLATE/--print after_move:path|…) and again indownload.ts(line.startsWith('prog|')/'path|'). Change one and the other breaks silently. Fix: export shared marker constants from one module. - CL2 — Long positional parameter list.
buildArgs(opts, outputTemplate, o, binDir, access, extraArgs)takes six positional args (andovsoptsis easy to swap). Fix: pass a single options object. - CL3 — Large method:
startDownload(~130 lines) bundles spawn + dual metadata path + four inlinechildevent handlers. Fix: extract the stdout-parse + close/error wiring (pairs with CC3's spawn-stream helper). - CL4 — Deep nesting:
updater.downloadAppUpdate— Promise →net.request→response→datawith nested conditionals/teardown is the hardest-to-follow block. Fix: extract astreamToFilehelper. - CL5 — Superfluous exports.
PROGRESS_TEMPLATE(L167) andgetManagedBinDirareexported but used only within their own module. Fix: make them module-private. - CL6 — Redundant wrappers (minor):
MediaThumb'skind === 'audio' ? 'audio' : 'video'(L15),clearDir=update({[t]:''}), and the storeopenFile/showInFolderthin wrappers aroundwindow.api. Harmless; collapse opportunistically.
Magic numbers / strings (consolidated, see L10): timeouts 15_000/30_000/60_000/180_000,
maxBuffers 4/64/128/256×1024×1024, caps 500/200/100/20000/80, VIRTUALIZE_AT 100,
installer handoff 1500, tickers 650/15_000, stderr tail 4000; magic strings 'enc:v1:',
'persist:aerofetch-login', 'AeroFetchDailySync', '--sync', 'prog|'/'path|' (CL1).
Fix: a constants.ts (CC11) — names over literals.
What can safely be removed (recommendation)
- Now (zero risk): the
getDefaultFolderslice (M2); un-exportPROGRESS_TEMPLATE+getManagedBinDir(CL5). - Now (housekeeping): prune
dist/to the current release (L84); the macOS-only branches if Windows-only is committed (L147). - Decision required (don't silently delete — these are documented features): command preview (M5), incognito (M6), and per-download options/extraArgs (UX1) — each is "wire it or remove it." Given UX1 rates the per-download panel as high value, wiring is likely better than deleting.
- Not removable (intentional): preview seed/mock data +
PREVIEWbranches (C1/L128) — they power the browser-preview dev workflow; consolidate (C1) rather than delete.
Simplification opportunities
Reusable abstractions that shrink the code and read better — no premature optimization, just de-duplication. Organized by the requested categories; each lists the repetition (with refs), the abstraction, and a rough LOC delta (net, after the helper's own cost). Estimates are deliberately conservative.
| # | Category | Repetition (refs) | Reusable abstraction | ~Net LOC |
|---|---|---|---|---|
| SIMP1 | Repeated file handling | JSON read/save+cap+try/catch in history/errorlog/templates; sources already has it (M1, CC10) | createJsonStore<T>(file, isValid, cap) |
−40 |
| SIMP2 | Repeated networking | execFile-in-Promise ×5 (probe/ytdlp×2/ffmpeg/probeMeta/indexer) (CC4/CC5) |
execFileAsync + spawnYtdlpJson() |
−40 |
| SIMP3 | Repeated parsing | byte/speed/eta/duration formatters ×4 (M9, L165, L166) | lib/format.ts (hour-aware, one precision rule) |
−40 |
| SIMP4 | Repeated parsing | YouTube id/URL parsing ×4 (H2) | lib/youtube.ts |
−30 |
| SIMP5 | Repeated logic | stdout line-buffering ×2 + taskkill tree-kill ×2 (download.ts/terminal.ts) (CC3, new) |
lineStream(stream, onLine) + shared killTree(pid) |
−25 |
| SIMP6 | Repeated commands | 7 identical preload on* subscribe/unsubscribe wrappers (new) |
subscribe(channel, cb) helper in preload |
−20 |
| SIMP7 | Repeated event handlers | Set<string> add/remove/toggle/toggle-all in DownloadBar/Library/History (new) |
useSelection() hook |
−30 |
| SIMP8 | Repeated view models | async-action setBusy/try/catch/finally + result + error ×~7 in SettingsView (new) |
useAsyncAction() hook |
−50 |
| SIMP9 | Repeated UI (cards) | hand-styled <div> card surfaces ×6 (UI7/UI20/L120) |
<Surface tier> (or use Fluent Card) |
−30 |
| SIMP10 | Repeated UI (settings) | sectionHeader + Card ×11 and Switch+Field+"On/Off" ×~12 in SettingsView |
<SettingsCard icon title> + <ToggleField> |
−90 |
| SIMP11 | Repeated UI (row actions) | Hint+Button icon-action ×~16 (QueueItem/History/Templates) |
<IconAction label icon onClick> |
−55 |
| SIMP12 | Repeated UI (empty states) | centered icon+text empty block ×3 (L116) |
<EmptyState icon title hint?> |
−25 |
| SIMP13 | Repeated dialogs/banners | suggestion banner ×2 (UI19); status chips Badge-vs-pill (UI18, M8) | <LinkSuggestion> + <StatusChip> |
−40 |
| SIMP14 | Repeated UI (controls) | two hand-rolled segmented controls (UI14) | <SegmentedControl> |
−30 |
| SIMP15 | Repeated validation | type-guards + sanitizeOptions + setSettings switch (CC9, M24) |
one schema (zod) validate+coerce | −60 |
| SIMP16 | Repeated networking | net.request + redirect-revalidate + timeout ×2 in updater (fetchTrustedText/downloadAppUpdate) (new) |
trustedRequest(url, {sink}) |
−30 |
| SIMP17 | Repeated logic (misc) | newId ×3 (M7), PREVIEW ×8 (C1), (ARR as readonly string[]).includes ×N (L90) |
lib: newId, isPreview, isMember |
−15 |
| SIMP18 | Repeated converters | MediaKind ×2 (L105), kind|quality string encode/decode (CL2/L91) |
single-source type + typed pair | −10 |
Highest-value (do these first)
- SettingsView decomposition (SIMP10 + SIMP8 + SIMP11). A
<SettingsCard>+<ToggleField>+useAsyncAction+<IconAction>would take the 1104-line file to roughly ~600, and the per-card split (H1) falls out for free. Biggest single readability win. createJsonStore(SIMP1) collapses three near-identical persistence modules to thin configs and removes the M1/CC10 "two ways to persist" seam.lib/format.ts+lib/youtube.ts(SIMP3/SIMP4) remove the most-copied pure utilities and fix the formatter divergences (L165/L166) at the same time.execFileAsync/spawnYtdlpJson(SIMP2/SIMP5) unify five spawn sites + the duplicated kill/line-buffer.
Estimate
Gross duplicated lines removable ≈ 650–750; the new helpers/primitives add back ≈ 150–200, for a
net reduction of ~550–800 lines (~5–7% of the ~12.5K total). Concentration: SettingsView ~−450
(1104 → ~600), the JSON stores ~−40, main spawn/format/util helpers ~−120, shared UI primitives
~−150. The real payoff is readability and killing the "two-ways-to-do-X" seams (M1/M8/M9/UI14/UI18/CC*),
not the line count. Caveat: stop short of over-abstracting — a createMirroredStore factory or a
generic form engine would cost more clarity than it saves; keep abstractions at the "one obvious helper" level.
Bug hunt (execution-path review)
Tracing real code paths for latent bugs by category. New bugs get B IDs; the genuine bugs found in
earlier passes are listed so this is complete; and the categories that came back clean are recorded
(bug-hunting that finds nothing is a result too).
New bugs
- B1 — No stall/idle timeout on downloads (resource leak + stuck slot).
spawn(ytdlp, …, { windowsHide: true })(download.ts:306) sets notimeout, andbuildArgsemits no--socket-timeout. A hung connection means yt-dlp never exits → theactivemap entry and the renderer'sdownloadingitem persist forever, permanently consuming a concurrency slot with no recovery or feedback. (The app-updater download does haveDOWNLOAD_IDLE_TIMEOUT_MS; downloads don't.) Fix: pass--socket-timeoutand/or an app-side idle watchdog that kills + errors a stalled child. - B2 — Source indexing can't be cancelled.
indexSourcewalks a channel's playlists with sequential probes (each up to the 180 sprobeFlattimeout); 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 anAbortSignal/cancel token and a Cancel button. - B3 —
extractSha256ignores the filename (wrong-hash risk). It returns the first 64-hex token in the file (updater.ts); a combined multi-file checksum uploaded as<asset>.sha256would verify the installer against the wrong line's hash. Safe only by the one-hash- per-asset convention. Fix: match the hash on the asset's filename line. - B4 —
probeMetaassumes one line per--printfield. It doesstdout.split('\n')→[title, uploader, duration](download.ts); a title containing a newline shifts channel and duration by a line. Fix: use a single--printwith an unlikely delimiter, or-J. - B5 — Missing status guard on the
metaevent. Betweencancel()and the child'sclose,active.has(id)is briefly true, soprobeMetacansendametaevent for a just-canceled item;applyEvent'smetacase (unlikeprogress/done/error) has no canceled guard, so it updates a canceled item's title. Harmless today, but the asymmetry is a latent bug. Fix: guardmetalike the others. - B6 — App-update download has no cancel. Once
downloadAppUpdatestarts 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). - B7 — Cookie-login promise can never resolve if the window is destroyed without
close.openCookieLoginWindowresolves only via theclose→exportAndResolvepath; adestroy()(or aclosedwithoutclose) would leavependingResolverspending forever. Not triggered by current code, but a latent never-resolve. Fix: also resolve/reject onclosed.
Real bugs already filed (confirmed in this pass)
- M32 playlist/channel batches download in reverse order · M35 History re-download duplicates rows
· M34 optimistic settings never reconcile (UI shows unsaved values) · L88 a
progressevent can flip aqueueditem todownloadingoutsidepump()· L139 startup yt-dlp auto-update vs a concurrent download spawning the same exe (Windows file-in-use) · L140 retry-during-teardown hits the "already running" guard · L148clearFinishedcan free a slot before the killed tree exits (brief over-cap) · L157markDownloadedno-ops afterremoveSource(orphan downloads).
Came back clean (no bug found)
- IPC-after-destroy: every
wc.send/e.sender.send/mainWindow.*is guarded byisDestroyed(). - Threading / UI threading / synchronization: single JS thread in main and renderer; React state
updates are always on-thread; the
activemap + module vars are main-thread-only — no data races. - Infinite loops: the stdout line-buffer advances each iteration;
parseExtraArgs/parseRssVideoIdsregexes can't zero-width-match; no self-recursivepump. - Stream disposal (updater):
downloadAppUpdatehas a singlefinish()that aborts the request, destroys the file stream, and unlinks the partial — correct on every failure path. - Event-listener cleanup: component
useEffectsubscriptions return their unsubscribe; module-level subscriptions are app-lifetime by design. - Off-by-one / boundary:
parseProgressguardsparts.length < 6; playlist/collection indexing is 1-based throughout;MAX_ENTRIESslices are correct;fmtBytesclamps the unit index.
Resilience & failure-mode review
A fresh lens: what happens when the environment fails (disk full, permission denied, corrupt file,
killed mid-write, huge inputs) rather than when the logic is wrong. These are robustness findings, not
cosmetics. R IDs.
- R1 — JSON stores are not written atomically (corruption on crash/power-loss). The hand-rolled
stores (history/errorlog/templates/sources/media-items) persist via
writeFileSync(file, JSON.stringify…)— not write-temp-then-rename. A crash/power-cut mid-write truncates the file; the next read hits a parse error and returns[].electron-store(settings) is atomic, so durability is inconsistent across the app. Fix: an atomic write (temp + rename, orwrite-file-atomic) in the sharedjsonStore(SIMP1). - R2 — A corrupt/invalid store silently loses all data.
readJsonArray/listHistoryetc. catch a parse error and return[]with no warning, backup, or recovery — one bad byte wipes the user's history/ sources/templates from their perspective. Fix: on parse failure, back up the bad file (.corrupt) and surface a notice. - R3 — O(n) full-file rewrite per download completion (≈O(n²) per channel), synchronously on the
main thread.
setMediaItemDownloaded(sources.ts:126) reads + parses the entiremedia-items.json(up toMAX_ITEMS= 20,000) and rewrites the whole file on every completion, andaddHistoryrewrites all ofhistory.jsonlikewise. Downloading a 1,000-video channel ⇒ ~1,000 full read-parse-write cycles of a multi-MB file, blocking the IPC/event loop each time. Fix: in-memory cache + debounced/batched atomic writes (or the deferred better-sqlite3). - R4 — Canceled downloads orphan
.partfiles.cancelDownloadtree-kills yt-dlp but never 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. - R5 — Settings write failure is unhandled.
setSettingscallsstore.set(...)with no try/catch; on disk-full/read-onlyelectron-storethrows → the IPC rejects → the renderer'ssetSettings().catch(() => {})swallows it while the optimistic UI keeps the change (the disk-full form of M34). Fix: catch and report. - R6 — Silent data loss on any store write failure. A completed download that can't be recorded
(disk full) shows "Completed" in-session but is gone on restart, with no error (the write
catchis empty, M29). Fix: detect write failure and warn. - R7 —
safeStorageunavailable ⇒ secrets silently stored as plaintext.encryptSecretfalls back to plaintext when DPAPI/safeStorage isn't available, with no indication — compounds H7 (cookies) and the backup-cleartext issue (M22). Fix: warn (or refuse to store) when encryption is unavailable. - R8 — Pretty-printed JSON for large stores.
JSON.stringify(value, null, 2)inflates size/write time for the potentially-20k-item media store (ties R3). Fix: compact JSON for the large stores. - R9 — Clock-skew sensitivity.
shouldAutoCheckYtdlpand the scheduled-download promoter compareDate.now(); a backward system-clock correction can skip or double-fire a check/schedule. Minor; note for awareness.
Ship-readiness (perceived quality)
Obsessive "does it feel finished" pass. The biggest untapped vein is defaults (first-run impressions),
plus a few stuck-status / progress glitches. SR IDs; already-filed perceived-quality items are listed at
the end so the picture is complete. Verified-clean first: card/label/button text is consistently
sentence case, and a careful read found no spelling typos — the polish gaps are defaults, jargon, and a couple of stuck states, not sloppy text.
Poor / risky defaults (first-run impressions)
- SR1 — Theme defaults to
'light', not'system'. A dark-mode Windows user gets a bright white app on first launch despite the app fully supporting "follow system." Single most-noticeable first-run miss. Fix: defaulttheme: 'system'. - SR2 —
ytdlpChanneldefaults to'nightly'. The app ships auto-pulling yt-dlp's daily, less-tested build (auto-update also on by default); one nightly regression breaks downloads for everyone. Defensible for chasing YouTube changes, but a risky default for a commercial release. Fix: considerstableas the shipped default. - SR3 —
autoDownloadNewdefaults totrue. The moment a user marks a channel "watched," new uploads auto-download with no per-event consent — can quietly fill a disk. Fix: default off (let watching be "notify," downloading be opt-in). - SR4 —
clipboardWatchdefaults totrue. The app reads the clipboard on every window focus from first launch (R7/L138). A privacy-conscious default would be off or first-run-prompted. Fix: default off, or ask on first run. - SR5 — Audio defaults to MP3 (lossy).
defaultAudioQuality: 'Best (MP3)'+audioFormat: 'mp3'give a lossy re-encode by default; m4a/opus are higher quality at the same size. Minor (MP3 = max compatibility), but "Best" implying MP3 is a quality mismatch (M18).
Stuck states & progress glitches
- SR6 — "Resolving…" can stick forever. A queued item's channel shows the
'Resolving…'placeholder until ametaevent arrives; if metadata never resolves (error before meta, or no channel in the probe), the row reads "Resolving… • 1080p • Video" permanently — looks hung. Fix: clear the placeholder on error / when meta fails. - SR7 — Progress bar visibly resets mid-download. A video+audio merge downloads as two streams, so the bar fills 0→100%, then resets to 0→100% again before muxing — looks like a glitch/restart to the user. Fix: weight the two phases (e.g. 0–50% / 50–100%) or show a "merging…" state.
- SR8 — Window title never reflects state (L108/W19): always the static "AeroFetch," so the taskbar can't show "3 downloading." A finished-feeling app updates its title/tooltip with activity.
Wording / jargon polish
- SR9 — Dev jargon in user-facing option hints (extends M37): "mux them into the video," "Tiebreaker,
not a hard filter," "Endcards / credits" ("End cards"), "Proof-of-Origin token," "
--extractor-args." Reads like engineer notes. Fix: plain-language hints. - SR10 — Inconsistent product self-description / casing surfaced to users: "yt-dlp frontend" caption flips to "v0.5.0" on load (L66); "Sign in" vs "Sign-in" (L95); "PO token" vs "PO Token" (L150).
Also reduces perceived quality (already filed — listed for completeness)
- Onboarding has no folder picker / feels thin (UX5/UX22); no boot loading state + one-frame theme flash
(UX26); refresh-icon mismatch ArrowClockwise vs ArrowSync (L106) and Regular/Filled brand icon (UI13); only
the sidebar animates, everything else snaps (UI26); separator glyph/spacing inconsistency
•/·(L164); the command palette is undiscoverable (UX21). (The unsigned-build SmartScreen prompt is accepted as a non-goal — no certificate.)
Ship-blockers (highest perceived-quality impact): SR1 (light-on-dark first launch), SR7 (progress visibly restarting), SR6 ("Resolving…" stuck), and W14 (placeholder icon) — all now resolved. The unsigned-build SmartScreen prompt is accepted as a non-goal (no certificate). Together these were most of the "this feels finished" gap.
Performance (no premature optimization — real hot paths only)
A fresh lens: redundant work on hot paths and scaling cliffs, not micro-tuning. PERF IDs. Verified
acceptable first: memoized QueueItems don't re-render when their item is unchanged (the applyEvent
map preserves references for untouched items); the queue + large library lists are virtualized;
electron-store is constructed lazily. The issues:
- PERF1 —
getSettings()is heavy and on every hot path. It runs per download spawn (twice — inbuildCommandand themaxConcurrentcheck), per completion (notify), and on the system-theme bridge; each call re-reads the whole store and, for users who've configured a proxy/PO-token/update-token, runs up to 3safeStorage.decryptString(DPAPI) calls. Fix: cache the decrypted settings, invalidate on write. - PERF2 —
templates.jsonis read+parsed on every download.resolveExtraArgsevaluatestemplates: listTemplates()eagerly as an argument (download.ts:182) beforeselectExtraArgscheckscustomCommandEnabled, so the file is read even when custom commands are off. Fix: pass a lazy getter, or short-circuit on the gate first. - PERF3 —
pump()is O(n) per queue event. Itfilter+reverse+slices the entire items array on every add/done/error/cancel/progress-driven change; with M33's unbounded channel enqueue (thousands of items) every event becomes O(n), and several events fire per second during active downloads. Fix: track running/queued counts + a pointer instead of rescanning. - PERF4 —
summarizeQueueruns twice per progress tick. Once in App's store subscription (which also pushes a taskbar IPC every tick, L25) and once in DownloadsView's render (nouseMemo, L94) — each O(items), every ~second per active download. Fix: compute once, memoize, throttle the taskbar push. - PERF5 —
VirtualListgets new function identities each render.estimateSize/getKey/renderItemare passed as inline arrows, so the virtualizer can't rely on stable references (risking re-measures/cache churn). Fix:useCallback/hoist them. - PERF6 — Per-thumbnail store subscriptions. Each
MediaThumbcallsuseResolvedDark()(two store subscriptions) (L162); a long list creates N×2 subscriptions. Fix: resolve once, pass as a prop. - PERF7 — (ref R3) O(n²) media-items rewrites dominate the main-process cost when downloading a
channel; closed by the cached/atomic
jsonStore(SIMP1) — the single highest-value perf fix. - PERF8 — No code-splitting. All views (incl. the rarely-used Terminal/Settings) load up front in
one renderer bundle alongside Fluent UI + React; software rendering (W15) compounds first-paint cost.
Minor for a desktop app, but lazy-loading heavy views would trim startup. Fix:
React.lazythe heavy tabs.
Priority: PERF7/R3 (scaling cliff) and PERF1/PERF2 (per-download redundant I/O + crypto) are the only ones with real user-visible impact; the rest are good hygiene. None warrant work before the correctness bugs (B1, M32, M35) — listed for completeness, not urgency.
Completed
Security & correctness audit (2026-06-23):
- S1 — Backup import: confirm before enabling imported custom-command templates.
- S2 — Cookie login window: restrict popups to http(s)/about: schemes.
- S3 — Enforce
maxConcurrentin main (startDownload), not just the renderer. - S4 — Path-traversal sanitization for
filenameTemplate+outputDir. - S5 — Per-row validation on persisted JSON reads.
- C1 (orig) — Bundle the missing
ffprobe.exe; assert ffmpeg+ffprobe presence up front. - P1 — Stop
getSettings()writing to disk on every read. - P2 — Forward the renderer's pre-probed metadata to main; skip the redundant probe.
- P3 — Document "lower maxConcurrent mid-flight doesn't pause overflow".
- M1 (orig) — Extract duplicated
cleanErrortolog.ts. - M2 (orig) — Document
parseExtraArgsquoting limitations. - M3 (orig) — App icon placeholder (designed asset still wanted pre-v1.0).
Deferred
- Designed app icon before v1.0 (placeholder shipped).
- Migrate JSON stores to better-sqlite3 if a user indexes many large channels.
- Metadata editing (
--parse-metadata/--replace-in-metadata) — held pending a live recipe. - PO-token WebView auto-minting — only the
--extractor-argsplumbing shipped. - Taskbar overlay badge (needs a drawn
NativeImage). - i18n — deliberately deferred (note: UI strings are hard-coded throughout).
- Code signing — non-goal: no certificate will be purchased, so builds ship unsigned and the SmartScreen
prompt is accepted. The build remains signing-ready via
CSC_LINK/CSC_KEY_PASSWORDenv vars if this ever changes — see SIGNING.md. - Live smoke-test of OS-level wiring (tray/jump list/scheduled sync/
aerofetch://).