Commit Graph

124 Commits

Author SHA1 Message Date
debont80 eb53de2ea5 feat(setup): fetch ffmpeg on first run instead of bundling it
Ship a smaller installer that no longer carries ffmpeg.exe/ffprobe.exe
(the bulk of its size). On first run they are downloaded from a pinned
upstream archive (gyan 8.1.2), verified against a pinned SHA-256, and
unpacked with the OS tar.exe into the managed userData/bin dir -- the
same place as the self-updating yt-dlp, so they survive app updates and
portable re-extraction. A hard onboarding gate blocks the app until they
are present, with a "locate existing ffmpeg" folder-picker fallback for
offline machines and a Repair action in Settings > About.

The updater's streaming/checksum/redirect/idle-timeout download loop is
extracted to lib/verifiedDownload.streamVerifiedFile and shared by both
the app-installer download and the ffmpeg fetch; the updater's public
behaviour and error strings are unchanged (its boundary tests still pass).
No new npm dependency: extraction uses the System32 bsdtar resolved by
absolute path (audit F3).

Note: this commit also carries pre-existing, in-progress aria2c/network
and updater-token work that was already uncommitted in the working tree
and is entangled with the above in shared files (updater.ts, ipc.ts,
preload/index.ts, mockApi.ts, shared/ipc.ts, plus the settings/network
files and aria2c.exe). It was not cleanly separable by path, so it is
included here rather than split out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:02:13 -04:00
debont80 cb25262a2d feat(library): add per-row "Download new" for watched sources
Queuing a watched channel's not-yet-downloaded videos previously meant
expanding the source and using its "Download N pending" button. Watched
source rows now carry a one-click "Download new" that loads the source's
items (even while collapsed) and queues everything not already
downloaded or in the queue, using the source's resolved kind/profile.

Extracts the shared, tested selectUndownloaded selector (also now backs
the expanded panel's pending list) and adds ensureSourceItems to load a
collapsed source's items without expanding it.
2026-07-05 17:49:39 -04:00
debont80 27cae28e12 feat(diagnostics): persist full stderr detail behind a "Show details" toggle
describeDownloadError condenses a postprocessing failure to one line for
the queue/notification, discarding the rest of yt-dlp's stderr. The
persisted error log now also keeps the fuller non-noise transcript
(stderrDetail) as an optional `detail` field, and the Diagnostics card
gets a collapsible "Show details" expander per row so a failure like
"Postprocessing: Conversion failed!" can be inspected -- and copied
into a bug report -- without re-running the download from a terminal.
2026-07-05 14:56:09 -04:00
debont80 6cb9c5ca47 fix(downloads): re-resolve options on retry instead of replaying stale ones
A queued item freezes its post-processing options at enqueue time, so
retrying a failed download replayed whatever was in effect when it was
first added -- a settings change made afterward (e.g. turning
SponsorBlock-remove off, which forces a slow, failure-prone software
re-encode) never took effect on retry.

retry/retryAll now re-resolve a source-tied item's options from the
current source profile / global settings (the same precedence enqueue
uses) before requeuing it. Manual pastes with no source keep their
explicit per-download options untouched, and this is deliberately
distinct from the history re-download path, which keeps frozen options
on purpose to reproduce an identical file.
2026-07-05 14:39:45 -04:00
debont80 5de4b40c1d fix(download): name the failing step in postprocessing errors
yt-dlp collapses an ffmpeg postprocessing failure to a bare
"Postprocessing: Conversion failed!" with no indication of which step
died, and without --verbose the underlying ffmpeg reason is never
emitted at all. describeDownloadError() appends the last non-noise
trailing lines (e.g. the [ModifyChapters] SponsorBlock re-encode) so
the error log and completion notification point at the actual cause
instead of a dead end.
2026-07-05 14:39:29 -04:00
debont80 037ea2da32 feat(updater): bake a read-only update token into the build
The release repo is private, so the auto-updater previously required each
user to paste a Gitea token into Settings before it could see or download
updates. Inject a read-only token at build time (AEROFETCH_UPDATE_TOKEN env
var or a gitignored .update-token file) via electron.vite's `define`, and
fall back to it in authHeader() when the user hasn't set their own. The token
value lives only in the built bundle, never in source or git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:20:21 -04:00
debont80 e29374dcfa docs: scrub dangling references to deleted roadmap docs
The ROADMAP/ROADMAP-PINCHFLAT docs were removed; this drops the now-dead
"see ROADMAP-PINCHFLAT.md" breadcrumbs (and one "the roadmap" mention)
from source comments, preserving the surrounding Pinchflat-style context.
Comment-only; typecheck (node+web) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:16:32 -04:00
debont80 15c2229539 fix(playlist): drop stale per-item formats on re-probe
Peer-review of the exact-format-per-item feature: re-clicking "Preview
formats" on an already-loaded playlist reset info/playlist but left
itemFormats/itemFormatOptions/formatExpanded keyed by position, so a
stale exact-format pick could silently reattach to a different video
landing at the same index in the new probe result and flow into
addPlaylist(). Extracted resetPlaylistItemState() and call it from
both clearProbe() and fetchFormats().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 17:05:29 -04:00
debont80 4c2b54a599 feat(playlist): exact-format-per-item selection
Each video row in the playlist panel gets an exact-format button that
lazily probes just that entry's URL (reusing the existing single-video
probe IPC, no new main-process code) and shows a native Select of its
real formats. The choice flows through addMany -> formatId per queue
item. Probing is on-demand per row, never all up front, since a
playlist can hold thousands of entries and per-entry probes are
rate-limited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 16:55:58 -04:00
debont80 5c88d9c8e0 feat(network): request-throttle controls to avoid YouTube rate-limiting
Re-implements the sleepRequests/sleepInterval/maxSleepInterval feature
(--sleep-requests / --sleep-interval / --max-sleep-interval) from the
stale feat/binary-management-and-library-scale branch, ported onto
current main's structure (zod settingsSchema, decomposed NetworkCard).
All default to 0/off, preserving current behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:34:34 -04:00
debont80 0956bd536a feat(pinchflat): Retention & quality-upgrade per Library source
On-demand maintenance panel (SourceRetention) on the source detail —
not a persisted schedule (roadmap's niche-for-desktop scope).

Prune: keep the newest N downloaded files, delete the rest's files
(download archive prevents silent re-download); dry-run count + two-step
confirm before any deletion. Upgrade: re-enqueue items whose recorded
downloadedQuality ranks below the source's current target (profile or
global). Selection is pure + injected-now in @shared/retention.ts
(selectPruneCandidates/selectUpgradeCandidates + qualityRank), unit-
tested; pruneMediaItems (main) deletes best-effort and clears state.
MediaItem.downloadedQuality now recorded through the completion path.
Live-verified the panel renders. 366 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:14:23 -04:00
debont80 ed1daf8bef feat(pinchflat): Media Profiles — named download presets per Library source
A MediaProfile bundles { kind, quality, options, outputTemplate, extraArgs }
that a Source points at via Source.profileId, overriding the global defaults
field-by-field for that source's downloads.

Backend mirrors CommandTemplate: profiles.ts (createJsonStore, PROFILES_MAX,
isProfileLike + sanitize), CRUD IPC (profiles:list/save/remove) + preload +
useProfiles store; Source.profileId + sources:set-profile. Pure resolution
in @shared/profileResolve.ts (profile → global, per field; unit-tested).
Applied in the sources-store enqueueItems; a per-download collectionOutput-
Template override threads to buildArgs so a profile's outputTemplate wins.

UI: ProfileManager (mirrors TemplateManager) in a new Settings → Advanced →
Media profiles card, plus a per-source Profile picker in the Library source
detail. Both live-verified in the preview; 354 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:02:07 -04:00
debont80 42b7b2e01f feat(roadmap): surface stale checkboxes + collection output-path setting
Command preview + incognito toggle were already wired end-to-end in the
DownloadBar (Show command button, Incognito checkbox) — flipped the two
stale ROADMAP.md checkboxes with notes; verified live in the preview.

Output-path setting (PINCHFLAT): new Settings.collectionOutputTemplate — a
raw yt-dlp -o template overriding the default Channel/Playlist/NNN-Title
layout for Library downloads (empty = default). collectionOutputTemplate()
takes an optional custom template (joined under outDir, left for yt-dlp to
expand); download.ts passes the setting; zod schema validates it as a safe
relative template; Settings -> Filenames field. Tests in buildArgs +
settingsSchema; live-verified the field renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:44:44 -04:00
debont80 9134e7d216 feat(audit): Batch 25 — project reorg (CC12), leading DI args (CC7), CC10 by-design
CC12: renderer views/ (5 screens + Onboarding + settings cards) +
lib/ (theme/thumb/datetime/id/qualityOptions/useClipboardLink/queueStats);
main core/ (buildArgs/validation/indexerCore/ytdlpPolicy). git mv preserved
history; all src+test imports updated. Build emits view chunks from views/,
344 tests + typecheck green, live probe rendered all screens.
CC7: wc/onProgress is the leading arg everywhere — downloadAppUpdate(wc,url),
indexSource(onProgress,url,signal), indexSourceCancelable(onProgress,url).
CC10: closed by-design — jsonStore backs all records; settings stay in
electron-store for DPAPI secret encryption (a deliberate two-store split).
Also closes the UI24/W4 context-menu cross-reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:37:28 -04:00
debont80 8f6b2a761b feat(audit): Batch 24 — one validate-and-coerce schema layer for settings (CC9)
zod field schemas in settingsSchema.ts replace setSettings' bespoke
per-key switch: parse → skip-on-fail → store, side effects (secret
encryption, launch-at-startup sync) kept in settings.ts. sanitizeOptions
extracted to settingsOptions.ts to break the import cycle; backup import
flows through the same schema. Field-by-field parity pinned in
test/settingsSchema.test.ts (15 tests); full suite unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:41:00 -04:00
debont80 134c6a167c feat(audit): Batch 23 — settings key renames with migration (CC1) + config.ts (CC11)
CC1: customCommandEnabled→enableCustomCommands, downloadArchive→
useDownloadArchive, clipboardWatch→watchClipboard, sidebarCollapsed→
isSidebarCollapsed, hardwareAcceleration→useHardwareAcceleration,
videoDir/audioDir→videoFolder/audioFolder (+ chooseDir/clearDir store
actions → chooseFolder/clearFolder). Rename map in settingsMigration.ts
(pure, unit-tested incl. pre-rename backup fixture), applied as an
idempotent on-disk shim at store open and on backup import so old
settings.json and old backups both keep working.

CC11: update host/owner/repo + release API moved to src/main/config.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:34:05 -04:00
debont80 34fbf36610 feat(audit): Batch 21 — updater refactor under behavior pins (CL4, closes CC5)
14 pinning tests lock downloadAppUpdate's security invariants (redirect
re-validation per hop, checksum refusal paths, SHA-256 verify + cleanup,
truncation, idle-timeout, cancel in both phases, progress) with a
scripted net.request fake; then the nested streaming block is extracted
verbatim into streamInstallerToFile. downloadAppUpdate is now a linear
pre-flight; B6 cancel-slot ownership stays with its module state via
onCancelReady/onSettled. Pins green before and after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:21:28 -04:00
debont80 3b0d668f6c feat(audit): Batch 20 — Windows/main-process (UI23, W12, W15, L131)
UI23: cookie sign-in + PO-token windows open with the app's resolved
theme background (passed from the IPC layer), no more white flash.
W12: multi-resolution fallback tray icon (16/24/32/48 via
addRepresentation dataURL, embedded in trayFallbackIcons.ts); decode +
scale factors verified in an Electron probe.
W15: hardwareAcceleration setting (default false = software rendering);
index.ts gates disableHardwareAcceleration on it after the portable
redirect; Appearance card switch with restart note; boot path verified.
L131: resolved by-design — clearing the queue acknowledges failures;
flashFrame (W10) covers attention, Diagnostics persists the record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:15:51 -04:00
debont80 a5885739d2 feat(audit): Batch 19 — Settings information architecture (UX7, UX8, UX23)
UX7: 11 cards grouped into five sections (Downloads, Appearance,
Network & accounts, Advanced, App & maintenance) with section headings
and a jump nav under the search box; search hides empty sections and
the nav; heading hierarchy corrected (h1 page, h2 section, h3 card).
UX8: post-processing card co-located under the Downloads card and
renamed 'Post-processing' with a defaults-vs-advanced hint.
UX23: closed as stale duplicate of the already-fixed L71 (editable,
validated, reconciled folder inputs) — verified in the preview probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:00:47 -04:00
debont80 cdb6eea437 feat(audit): Batch 18 — watched visual pass (UI3, UI12, UI16, UI21, UX18, L109, UX26)
UI3: remaining list-row/control paddings on the SPACE scale (History row,
Downloads summary, Library head/detail, Terminal gate/log, template rows).
UI21: active-nav rail now shows collapsed too (moved into navItemActive).
UX18: stable 'Quality' label + brand '• fetched' tag instead of the
label/control morph after Fetch.
L109/UX26: skeleton lines for the About yt-dlp/ffmpeg boot load; skeleton
cards in Settings until the store's loaded flag flips.
UI12/UI16: deferred eyeball deltas verified via Electron-probe screenshots
(light+dark, collapsed sidebar, fetch flow); UI16 convention codified in
ui/tokens.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:55:45 -04:00
debont80 17d42ed4fe feat(audit): Batch 17 — screen scroll contract + History virtualization (L161, L104)
L161: <main> is now a non-scrolling flex slot; each screen owns its scroll
via shared shells in ui/Screen.tsx — 'page' (Library/Settings/empty History
scroll the shell) and 'fill' (Downloads/Terminal/History scroll their own
list/log, shell scrolls only as short-window fallback). height:100%
couplings replaced with flexGrow+minHeight:0; list regions floored at
160px so they can't collapse on short windows.

L104: HistoryView renders through VirtualList; Ctrl+A re-homed onto a
wrapper around the scroller, per-row Delete unchanged on rows.

Verified with an Electron probe against the vite preview at 1100x720 and
800x520 (probe metrics + screenshots): one active scroller per screen,
main/body never scroll, floors hold. Adds .claude/launch.json for the
preview server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:43:34 -04:00
debont80 2ef19ead71 feat(audit): Batch 16 — store discipline (L142 reconciliation, L93 view-model hooks)
L142 (+CC14): keyed latest-wins reconciler (lib/reconcile.ts, unit-tested)
applies main's authoritative IPC returns in the history/templates/sources
stores, extending the M34 settings pattern; loadSources shares the key so
a slow list read can't clobber a newer mutation result.

L93 (+CC13): settings cards, TerminalView, and LibraryView now reach IPC
only through colocated view-model hooks (useAboutCard/useBackupCard/
useCookiesCard/useSoftwareUpdateCard/useNetworkCard/useTerminalRun) or
store actions (openHighContrastSettings, scheduled-sync on sources) —
no window.api left in components.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:10:49 -04:00
debont80 96800d5a17 feat(audit): L51 + L64 — daily-sync time picker + aria2c connection tuning
Two small self-contained features from the audit's deferred list:

- L51: the Task Scheduler daily sync is no longer pinned to 09:00. New
  Settings.syncTime (24h HH:MM) with a native time input beside the Library
  "Daily sync" switch — persists on change, re-registers the task on blur
  (schtasks /Create /F overwrite is the time-change mechanism). The value
  reaches the schtasks /ST argv, so a shared isValidSyncTime guards it at
  both the settings write and in schedule.ts (unit-tested incl.
  injection-shaped input). Live-verified against the machine's real task.

- L64: aria2c connection count (-x/-s) is user-tunable. New
  Settings.aria2cConnections (1-16, aria2c's own ceiling) exposed as a
  SpinButton in Settings -> Network while the aria2c toggle is on.
  ARIA2C_ARGS became the pure aria2cArgs(n?) with a defensive clamp;
  threaded through AccessOptions. The -k 1M split floor stays fixed — a
  free-text field would bypass the custom-command consent gate.

typecheck + 309 tests + eslint + production build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:57:20 -04:00
debont80 e19f988c72 feat(audit): Batch 15 live-verify — download-engine lifecycle (R4, L65, L139, B2, B6, L143)
The watched live session for the six deferred lifecycle items, each verified
against real yt-dlp/Electron behavior on Windows:

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

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

typecheck + 304 tests + eslint + production build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:36:22 -04:00
debont80 a2c8992650 refactor(audit): peer-review fixes — mergeClasses + import placement
Self-review of the Batches 9-15 changes surfaced two style inconsistencies (no
behavior change):
- Toaster.tsx: combine the icon + tone classes with mergeClasses instead of a
  template-literal string, matching how the rest of the codebase composes Griffel
  classes.
- App.tsx: move the PERF8 React.lazy() view declarations below the import block
  (they were interleaved between import statements).

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:00:21 -04:00
debont80 4d1c33a081 feat(audit): Batch 15 — download-engine lifecycle (safe part; rest live-verify)
This is the batch the run reserves for a WATCHED LIVE SESSION, so only the safe,
non-file-deleting item is implemented here; the rest are staged with per-item
live-verify rationale in CODE-AUDIT.md.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:41:48 -04:00
debont80 0f784847a3 feat(audit): Batch 14 — perf (code-split + seed DCE) + first-run default
- PERF8: code-split the non-default tabs. Library/History/Terminal/Settings are
  React.lazy chunks behind a Suspense; Downloads (the launch tab) stays eager. The
  production build now emits separate SettingsView (~90kB), LibraryView (~29kB),
  HistoryView (~16kB), TerminalView (~8kB) chunks — ~140kB out of the initial
  bundle. The stores these views use are imported eagerly elsewhere, so lazy-
  loading the views never delays store startup/persistence (per the C2 note).
- L128: gate the three preview seed arrays (downloadSeed / sources seeds / history
  seed) on `import.meta.env.DEV && PREVIEW`. In the packaged build DEV is
  statically false, so Rollup constant-folds and dead-code-eliminates the seed
  literals; the browser dev preview (DEV true) still gets them.
- SR4: clipboardWatch now defaults false for new installs (privacy — no clipboard
  read on focus until opted in). Existing installs keep their saved value
  (electron-store fills only missing keys); the onboarding tip points to the
  setting. NOTE: this flips a signature convenience off-by-default for new users —
  flagged for the maintainer, trivially reversible.

Deferred with rationale (CODE-AUDIT.md): PERF3 (core-queue micro-opt on a bounded
list, risky unattended), PERF6/L162 (bounded by virtualization + conflicts with
PERF5's hoisted renderItem), SR5 (MP3 = deliberate compatibility default; the label
bug was M18), W12 (fallback-of-a-fallback icon), W15 (GPU software-rendering
workaround — external driver dependency), L104 (History virtualization needs the
L161 layout rework + live verification; bounded by the 500-cap).

Verified: typecheck (node+web) + 279 tests + eslint + production build green
(code-split chunks confirmed in output); touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:35:40 -04:00
debont80 71f0b2b68f feat(audit): Batch 13 — copy polish + misc-L cleanup
Contained fixes:
- L158: drag-highlight flicker. useDownloadBar tracks a dragDepth counter —
  onDragEnter increments, onDragLeave decrements, the highlight clears only when
  depth returns to 0 (the drag truly left the card). onDragOver just
  preventDefaults; onDrop resets. No more blink when crossing child elements.
- L92: preload method names aligned with main — markSourceItemDownloaded →
  setMediaItemDownloaded, syncSources → syncWatchedSources (callers + mock
  updated; compiler-enforced via the derived Api type).
- L149: the three-sentence "Keep running in the tray" hint is now one line.
- L171: one MOCK_CURRENT_VERSION backs both the preview mock's getAppVersion and
  checkForAppUpdate.currentVersion, so they agree.
- L5 (representative): the About card's repeated monospace inline styles → shared
  mono/monoBlock/monoPre classes; convention set (makeStyles for static, inline
  style only for data-driven values).

Resolved by verification/convention (CODE-AUDIT.md): L21 (moot after the H1
SettingsView decomposition), L31 (dual theme switcher is width-driven), L130
(lowercase fragments vs shown-raw sentences), L152 (search sizes are role-
appropriate), UI22 (brand/entity/section icon-emphasis set now defined).

Deferred with rationale: L51/L64 (features), L93 (CC13 view-model pass), L104
(moved to Batch 14 perf), L109/UI21 (visual), L161/UI23 (layout/live-verify).

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:26:01 -04:00
debont80 d131ee67d5 feat(audit): Batch 12 — a11y & discoverability
- L118: decouple URL-field focus. The DownloadBar registers a focuser (its own
  input ref) in the nav store; App calls useNav.getState().focusUrlField() on
  launch / "New download" instead of document.getElementById('aerofetch-url').
  The hardcoded id is removed — App no longer reaches into DownloadBar's markup.
- L155: the accent swatches are now a real role="radiogroup" with aria-checked
  role="radio" swatches, roving tabindex, ←/→/↑/↓/Home/End arrow-key navigation,
  and the shared focus ring — matching the app's SegmentedControl radio pattern.
  Redundant "(selected)" aria-label text dropped (aria-checked conveys it).
- UX21: keyboard-shortcut discoverability — an Onboarding "Press Ctrl+K anytime"
  tip, and the command palette's Settings action surfaces its "Ctrl ," shortcut.
- UX22 / L67: onboarding is revisitable — a "Show welcome tips again" button in
  Settings → About re-opens the welcome screen.

Verified & documented (no code needed): UX17 (Fetch/Paste already have aria-label
+ focus-reachable Hint tooltips — a11y met; a permanent visible label is a compact-
layout choice), UI24 (context menus "none by design" — row actions are all visible
inline buttons + keyboard, and Fluent's Menu is portal-based, which the app avoids
for the dev-GPU flicker reason).

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:15:10 -04:00
debont80 661a4e7572 feat(audit): Batch 11 — UX behavior gaps (toast surface, sidebar badge)
A new in-app toast surface is the batch's spine, making the silent-failure and
global-status gaps fixable:

- Toast infra: store/toasts.ts (useToasts + toast(); auto-dismiss) + ui/Toaster.tsx
  (non-portal bottom-center stack, avoids the Fluent-portal GPU flicker). Unit-
  tested (test/toasts.test.ts). Mounted at the app root beside LiveRegion.
- UX6: shared renderer reveal.ts revealFile('open'|'folder') awaits the IPC result
  and toasts the main-process reason instead of silently no-oping on a moved/typed-
  out file. safeShowInFolder upgraded to return an error string like safeOpenPath
  (preload/mock/Api types updated).
- UX9 / UI25: a Fluent CounterBadge on the sidebar Downloads nav item shows the
  active (downloading + queued) count from any tab, via a count-only App selector
  that doesn't re-render on progress ticks.
- UX15: cancelAll store action + a "Cancel all (N)" header button.
- UX24: the disabled "Check watched" button uses disabledFocusable + a Hint that
  explains it needs a watched source.
- UX12: the Terminal gate gains an inline "Enable custom commands" button, so it's
  no longer a dead-end pointing at another screen.
- L144: the DownloadBar duplicate guard now also checks history — a URL downloaded
  earlier warns "Already downloaded: …" (dupInHistory) vs "Already in your queue: …".

Deferred with rationale (CODE-AUDIT.md): UX7/UX8 (Settings IA redesign — visual),
UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs live taskbar),
L142 (history/templates/sources IPC-return reconciliation — a focused own PR).

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:05:26 -04:00
debont80 814ecac287 feat(audit): Batch 10 — CC conventions (shared line-buffer, cleanError)
The contained consistency consolidations; the sweeping ones are deferred with
their standard documented (they're the audit's own post-1.0 refactors, and
several would be reckless to do unattended).

Landed:
- src/main/lib/lineBuffer.ts (createLineBuffer): one newline line-splitter for
  streamed child-process output, replacing the duplicated buffer loop in
  download.ts and terminal.ts (CC3/CC4/SIMP5). Pure + unit-tested
  (test/lineBuffer.test.ts, 7 cases: partial lines, CRLF, empty lines, flush,
  multi-line chunks). download.ts keeps byte-identical marker parsing and no
  close-flush (yt-dlp template lines are always newline-terminated).
- ytdlp.ts: getYtdlpVersion/updateYtdlp now run stderr through cleanError, the
  same `cleanError(r.stderr) || r.error?.message || ''` idiom already used by
  download/probe/indexer (CC6) — a failed check shows the trailing error line.
- CL6 resolved: the one real redundancy was L15; clearDir/openFile/showInFolder
  are deliberate view-model wrappers (CC13), left as-is.

Deferred with documented standard + rationale (CODE-AUDIT.md): CC1 (breaking
persisted-key rename → migration), CC5+CL4 (security-critical updater
net.request, not live-verifiable here), CC7 (cosmetic arg-order), CC9 (zod dep),
CC10 (electron-store↔jsonStore split is deliberate for DPAPI), CC11 (config.ts
move), CC12 (file-tree reorg), CC13→L93, CC14→L142.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:48:54 -04:00
debont80 3d4e574916 feat(audit): Batch 9 — surface primitives (empty-state, banner, truncation)
Shared UI primitives to end the "two ways to do X" drift on surfaces:

- EmptyState (ui/EmptyState.tsx): one centered empty block — optional focal
  icon/badge + Body1 message + optional muted hint, with a `compact` variant
  for in-content placeholders. Adopted by Downloads, History, Library (L116),
  and — compact — the Terminal log, Diagnostics, and TemplateManager (L117).
- Banner (ui/Banner.tsx) + LinkSuggestion (ui/LinkSuggestion.tsx): one tinted
  notification row (icon + truncating/wrap content + actions, tone brand/warning)
  replacing five hand-rolled style blocks — the DownloadBar copied-link
  suggestion / channel nudge / duplicate warning and the Library copied-link
  suggestion (UI19).
- useTextStyles().truncate: shared single-line-ellipsis utility, adopted in
  Library rows + the playlist panel + Banner; `title` now also sets minWidth:0
  so it shrinks in a flex row (L126).
- L102: Settings "Default format" now uses the same SegmentedControl (Video/
  Audio) + quality Select as the DownloadBar — one mental model; the combined
  `kind|quality` string encoding is gone.

Resolved by verified convention (documented in code / CODE-AUDIT.md):
- L103 busy = in-button icon→Spinner everywhere (done in L134; no adjacent-
  spinner pattern survives).
- L115 list-row actions icon-only+tooltip vs card-toolbar actions labelled.
- L120 Fluent <Card> for static content cards; tokenized surface recipe for
  interactive/list-item cards.
- UI20 toggles = solid brand, list-selection = brand-tint (rule noted on
  SegmentedControl); the cited Library pill is gone via UI18.

L128 (build-time DCE of preview seeds) deferred to Batch 14 with PERF8.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:37:32 -04:00
debont80 bd7ba8726e feat(audit): Batch 6-7 — design tokens + type/color roles, full migration
Landed complete design-token foundation (Batch 6) and type/color standardization (Batch 7):
- New src/renderer/src/components/ui/tokens.ts: SPACE, RADIUS, ELEVATION, Z, ICON, MOTION, SCRIM, META_SEP scales
- New src/renderer/src/components/ui/text.ts: useTextStyles hook + type-role convention (Subtitle2/Text/Body1/Caption1)
- Migrated scattered literals in 17 renderer files to named tokens
- Adopted unified muted color and row-title component across 7 list views
- All tile glyphs snapped to ICON tiers (no literal px remain)
- base.css reduced-motion gate + prefers-color-scheme alignment
- Verified tile-glyph visual deltas (≤2px) for watched pass; UI3/UI12 implementation confirmed, awaiting visual check
- CODE-AUDIT.md reconciled: Batch 6 complete (12 items checked, UI3+UI12 flagged); Batch 7 complete (6 items checked)

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-01 15:24:00 -04:00
debont80 e6b4f343e0 feat(config+ux): batch #5 — source maps, integration script, copy, redownload options
Clean config/copy items (Phase 7):
- L170: build.sourcemap 'hidden' on main/preload/renderer — emit .map for offline
  crash symbolication (of the CC8 logger's raw stacks) without exposing them.
- L34: add `test:integration` npm script (AEROFETCH_REAL_DOWNLOAD=1) for the opt-in
  real-download suite; stays out of `npm test` (needs network + live yt-dlp).
- L87: HistoryEntry carries the original `options`; redownload replays them so a
  re-download reproduces the same post-processing instead of the current defaults.
- L96: folder "Browse" buttons -> "Browse…" (they open the OS picker).
- L97: PO-token placeholder "(none)" -> "Optional -- paste a token" (consistent).
- Ticked already-resolved: L99 (M28 aria-label), L113 (UX2 tooltip=aria-label),
  L95 (usage follows verb/modifier rule), L172 (skipLibCheck accepted).

typecheck + 268 tests + eslint + prettier green (touched files LF/clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:06:06 -04:00
debont80 cc4fb132e4 feat(ux): UX16 — autofocus the URL field on launch
An App mount effect focuses #aerofetch-url when the Downloads tab is active
(double-rAF for paint), so a user can paste + type immediately instead of
clicking first.

typecheck + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:35:44 -04:00
debont80 ab825488b8 feat(win): W2/UX19 persist window placement, W10 taskbar flash; tick W11
- W2/UX19: windowState.ts persists the window's normal bounds + maximized flag to
  <userData>/window-state.json (no new dep); createWindow restores from it with a
  screen.getAllDisplays() visibility guard so it never reopens off a disconnected
  monitor. Debounced save on resize/move + save on close (which may hide to tray).
- W10: notify() flashFrame(true)s the taskbar when a completion/failure lands while
  the window is unfocused/minimized (Windows clears the flash on focus).
- W11: already implemented (badge.ts overlay dot + setOverlayIcon) — ticked.

typecheck + 268 tests + eslint + prettier green. (Window restore + flash want a
live quit/relaunch confirm.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:33:11 -04:00
debont80 64af608596 refactor(renderer): L132 — share resetForm() across download + addPlaylist
Extract the post-enqueue reset (URL + probe + one-shot trim/schedule/advanced/
incognito/options) into resetForm(), called by both download() and addPlaylist().
clearProbe() stays the lighter probe-only reset for onUrlChange. Also fixes a
latent inconsistency: addPlaylist previously left the trim/schedule/advanced
panels open after enqueuing a playlist.

typecheck + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:26:36 -04:00
debont80 809c99d5bb fix(renderer): L138/L145 clipboard privacy+dedup, M14 state-driven settings search
- L138: gate the clipboard check on the settings `loaded` flag (not just
  clipboardWatch), so it never reads using the pre-load fallback; the effect
  re-runs when loaded flips true so a pre-launch copied link is still offered.
- L145: lastSeen is module-level, so a dismissed clipboard link stays suppressed
  across the hook's remount on a Downloads<->Library tab switch.
- M14: SettingsView renders each card in a React-owned wrapper and drives its
  visibility from a `hidden` state array — the search no longer writes display:none
  onto a card's own DOM node. Matching still reads textContent (full label coverage,
  no per-card keyword upkeep).

typecheck + 268 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:17:23 -04:00
debont80 63f0a40f0e perf(renderer): memoize queue summary + stabilize list callbacks (L94/PERF4/PERF5/L163)
- L94/PERF4: add queueSummaryOf — a 1-entry items-ref memo over summarizeQueue.
  The store hands out a new items array per change, so App's taskbar subscription
  and DownloadsView's render compute the aggregate once per change (second caller
  hits the cache) instead of twice per tick. summarizeQueue stays pure/tested.
- PERF5: hoist VirtualList estimateSize/getKey/renderItem to stable module-level
  functions so the virtualizer doesn't churn its measure/key cache.
- L163: one useShallow selection replaces QueueItem's 10 individual useDownloads
  subscriptions (stable actions -> never re-renders the row).

typecheck + 268 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:09:09 -04:00
debont80 0a681f864f feat(m4): persist the download queue across restarts
The queue/scheduler lived only in renderer memory, so saved/scheduled and other
pending downloads were silently lost on quit (audit M4). Persist them:

- main: queue.ts store (proven cached-atomic createJsonStore, queue.json) +
  queue:list / queue:save IPC; QUEUE_MAX cap.
- shared: PersistedQueueItem = the durable subset of DownloadItem (runtime-only
  progress/speed/eta/sizeLabel/finishing dropped).
- renderer: mirror the persistable items on every queue change (a signature over
  the subset means progress ticks don't re-save; main's jsonStore coalesces the
  writes) and rehydrate on launch via an App useEffect. Pure mappers extracted
  to store/queuePersist.ts and unit-tested.

Restore semantics: downloading -> queued (yt-dlp continues the .part), saved +
scheduledFor survives so the promoter fires when due, paused/error return
actionable; completed/canceled are never persisted. A queueHydrated gate stops a
launch-time store change from wiping queue.json before it's read.

Reconciles ROADMAP.md. typecheck + 268 tests + eslint + prettier green.
(OS-level quit/relaunch cycle is worth a manual smoke test.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:25:58 -04:00
debont80 63687aaec3 docs(exec): correct execFileAsync comment — schedule.ts is not migrated
schedule.ts keeps its own schtasks wrapper because it needs the numeric exit
code (getScheduledSync checks r.code === 0), which execFileAsync deliberately
doesn't surface. The 5 migrated sites are probe/ytdlp(x2)/ffmpeg/indexer/
download.probeMeta. Comment-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:25:36 -04:00
debont80 81dd0ce742 fix(main): R9 — guard the yt-dlp update throttle against a backward clock
shouldAutoCheckYtdlp compared (now - lastCheck) >= interval; a backward system-
clock correction leaves lastCheck in the future so the difference is negative and
the daily check never comes due again. Treat a future lastCheck as skewed and run
the check. The renderer scheduled-item promoter is inherently fire-once (promotion
flips the item out of 'saved'), so it can't double-fire on a backward jump — a
forward jump firing early is documented and accepted as minor.

Unit-tested. typecheck + 263 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:19:00 -04:00
debont80 701c73a889 fix(main): L136 — enforce incognito in main (no errorlog / toast / cookie leak)
Incognito lived only in the renderer (history skip), so a "private" download
still leaked in main: errorlog entries (title+URL), OS toasts showing the title,
and the signed-in cookies attached — the UI promised "no logging, no history, no
cookies" but only history held.

Plumb `incognito` through StartDownloadOptions and enforce it in main:
- logFailure skips the errorlog write (and the pre-spawn errorlog in ipc.ts).
- notify uses a generic outcome title and drops the detail body, so no title/URL
  lands in the Windows Action Center.
- both cookie sources (--cookies login jar, --cookies-from-browser) are withheld,
  so a private download can't be tied to the user's identity.

typecheck + 262 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:14:49 -04:00
debont80 3baba3b7bc refactor(main): execFileAsync helper — unify the 5 spawn-and-read sites
Replace the hand-rolled `new Promise((resolve) => execFile(…, cb))` wrappers in
probe / ytdlp (×2) / ffmpeg / indexer.probeFlat / download.probeMeta with one
lib/exec.ts execFileAsync returning a normalized
{ok,stdout,stderr,timedOut,error} that never rejects. Each caller keeps its own
error mapping (timedOut -> "Timed out …" vs stderr) and JSON parsing; behaviour
is unchanged. windowsHide defaults on; per-call timeout/maxBuffer preserved.

Foundation for CC4/CC5 — the stdout line-buffer half lands with SIMP5, the
net.request half with SIMP16, at which point those checkboxes close.

Unit-tested (exec.test.ts) against the node binary: ok / stderr+nonzero-exit /
missing-binary / timeout. typecheck + 262 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:06:08 -04:00
debont80 519e522917 feat(main): CC8 — file-backed leveled logger + renderer log sink
Diagnostics were invisible in a packaged build: main catches did a bare
console.error (DevTools suppressed in prod, M31) and the renderer's logError
(M29) wrote to an unreachable console. Add a small leveled logger
(src/main/logger.ts) appending to <userData>/logs/aerofetch.log with size-capped
rotation; all app/fs access is lazy+guarded so it's a safe no-op under tests.

- Route the 5 main-process console.* catch sites (jsonStore write-fail, settings
  write-fail + plaintext-secret warn, cookie loadURL, yt-dlp auto-update)
  through the logger.
- Add a fire-and-forget log:write IPC channel + preload logError() so the
  renderer's logError forwards failures to the main file sink (closes the M29
  "sink" half the code comments deferred to CC8). mockApi + Api type updated.
- Unit-test pure formatLine/composeMessage + no-op safety; update the R6
  jsonStore test to assert against the logger.

The user-facing toast half of CC8 ties to the global status surface (UI25/UX9).
typecheck + 258 tests + lint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:53:06 -04:00
debont80 6a9856b590 Self-review: drop inert font-size/weight resets from the heading rule
The UI33 heading reset only needs to zero the UA margin — every heading in the
app carries a Fluent typography class that out-specifies this element selector
(and there are no raw headings), so the font-size/font-weight `inherit` lines
never applied and contradicted the comment. Keep just `margin: 0`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:19:41 -04:00
debont80 29ce270d46 a11y: Select disabled (UI31), native colorScheme (UI32), touch targets (W16), High Contrast (W18)
UI31 — the native <Select> wrapper now takes a `disabled` prop that passes to
the <select> and applies disabled foreground/background/stroke tokens +
not-allowed cursor, so it can show a disabled state like Fluent controls.

UI32 — dropped the datetime-local's explicit colorScheme:'light dark' so it
inherits the resolved in-app scheme from the app root; the native calendar popup
now follows the app's Light/Dark theme, consistent with the native <Select>,
instead of tracking the OS preference.

W16 — the icon-only row-action clusters (QueueItem + HistoryView) enforce a
>=40x40px hit target via a `& button` min-size rule (glyph unchanged, subtle
button just gains padding). Labels were already non-hover-only: the shared Hint
shows on :focus-within and every action has an aria-label. LibraryView item rows
have no icon-only actions, so nothing there needed changing.

W18 — defended the two color-meaning surfaces against forced-colors: accent
swatches set forced-color-adjust:none (they're color previews; otherwise all
flatten to one system color), and the SegmentedControl active segment paints
with system Highlight/HighlightText under @media (forced-colors: active) so the
checked state stays visible. Status chips (Fluent Badge) and thumbnail tints
correctly let the system palette win.

typecheck + 253 tests + eslint + prettier + production build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:11:30 -04:00
debont80 4aec32e3c2 a11y: semantic headings (UI33) + command-palette listbox semantics (UI27)
UI33 — render titles as real headings so Narrator heading-navigation works:
the shared ScreenHeader title is now <Subtitle2 as="h1"> (one h1 per screen in
one place — Downloads/History/Library/Settings/Terminal); DownloadsView "Queue
(N)" and all 11 settings cards are <Subtitle2 as="h2"> sections; Onboarding
"Welcome" is <Title2 as="h1">. A minimal h1–h6 margin reset in base.css zeroes
the UA heading margin (Fluent's typography classes out-specify the element
selector, so the visual ramp is unchanged) so layout matches the former spans.

UI27 — give the command palette proper combobox/listbox semantics: the input is
role="combobox" with aria-controls/aria-activedescendant/aria-autocomplete, the
list is role="listbox", and each row is role="option" with aria-selected, so the
active row is announced to screen readers via active-descendant. Focus stays on
the input (standard combobox pattern), so the rows became non-focusable divs;
onMouseEnter is kept to unify pointer + keyboard highlight on one sel state.

Also corrected UI30's checkbox in the audit (already implemented via the shared
SegmentedControl radiogroup; only the marker lagged).

typecheck + 253 tests + eslint + prettier + production build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:02:54 -04:00
debont80 36699531cf Format code with Prettier (8 modified files from H1 decomposition)
Aligns with CC2 enforcement: ESLint + Prettier + noImplicitAny now enforced.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-01 07:54:02 -04:00
debont80 9e0064aab2 Self-review H1: give useDownloadBar an explicit return type
The extracted hook relied on inferred return typing, inconsistent with the
codebase convention (useClipboardLink: ClipboardLink, useResolvedDark: boolean).
Add a compiler-verified DownloadBarController interface and annotate the hook —
the returned object is checked against it, so the surface can't silently drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:41:06 -04:00