Fix C1/C2 + reliability cluster (L140/L141/L148, R6/R7)

Critical:
- C1: single source of truth for IPC mock + Settings defaults. DEFAULT_SETTINGS
  in @shared/ipc; main DEFAULTS, renderer FALLBACK, and preview MOCK_SETTINGS all
  derive from it. Whole browser mock collapsed into one typed mockApi.ts; main.tsx
  shrinks to window.api = mockApi. PREVIEW check single-sourced in isPreview.ts.
- C2: break the downloads<->sources circular import via a typed event bus
  (store/coordinator.ts). App eagerly imports sources for side-effects so startup
  load + scheduled --sync + downloadCompleted subscription still run.

Reliability:
- L140/L148: cancel/pause release the active slot synchronously; identity-guarded
  releaseActive; per-spawn cookie jar so a same-id retry/resume is never rejected
  by a stale entry nor run over the concurrency cap.
- L141: renderer history capped at 500 + url de-dup to match main.
- R6: writeJsonAtomic reports/logs write failures and keeps data dirty for retry
  instead of an empty catch.
- R7: encryptSecret warns before the plaintext fallback.

typecheck + 243 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 20:25:49 -04:00
parent 6c19899f75
commit 7e3e5af52d
20 changed files with 596 additions and 413 deletions
+67 -11
View File
@@ -33,6 +33,24 @@ fmtBytes/fmtEta/parseProgress unit-tested including NA paths), L38 (webm thumbna
test), L39 (CROP_SQUARE_PPA structural test), L40 (looksLikeUrl/looksLikeSingleVideo extracted test), L39 (CROP_SQUARE_PPA structural test), L40 (looksLikeUrl/looksLikeSingleVideo extracted
to lib/urlHelpers.ts; test imports pure module directly). All verified: typecheck + 242 tests + eslint + prettier green. to lib/urlHelpers.ts; test imports pure module directly). All verified: typecheck + 242 tests + eslint + prettier green.
**Session 2026-06-30 pass 3 (reliability — B/R/L races & silent failures):** L140 + L148 (the `active`-map
teardown race — `cancelDownload`/`pauseDownload` release the concurrency slot synchronously and an
identity-guarded `releaseActive` + per-spawn cookie jar make same-id retry/resume safe), L141 (renderer
history capped at 500 + url de-dup to mirror main), R6 (`writeJsonAtomic` reports + logs write failures and
keeps the data dirty for retry instead of an empty catch — log half; toast → CC8), R7 (`encryptSecret` warns
before the plaintext fallback). All verified: typecheck + 243 tests + eslint green; the 5 touched files are
prettier-clean. *Deferred this pass (need UI/live-app or larger design): B2/B6 (cancel buttons + abort
wiring), R4 (can't safely identify a download's `.part` files at cancel time — needs the yt-dlp "Destination"
path tracked), L139 (needs a spawn↔auto-update interlock), L142 (multi-site IPC-return reconciliation,
generalises M34), L157 (cross-store cancel-on-remove, ties to C2), R9 (clock-skew — "note for awareness").*
**Session 2026-06-30 pass 4 (Critical — store architecture):** **C1** (single source of truth for the
preview mock + Settings defaults — `DEFAULT_SETTINGS` in `@shared`, one typed `mockApi.ts`, centralized
`isPreview`) and **C2** (broke the `downloads ↔ sources` circular import via a pure typed event bus,
`store/coordinator.ts` — neither store imports the other now). All verified: typecheck + 243 tests +
eslint green; the touched files are prettier-clean (3 pre-existing format violations in
DownloadOptionsForm/LibraryView/SettingsView are untouched and out of scope).
**Deliberately deferred** (need live-app/visual verification or are larger refactors the **Deliberately deferred** (need live-app/visual verification or are larger refactors the
audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store
refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations, refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations,
@@ -176,15 +194,30 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
## Critical ## Critical
- [ ] **C1 — Single source of truth for the IPC mock + Settings defaults.** `const PREVIEW` - [x] **C1 — Single source of truth for the IPC mock + Settings defaults.** `const PREVIEW`
redeclared in 8 files; `main.tsx` reimplements the entire `Api` (~180 lines); the full redeclared in 8 files; `main.tsx` reimplements the entire `Api` (~180 lines); the full
`Settings` object is hand-maintained in 3 places (`main/settings.ts` `DEFAULTS`, `Settings` object is hand-maintained in 3 places (`main/settings.ts` `DEFAULTS`,
`renderer/store/settings.ts` `FALLBACK`, `main.tsx` `MOCK_SETTINGS`). Add `DEFAULT_SETTINGS` `renderer/store/settings.ts` `FALLBACK`, `main.tsx` `MOCK_SETTINGS`). Add `DEFAULT_SETTINGS`
to `shared/ipc.ts`, extract the preview mock into one `mockApi.ts` typed as `Api`, centralize to `shared/ipc.ts`, extract the preview mock into one `mockApi.ts` typed as `Api`, centralize
`isPreview`. `isPreview`. *Fixed: `DEFAULT_SETTINGS` is now the canonical default in
- [ ] **C2 — Break the `downloads ↔ sources` store circular dependency.** `downloads.ts` [shared/ipc.ts](src/shared/ipc.ts); main's `DEFAULTS`, the renderer `FALLBACK`
([store/settings.ts](src/renderer/src/store/settings.ts)), and the preview `MOCK_SETTINGS`
all derive from it (preview-only field tweaks layered on top) instead of three hand-kept
copies. The whole browser mock moved into one [mockApi.ts](src/renderer/src/mockApi.ts) typed
`Window['api']` (so a missing/mistyped method is a compile error), and `main.tsx` shrank to
`window.api = mockApi`. The `PREVIEW` check is single-sourced in
[isPreview.ts](src/renderer/src/isPreview.ts) and imported (as `PREVIEW`) by all 8 sites.*
- [x] **C2 — Break the `downloads ↔ sources` store circular dependency.** `downloads.ts`
imports `useSources`; `sources.ts` imports `useDownloads`. Works only via lazy `.getState()`. imports `useSources`; `sources.ts` imports `useDownloads`. Works only via lazy `.getState()`.
Introduce a coordinator/event bus that owns cross-store reactions. Introduce a coordinator/event bus that owns cross-store reactions. *Fixed: a tiny typed event
bus [store/coordinator.ts](src/renderer/src/store/coordinator.ts) (no runtime deps — the
`AddEntry` import is type-only) now owns the two cross-store reactions. `sources` emits
`enqueueDownloads` (consumed by `downloads.addMany`); `downloads` emits `downloadCompleted`
(consumed by `sources.markDownloaded`). Each store imports the bus, not the other, so the
cycle is gone. Because `downloads` no longer pulls in `sources`, `App` now eagerly imports the
`sources` store for side-effects (`import './store/sources'`) so its startup load + scheduled-
`--sync` kickoff and the `downloadCompleted` subscription still run at launch — robust even
once the views are lazy-loaded (PERF8).*
## High ## High
@@ -649,10 +682,19 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L139 — Auto-update vs concurrent spawn race.** `runStartupYtdlpAutoUpdate` can overwrite - [ ] **L139 — Auto-update vs concurrent spawn race.** `runStartupYtdlpAutoUpdate` can overwrite
`yt-dlp.exe` on launch while a deep-link/queued download spawns the same binary — a Windows `yt-dlp.exe` on launch while a deep-link/queued download spawns the same binary — a Windows
file-in-use/corrupt-spawn window. file-in-use/corrupt-spawn window.
- [ ] **L140 — Retry-during-teardown race.** `retry` reuses the same id; if the previous process's - [x] **L140 — Retry-during-teardown race.** `retry` reuses the same id; if the previous process's
`close` handler hasn't removed it from `active` yet, `startDownload` returns "already running" → markError. `close` handler hasn't removed it from `active` yet, `startDownload` returns "already running" → markError.
- [ ] **L141 — Renderer history grows unbounded in memory.** `useHistory.add` prepends without a cap *Fixed in [download.ts](src/main/download.ts): `cancelDownload`/`pauseDownload` now drop the item from
`active` synchronously (so a retry/resume's same-id spawn is never rejected by the stale entry), and the
doomed child's `close`/`error`/watchdog release the slot through a new `releaseActive(id, rec)` that only
deletes when that rec still owns the id — so a late teardown can't evict the fresh spawn. The transient
cookie jar is now keyed per spawn (`-${spawnSeq}`) so the old download's cleanup can't unlink the new one's
file. (with L148)*
- [x] **L141 — Renderer history grows unbounded in memory.** `useHistory.add` prepends without a cap
while main caps `history.json` at 500; in a long session the in-memory list exceeds the persisted cap (resets on reload). while main caps `history.json` at 500; in a long session the in-memory list exceeds the persisted cap (resets on reload).
*Fixed in [store/history.ts](src/renderer/src/store/history.ts): `add` now `.slice(0, MAX_ENTRIES)` (500, the
same cap as main's `history.ts`) and de-dupes by `url` as well as `id`, so the optimistic in-memory list
matches what a reload would show (also aligns with main's M35 url de-dup).*
- [ ] **L142 — IPC mutation return values are discarded everywhere.** history/templates/sources/settings - [ ] **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 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). optimistic-only updates and ignores it — client and persisted state can silently diverge until reload (generalizes M34).
@@ -668,8 +710,12 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
rather than being rejected up front. rather than being rejected up front.
- [x] **L147 — macOS branches in a Windows-only app.** `app.on('activate')` and the - [x] **L147 — macOS branches in a Windows-only app.** `app.on('activate')` and the
`process.platform !== 'darwin'` guard in `window-all-closed` are dead on Windows — boilerplate that implies multi-platform support the app doesn't ship. `process.platform !== 'darwin'` guard in `window-all-closed` are 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 - [x] **L148 — `clearFinished`/remove can free a slot before the killed process exits.** Removing a
just-canceled item lets `pump()` launch into the "freed" slot while `taskkill` is still tearing down the prior tree — a brief window over the concurrency cap. just-canceled item lets `pump()` launch into the "freed" slot while `taskkill` is still tearing down the prior tree — a brief window over the concurrency cap.
*Fixed with L140: `cancelDownload`/`pauseDownload` release the `active` slot the instant they issue the kill
(rather than on the async `close`), so main's concurrency accounting matches the renderer immediately — the
just-promoted next item is neither falsely rejected by the maxConcurrent guard nor run over the cap while the
prior tree dies.*
*Round 7 (2026-06-29) — copy, a11y patterns & micro-behavior:* *Round 7 (2026-06-29) — copy, a11y patterns & micro-behavior:*
@@ -1344,12 +1390,22 @@ cosmetics. `R` IDs.
- [x] **R5 — Settings write failure is unhandled.** `setSettings` calls `store.set(...)` with no try/catch; - [x] **R5 — Settings write failure is unhandled.** `setSettings` calls `store.set(...)` with no try/catch;
on disk-full/read-only `electron-store` throws → the IPC rejects → the renderer's `setSettings().catch(() on disk-full/read-only `electron-store` throws → the IPC rejects → the renderer's `setSettings().catch(()
=> {})` swallows it while the optimistic UI keeps the change (the disk-full form of M34). **Fix:** catch and report. => {})` swallows it while the optimistic UI keeps the change (the disk-full form of M34). **Fix:** catch and report.
- [ ] **R6 — Silent data loss on any store write failure.** A completed download that can't be recorded - [x] **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 `catch` is empty, (disk full) shows "Completed" in-session but is gone on restart, with no error (the write `catch` is empty,
M29). **Fix:** detect write failure and warn. M29). **Fix:** detect write failure and warn. *Fixed (log half) in [jsonStore.ts](src/main/jsonStore.ts):
- [ ] **R7 — `safeStorage` unavailable ⇒ secrets silently stored as plaintext.** `encryptSecret` falls back `writeJsonAtomic` now returns whether the write landed and logs the failure (with the path) instead of an
empty catch, and removes the leftover `.tmp`; `createJsonStore`'s `flush` only clears the dirty flag on
success, so a failed write stays pending and is retried on the next `write()` or the quit-time
`flushAllStores()` rather than dropped. Unit-tested in `test/jsonStore.test.ts`. **Scope note:** like M29
this delivers the "no longer silent" half (logged + retried); the user-facing toast needs the leveled file
logger / toast sink deferred to **CC8**.*
- [x] **R7 — `safeStorage` unavailable ⇒ secrets silently stored as plaintext.** `encryptSecret` falls back
to plaintext when DPAPI/safeStorage isn't available, with no indication — compounds H7 (cookies) and the 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. backup-cleartext issue (M22). **Fix:** warn (or refuse to store) when encryption is unavailable. *Fixed in
[settings.ts](src/main/settings.ts): `encryptSecret` now `console.warn`s before returning a non-empty secret
as plaintext, so the cleartext fallback is no longer silent. Chose warn-not-refuse to preserve the
pre-encryption behaviour (storing still works); DPAPI is effectively always present on Windows, so this
should never fire in practice. (refusing/at-rest enforcement remains an option if ever wanted.)*
- [x] **R8 — Pretty-printed JSON for large stores.** `JSON.stringify(value, null, 2)` inflates size/write - [x] **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. time for the potentially-20k-item media store (ties R3). **Fix:** compact JSON for the large stores.
- [ ] **R9 — Clock-skew sensitivity.** `shouldAutoCheckYtdlp` and the scheduled-download promoter compare - [ ] **R9 — Clock-skew sensitivity.** `shouldAutoCheckYtdlp` and the scheduled-download promoter compare
+34 -5
View File
@@ -47,6 +47,21 @@ interface ActiveDownload {
const active = new Map<string, ActiveDownload>() const active = new Map<string, ActiveDownload>()
// Per-spawn sequence, so a retry/resume that reuses the item id still gets a
// unique transient cookie file (below) — otherwise a superseded download's
// teardown could unlink the new spawn's jar mid-read.
let spawnSeq = 0
// Remove an item from the active map, but only if THIS rec still owns the slot.
// cancel/pause release the slot synchronously (so the renderer's just-promoted
// next item isn't rejected by the maxConcurrent guard while the killed tree is
// still tearing down); the doomed child's later 'close'/'error' then runs this as
// a no-op. The identity check matters because a retry/resume reuses the item id,
// so a stale teardown must not evict the newer same-id spawn. (L140/L148)
function releaseActive(id: string, rec: ActiveDownload): void {
if (active.get(id) === rec) active.delete(id)
}
// Backstop for B1: if a spawned yt-dlp goes completely silent — no stdout or // Backstop for B1: if a spawned yt-dlp goes completely silent — no stdout or
// stderr — for this long, treat it as wedged, kill it, and error the item so the // stderr — for this long, treat it as wedged, kill it, and error the item so the
// concurrency slot frees instead of leaking forever. Generous on purpose so a // concurrency slot frees instead of leaking forever. Generous on purpose so a
@@ -269,7 +284,9 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
// settles, so the plaintext never lingers at rest. // settles, so the plaintext never lingers at rest.
let cookiesFile: string | undefined let cookiesFile: string | undefined
if (getSettings().cookieSource === 'login' && hasStoredCookies()) { if (getSettings().cookieSource === 'login' && hasStoredCookies()) {
const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}.txt`) // Unique per spawn (not just per id): a retry/resume reuses opts.id, and the
// superseded download's cleanupCookies() must not unlink the new spawn's jar.
const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}-${++spawnSeq}.txt`)
if (materializeCookies(tmp)) cookiesFile = tmp if (materializeCookies(tmp)) cookiesFile = tmp
} }
function cleanupCookies(): void { function cleanupCookies(): void {
@@ -337,11 +354,14 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
function bumpWatchdog(): void { function bumpWatchdog(): void {
clearWatchdog() clearWatchdog()
stallTimer = setTimeout(() => { stallTimer = setTimeout(() => {
if (settled) return // A cancel/pause races the timer: it already released the slot and the
// child's close stays silent, so don't fire a spurious stall error (mirrors
// the canceled/paused guards on the close handler below).
if (settled || rec.canceled || rec.paused) return
settled = true settled = true
clearWatchdog() clearWatchdog()
cleanupCookies() cleanupCookies()
active.delete(opts.id) releaseActive(opts.id, rec)
killTree(rec) killTree(rec)
const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.` const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
send(wc, { type: 'error', id: opts.id, error: msg }) send(wc, { type: 'error', id: opts.id, error: msg })
@@ -385,7 +405,7 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
settled = true settled = true
clearWatchdog() clearWatchdog()
cleanupCookies() cleanupCookies()
active.delete(opts.id) releaseActive(opts.id, rec)
// A paused download was killed on purpose — stay silent, like a cancel. // A paused download was killed on purpose — stay silent, like a cancel.
if (!rec.canceled && !rec.paused) { if (!rec.canceled && !rec.paused) {
send(wc, { type: 'error', id: opts.id, error: err.message }) send(wc, { type: 'error', id: opts.id, error: err.message })
@@ -399,7 +419,7 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
settled = true settled = true
clearWatchdog() clearWatchdog()
cleanupCookies() cleanupCookies()
active.delete(opts.id) releaseActive(opts.id, rec)
// Canceled: renderer already showed 'canceled'. Paused: renderer showed // Canceled: renderer already showed 'canceled'. Paused: renderer showed
// 'paused' and keeps the .part for a later resume. Either way, no event. // 'paused' and keeps the .part for a later resume. Either way, no event.
if (rec.canceled || rec.paused) return if (rec.canceled || rec.paused) return
@@ -438,6 +458,11 @@ export function cancelDownload(id: string): void {
const rec = active.get(id) const rec = active.get(id)
if (!rec) return if (!rec) return
rec.canceled = true rec.canceled = true
// Free the slot now (not on the async 'close') so the renderer's just-promoted
// next item isn't rejected by the maxConcurrent guard — or run briefly over the
// cap — while taskkill tears down this tree. The child's later 'close' stays
// silent (rec.canceled) and releaseActive() no-ops. (L148)
active.delete(id)
killTree(rec) killTree(rec)
} }
@@ -452,5 +477,9 @@ export function pauseDownload(id: string): void {
const rec = active.get(id) const rec = active.get(id)
if (!rec) return if (!rec) return
rec.paused = true rec.paused = true
// Free the slot now (see cancelDownload / L148). The partial .part stays on disk
// for a later resume, which reuses this id — releaseActive()'s identity check
// keeps the doomed child's 'close' from evicting that fresh spawn. (L140)
active.delete(id)
killTree(rec) killTree(rec)
} }
+4 -4
View File
@@ -1,16 +1,16 @@
import type { HistoryEntry } from '@shared/ipc' import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { isValidHistoryEntry } from './validation' import { isValidHistoryEntry } from './validation'
import { createJsonStore } from './jsonStore' import { createJsonStore } from './jsonStore'
// Plain JSON in userData (portable build redirects userData next to the exe). // Plain JSON in userData (portable build redirects userData next to the exe).
// Kept simple per the build plan; can migrate to better-sqlite3 later. Atomic // Kept simple per the build plan; can migrate to better-sqlite3 later. Atomic
// writes, corruption backup, and caching come from the shared jsonStore (R1R3). // writes, corruption backup, and caching come from the shared jsonStore (R1R3).
const MAX_ENTRIES = 500 // The row cap (HISTORY_MAX_ENTRIES) is shared with the renderer's optimistic list.
//
// Per-entry validation (isValidHistoryEntry) so a hand-edited or corrupted // Per-entry validation (isValidHistoryEntry) so a hand-edited or corrupted
// history.json can't feed the UI (or openPath) entries with the wrong shape — // history.json can't feed the UI (or openPath) entries with the wrong shape —
// invalid rows are dropped rather than trusted. (audit S5) // invalid rows are dropped rather than trusted. (audit S5)
const store = createJsonStore('history.json', isValidHistoryEntry, MAX_ENTRIES) const store = createJsonStore('history.json', isValidHistoryEntry, HISTORY_MAX_ENTRIES)
export function listHistory(): HistoryEntry[] { export function listHistory(): HistoryEntry[] {
return store.read() return store.read()
+22 -7
View File
@@ -1,6 +1,6 @@
import { app } from 'electron' import { app } from 'electron'
import { join } from 'path' import { join } from 'path'
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync } from 'fs' import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, unlinkSync } from 'fs'
/** /**
* One shared persistence layer for the hand-rolled JSON array stores (history, * One shared persistence layer for the hand-rolled JSON array stores (history,
@@ -55,16 +55,29 @@ export function readJsonArraySafe<T>(path: string, isValid: (o: unknown) => o is
* `rename` it over the target (an atomic operation on the same volume), so a * `rename` it over the target (an atomic operation on the same volume), so a
* crash mid-write leaves either the old file or the new one -- never a truncated * crash mid-write leaves either the old file or the new one -- never a truncated
* one (R1). `pretty` indents for human-readable files; pass false for large * one (R1). `pretty` indents for human-readable files; pass false for large
* machine-only stores to avoid inflating size/write time (R8). Best-effort: a * machine-only stores to avoid inflating size/write time (R8). Returns whether the
* read-only data dir just means nothing is persisted. * write landed: a failure (disk full, read-only data dir) is logged rather than
* swallowed silently, so a record that looked saved but vanished on restart is at
* least diagnosable (R6). The leftover temp file is removed on failure so a partial
* `.tmp` can't accumulate.
*/ */
export function writeJsonAtomic(path: string, value: unknown, pretty = true): void { export function writeJsonAtomic(path: string, value: unknown, pretty = true): boolean {
const tmp = `${path}.tmp` const tmp = `${path}.tmp`
try { try {
writeFileSync(tmp, pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)) writeFileSync(tmp, pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value))
renameSync(tmp, path) renameSync(tmp, path)
return true
} catch (e) {
// R6: previously a silent catch — a failed persist (disk full, read-only
// profile) dropped the change with no trace. Log it; the user-facing warning
// is deferred to the leveled file logger (CC8).
console.error(`[AeroFetch] failed to write ${path}:`, e)
try {
if (existsSync(tmp)) unlinkSync(tmp)
} catch { } catch {
/* best-effort; e.g. a read-only data dir */ /* best-effort temp cleanup */
}
return false
} }
} }
@@ -110,8 +123,10 @@ export function createJsonStore<T>(
timer = null timer = null
} }
if (!dirty) return if (!dirty) return
dirty = false // Only clear the dirty flag once the write actually lands. A failed flush
writeJsonAtomic(filePath(), cache ?? [], pretty) // (disk full, transient lock) stays dirty so the next write() or the
// quit-time flushAllStores() retries it instead of silently dropping it (R6).
if (writeJsonAtomic(filePath(), cache ?? [], pretty)) dirty = false
} }
function write(items: T[]): T[] { function write(items: T[]): T[] {
+11 -45
View File
@@ -13,6 +13,7 @@ import {
VIDEO_QUALITY_OPTIONS, VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS, AUDIO_QUALITY_OPTIONS,
DEFAULT_DOWNLOAD_OPTIONS, DEFAULT_DOWNLOAD_OPTIONS,
DEFAULT_SETTINGS,
isYtdlpUpdateChannel, isYtdlpUpdateChannel,
type Settings, type Settings,
type DownloadOptions, type DownloadOptions,
@@ -22,51 +23,11 @@ import {
type VideoCodecPref type VideoCodecPref
} from '@shared/ipc' } from '@shared/ipc'
const DEFAULTS: Settings = { // The electron-store defaults are the canonical DEFAULT_SETTINGS from the shared
// Both blank by default → downloads land in Documents\Video / Documents\Audio // contract — single-sourced so the renderer FALLBACK and the preview mock can't
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override. // drift from main (C1). The rationale for individual defaults (SR1/SR2/SR3) lives
videoDir: '', // alongside DEFAULT_SETTINGS in shared/ipc.ts.
audioDir: '', const DEFAULTS: Settings = DEFAULT_SETTINGS
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
// Follow the OS theme on first launch (SR1) so a dark-mode Windows user isn't
// greeted by a bright white window despite full system-theme support.
theme: 'system',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
downloadArchive: false,
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
// configured channel so a stale binary can't silently cause YouTube 403s.
autoUpdateYtdlp: true,
// Default to stable so a nightly regression doesn't break downloads for
// everyone out of the box (SR2). Users who want the latest YouTube fixes
// can switch to nightly in Settings → Software.
ytdlpChannel: 'stable',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
// Default off so adding a watched channel doesn't silently fill the user's
// disk on first use (SR3). Enable explicitly once they know what it does.
autoDownloadNew: false,
hasCompletedOnboarding: false,
minimizeToTray: false,
launchAtStartup: false,
sidebarCollapsed: false,
updateToken: ''
}
/** /**
* Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at * Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at
@@ -197,6 +158,11 @@ function encryptSecret(plain: string): string {
} catch { } catch {
/* fall through — store plaintext, as it was before encryption existed */ /* fall through — store plaintext, as it was before encryption existed */
} }
// R7: OS encryption (DPAPI/keychain) is unavailable, so this credential is about
// to be written in cleartext. Warn rather than fall back silently — a leaked
// settings.json would then expose the proxy password / API token. (DPAPI is
// effectively always present on Windows, so this should never fire in practice.)
console.warn('[AeroFetch] OS secret encryption unavailable — storing credential as plaintext.')
return plain return plain
} }
+6
View File
@@ -12,6 +12,12 @@ import { LiveRegion } from './components/LiveRegion'
import { getTheme, pageBackground } from './theme' import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings' import { useSettings } from './store/settings'
import { useDownloads } from './store/downloads' import { useDownloads } from './store/downloads'
// Eagerly load the sources store for its startup side-effects (load persisted
// sources + the watched-source / scheduled `--sync` kickoff) and its cross-store
// subscription (coordinator's downloadCompleted → markDownloaded). Before C2 this
// was guaranteed by downloads.ts importing it; now that the cycle is broken, App
// owns the eager load so it survives the views becoming lazy-loaded (PERF8).
import './store/sources'
import { summarizeQueue } from './store/queueStats' import { summarizeQueue } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme' import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError' import { logError } from './reportError'
+1 -3
View File
@@ -28,6 +28,7 @@ import {
DismissRegular DismissRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { MediaItem, Source, MediaKind } from '@shared/ipc' import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { useSources } from '../store/sources' import { useSources } from '../store/sources'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink' import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
@@ -43,9 +44,6 @@ import { SegmentedControl } from './ui/SegmentedControl'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
import { THUMB_XS } from '../thumbSizes' import { THUMB_XS } from '../thumbSizes'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */ /** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending' type ItemStatus = DownloadStatus | 'pending'
+8
View File
@@ -0,0 +1,8 @@
/**
* True in the standalone browser UI preview, where Electron's preload never ran
* so `window.electron` (and the real `window.api` IPC bridge) is absent. The
* stores and a few views branch on this to drive seed data / fake tickers instead
* of calling IPC. Single-sourced here (C1) so the check isn't re-declared in ~8
* files import it as `PREVIEW` where that reads better locally.
*/
export const isPreview = typeof window === 'undefined' || !window.electron
+5 -268
View File
@@ -1,279 +1,16 @@
import './assets/base.css' import './assets/base.css'
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings, type CommandTemplate } from '@shared/ipc'
import App from './App' import App from './App'
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import { mockApi } from './mockApi'
// In the standalone UI preview (browser, no Electron preload) window.api isn't // In the standalone UI preview (browser, no Electron preload) window.api isn't
// injected. Stub the full surface so the UI renders and stays interactive during // injected. Install the mock surface (mockApi.ts) so the UI renders and stays
// design work. The store detects preview mode (no window.electron) and drives a // interactive during design work. In the real Electron app this branch is
// fake progress ticker instead of calling these, so most are inert no-ops. // skipped — preload provides the real window.api.
// In the real Electron app this branch is skipped — preload provides window.api.
if (import.meta.env.DEV && !window.api) { if (import.meta.env.DEV && !window.api) {
const MOCK_SETTINGS: Settings = { window.api = mockApi
videoDir: 'C:\\Users\\you\\Documents\\Video',
audioDir: 'C:\\Users\\you\\Documents\\Audio',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'system',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: true,
minimizeToTray: false,
launchAtStartup: false,
sidebarCollapsed: false,
updateToken: ''
}
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
// sign-in/clear flow be exercised in this browser-only preview.
let mockCookiesSavedAt: number | null = null
// Stands in for templates.json — lets the Custom commands card's CRUD and
// the download bar's template picker be exercised in this browser-only preview.
let mockTemplates: CommandTemplate[] = [
{ id: 'tpl1', name: 'Write thumbnail, no mtime', args: '--write-thumbnail --no-mtime' }
]
window.api = {
getAppVersion: async () => '0.4.0-preview',
checkForAppUpdate: async () => {
await new Promise((r) => setTimeout(r, 400))
return {
ok: true,
available: true,
currentVersion: '0.4.0',
latestVersion: '0.5.0',
notes:
'### Whats new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes',
htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases',
downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe',
assetName: 'AeroFetch-Setup-0.5.0.exe'
}
},
downloadAppUpdate: async () => ({
ok: false,
error: 'Downloading updates is disabled in the browser preview.'
}),
runAppUpdate: async () => ({ ok: true }),
onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
getFfmpegVersions: async () => ({
ffmpeg: '7.1-full_build (UI preview mock)',
ffprobe: '7.1-full_build (UI preview mock)'
}),
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
if (/list=|playlist/i.test(url)) {
return {
ok: true,
kind: 'playlist',
playlist: {
title: 'Full Electron Course — All Episodes',
uploader: 'DevChannel',
count: 5,
entries: Array.from({ length: 5 }, (_, i) => ({
index: i + 1,
id: `vid${i + 1}`,
title: `Episode ${i + 1}${['Setup', 'Main Process', 'IPC', 'Packaging', 'Release'][i]}`,
url: `https://youtube.com/watch?v=vid${i + 1}`,
durationLabel: `${10 + i}:0${i}`,
uploader: 'DevChannel'
}))
}
}
}
return {
ok: true,
kind: 'video',
info: {
title: 'Building a Desktop App with Electron — Full Course',
channel: 'DevChannel',
durationLabel: '1:42:08',
thumbnail: undefined,
formats: [
{ id: '__best__', label: 'Best available', ext: 'mp4', hasAudio: true },
{
id: '299',
label: '1080p60 · mp4 · 248 MB',
height: 1080,
ext: 'mp4',
filesizeLabel: '248 MB',
hasAudio: false
},
{
id: '136',
label: '720p · mp4 · 124 MB',
height: 720,
ext: 'mp4',
filesizeLabel: '124 MB',
hasAudio: false
},
{
id: '135',
label: '480p · mp4 · 72 MB',
height: 480,
ext: 'mp4',
filesizeLabel: '72 MB',
hasAudio: false
},
{
id: '134',
label: '360p · mp4 · 38 MB',
height: 360,
ext: 'mp4',
filesizeLabel: '38 MB',
hasAudio: false
}
]
}
}
},
startDownload: async () => ({ ok: true }),
cancelDownload: async () => {},
pauseDownload: async () => {},
chooseFolder: async () => null,
openPath: async () => '',
openUrl: async () => {},
showInFolder: async () => {},
readClipboard: async () => '',
getSettings: async () => MOCK_SETTINGS,
setSettings: async (partial) => Object.assign(MOCK_SETTINGS, partial),
listHistory: async () => [],
addHistory: async () => [],
removeHistory: async () => [],
removeManyHistory: async () => [],
clearHistory: async () => [],
cookiesLogin: async () => {
await new Promise((r) => setTimeout(r, 600)) // simulate the sign-in window
mockCookiesSavedAt = Date.now()
return { ok: true, cookieCount: 14 }
},
cookiesStatus: async () =>
mockCookiesSavedAt ? { exists: true, savedAt: mockCookiesSavedAt } : { exists: false },
cookiesClear: async () => {
mockCookiesSavedAt = null
},
listTemplates: async () => mockTemplates,
saveTemplate: async (template) => {
mockTemplates = [template, ...mockTemplates.filter((t) => t.id !== template.id)]
return mockTemplates
},
removeTemplate: async (id) => {
mockTemplates = mockTemplates.filter((t) => t.id !== id)
return mockTemplates
},
previewCommand: async (opts) => {
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
return {
ok: true,
command:
`yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
}
},
updateYtdlp: async (channel) => {
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
return {
ok: true,
output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)`
}
},
// No background updater in the browser preview — hand back an inert unsubscribe.
onYtdlpAutoUpdateStatus: () => () => {},
listErrorLog: async () => [],
clearErrorLog: async () => [],
exportBackup: async () => ({
ok: true,
path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json'
}),
importBackup: async () => ({ ok: true }),
onDownloadEvent: () => () => {},
getSystemTheme: async () => ({
shouldUseDarkColors: false,
shouldUseHighContrastColors: false
}),
onSystemThemeUpdate: () => () => {},
openHighContrastSettings: async () => {},
onExternalUrl: () => () => {},
// Media-manager sources — a seeded channel so the (Phase H) Library view has
// demo data in this browser-only preview.
listSources: async () => [
{
id: 'src-demo',
url: 'https://www.youtube.com/@DevChannel',
kind: 'channel',
title: 'DevChannel',
channel: 'DevChannel',
addedAt: Date.now() - 86_400_000,
lastIndexedAt: Date.now() - 3_600_000,
itemCount: 7
}
],
indexSource: async (url) => {
await new Promise((r) => setTimeout(r, 800)) // simulate the index walk
return {
ok: true,
source: {
id: 'src-demo',
url,
kind: 'channel',
title: 'DevChannel',
channel: 'DevChannel',
addedAt: Date.now(),
lastIndexedAt: Date.now(),
itemCount: 7
},
itemCount: 7
}
},
reindexSource: async () => ({ ok: true, itemCount: 7, newCount: 0 }),
removeSource: async () => [],
markSourceItemDownloaded: async () => [],
setSourceWatched: async () => [],
syncSources: async () => ({ ok: true, newItems: [] }),
getScheduledSync: async () => ({ enabled: false }),
setScheduledSync: async (enabled: boolean) => ({ enabled }),
listSourceItems: async (sourceId) =>
Array.from({ length: 7 }, (_, i) => ({
id: `${sourceId}:vid${i + 1}`,
sourceId,
videoId: `vid${i + 1}`,
title: `Episode ${i + 1}`,
url: `https://youtube.com/watch?v=vid${i + 1}`,
playlistTitle: i < 5 ? 'Full Electron Course' : 'Uploads',
playlistIndex: i < 5 ? i + 1 : i - 4,
durationLabel: `${10 + i}:0${i}`,
downloaded: i < 2
})),
onIndexProgress: () => () => {},
runTerminal: async () => ({ ok: true }),
cancelTerminal: async () => {},
onTerminalOutput: () => () => {},
setTaskbarProgress: async () => {},
mintPoToken: async () => null
}
} }
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
+255
View File
@@ -0,0 +1,255 @@
import { DEFAULT_SETTINGS, type Settings, type CommandTemplate } from '@shared/ipc'
/**
* The browser-preview stand-in for the real `window.api` IPC bridge (C1). In the
* standalone UI preview (browser, no Electron preload) `window.api` isn't
* injected, so main.tsx assigns this mock to keep the UI interactive during
* design work. Typed as the real `Api` surface (`Window['api']`) so it can't
* silently drift from the preload contract a missing or mistyped method is a
* compile error. Most methods are inert no-ops because the stores detect preview
* mode (no window.electron) and drive a fake progress ticker instead.
*/
type Api = Window['api']
// The preview settings: the canonical defaults, plus preview-only tweaks —
// placeholder folders, a nightly channel + a recent check to exercise the
// updater card, auto-download on, and onboarding already dismissed so design
// work isn't blocked behind the welcome screen.
const MOCK_SETTINGS: Settings = {
...DEFAULT_SETTINGS,
videoDir: 'C:\\Users\\you\\Documents\\Video',
audioDir: 'C:\\Users\\you\\Documents\\Audio',
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
autoDownloadNew: true,
hasCompletedOnboarding: true
}
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
// sign-in/clear flow be exercised in this browser-only preview.
let mockCookiesSavedAt: number | null = null
// Stands in for templates.json — lets the Custom commands card's CRUD and
// the download bar's template picker be exercised in this browser-only preview.
let mockTemplates: CommandTemplate[] = [
{ id: 'tpl1', name: 'Write thumbnail, no mtime', args: '--write-thumbnail --no-mtime' }
]
export const mockApi: Api = {
getAppVersion: async () => '0.4.0-preview',
checkForAppUpdate: async () => {
await new Promise((r) => setTimeout(r, 400))
return {
ok: true,
available: true,
currentVersion: '0.4.0',
latestVersion: '0.5.0',
notes:
'### Whats new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes',
htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases',
downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe',
assetName: 'AeroFetch-Setup-0.5.0.exe'
}
},
downloadAppUpdate: async () => ({
ok: false,
error: 'Downloading updates is disabled in the browser preview.'
}),
runAppUpdate: async () => ({ ok: true }),
onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
getFfmpegVersions: async () => ({
ffmpeg: '7.1-full_build (UI preview mock)',
ffprobe: '7.1-full_build (UI preview mock)'
}),
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
if (/list=|playlist/i.test(url)) {
return {
ok: true,
kind: 'playlist',
playlist: {
title: 'Full Electron Course — All Episodes',
uploader: 'DevChannel',
count: 5,
entries: Array.from({ length: 5 }, (_, i) => ({
index: i + 1,
id: `vid${i + 1}`,
title: `Episode ${i + 1}${['Setup', 'Main Process', 'IPC', 'Packaging', 'Release'][i]}`,
url: `https://youtube.com/watch?v=vid${i + 1}`,
durationLabel: `${10 + i}:0${i}`,
uploader: 'DevChannel'
}))
}
}
}
return {
ok: true,
kind: 'video',
info: {
title: 'Building a Desktop App with Electron — Full Course',
channel: 'DevChannel',
durationLabel: '1:42:08',
thumbnail: undefined,
formats: [
{ id: '__best__', label: 'Best available', ext: 'mp4', hasAudio: true },
{
id: '299',
label: '1080p60 · mp4 · 248 MB',
height: 1080,
ext: 'mp4',
filesizeLabel: '248 MB',
hasAudio: false
},
{
id: '136',
label: '720p · mp4 · 124 MB',
height: 720,
ext: 'mp4',
filesizeLabel: '124 MB',
hasAudio: false
},
{
id: '135',
label: '480p · mp4 · 72 MB',
height: 480,
ext: 'mp4',
filesizeLabel: '72 MB',
hasAudio: false
},
{
id: '134',
label: '360p · mp4 · 38 MB',
height: 360,
ext: 'mp4',
filesizeLabel: '38 MB',
hasAudio: false
}
]
}
}
},
startDownload: async () => ({ ok: true }),
cancelDownload: async () => {},
pauseDownload: async () => {},
chooseFolder: async () => null,
openPath: async () => '',
openUrl: async () => {},
showInFolder: async () => {},
readClipboard: async () => '',
getSettings: async () => MOCK_SETTINGS,
setSettings: async (partial) => Object.assign(MOCK_SETTINGS, partial),
listHistory: async () => [],
addHistory: async () => [],
removeHistory: async () => [],
removeManyHistory: async () => [],
clearHistory: async () => [],
cookiesLogin: async () => {
await new Promise((r) => setTimeout(r, 600)) // simulate the sign-in window
mockCookiesSavedAt = Date.now()
return { ok: true, cookieCount: 14 }
},
cookiesStatus: async () =>
mockCookiesSavedAt ? { exists: true, savedAt: mockCookiesSavedAt } : { exists: false },
cookiesClear: async () => {
mockCookiesSavedAt = null
},
listTemplates: async () => mockTemplates,
saveTemplate: async (template) => {
mockTemplates = [template, ...mockTemplates.filter((t) => t.id !== template.id)]
return mockTemplates
},
removeTemplate: async (id) => {
mockTemplates = mockTemplates.filter((t) => t.id !== id)
return mockTemplates
},
previewCommand: async (opts) => {
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
return {
ok: true,
command: `yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
}
},
updateYtdlp: async (channel) => {
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
return {
ok: true,
output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)`
}
},
// No background updater in the browser preview — hand back an inert unsubscribe.
onYtdlpAutoUpdateStatus: () => () => {},
listErrorLog: async () => [],
clearErrorLog: async () => [],
exportBackup: async () => ({
ok: true,
path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json'
}),
importBackup: async () => ({ ok: true }),
onDownloadEvent: () => () => {},
getSystemTheme: async () => ({
shouldUseDarkColors: false,
shouldUseHighContrastColors: false
}),
onSystemThemeUpdate: () => () => {},
openHighContrastSettings: async () => {},
onExternalUrl: () => () => {},
// Media-manager sources — a seeded channel so the (Phase H) Library view has
// demo data in this browser-only preview.
listSources: async () => [
{
id: 'src-demo',
url: 'https://www.youtube.com/@DevChannel',
kind: 'channel',
title: 'DevChannel',
channel: 'DevChannel',
addedAt: Date.now() - 86_400_000,
lastIndexedAt: Date.now() - 3_600_000,
itemCount: 7
}
],
indexSource: async (url) => {
await new Promise((r) => setTimeout(r, 800)) // simulate the index walk
return {
ok: true,
source: {
id: 'src-demo',
url,
kind: 'channel',
title: 'DevChannel',
channel: 'DevChannel',
addedAt: Date.now(),
lastIndexedAt: Date.now(),
itemCount: 7
},
itemCount: 7
}
},
reindexSource: async () => ({ ok: true, itemCount: 7, newCount: 0 }),
removeSource: async () => [],
markSourceItemDownloaded: async () => [],
setSourceWatched: async () => [],
syncSources: async () => ({ ok: true, newItems: [] }),
getScheduledSync: async () => ({ enabled: false }),
setScheduledSync: async (enabled: boolean) => ({ enabled }),
listSourceItems: async (sourceId) =>
Array.from({ length: 7 }, (_, i) => ({
id: `${sourceId}:vid${i + 1}`,
sourceId,
videoId: `vid${i + 1}`,
title: `Episode ${i + 1}`,
url: `https://youtube.com/watch?v=vid${i + 1}`,
playlistTitle: i < 5 ? 'Full Electron Course' : 'Uploads',
playlistIndex: i < 5 ? i + 1 : i - 4,
durationLabel: `${10 + i}:0${i}`,
downloaded: i < 2
})),
onIndexProgress: () => () => {},
runTerminal: async () => ({ ok: true }),
cancelTerminal: async () => {},
onTerminalOutput: () => () => {},
setTaskbarProgress: async () => {},
mintPoToken: async () => null
}
+48
View File
@@ -0,0 +1,48 @@
import type { AddEntry } from './downloads'
/**
* A tiny typed event bus that owns the cross-store reactions between the
* downloads and sources stores (C2). Previously `downloads.ts` imported
* `useSources` and `sources.ts` imported `useDownloads`, a circular dependency
* that only worked because both sides reached across lazily via `.getState()`.
*
* Now each store depends on this bus instead of on the other: a store *emits* the
* events it produces and *subscribes* to the ones it consumes. The bus itself has
* no runtime dependency on either store (the `AddEntry` import is type-only and is
* erased at build time), so there is no import cycle.
*
* sources --enqueueDownloads--> downloads (queue a channel's media items)
* downloads --downloadCompleted--> sources (mark the source item downloaded)
*/
export interface StoreEvents {
/** sources → downloads: enqueue a batch of media items as downloads */
enqueueDownloads: AddEntry[]
/** downloads → sources: a download tied to a source MediaItem finished */
downloadCompleted: { mediaItemId: string; filePath?: string }
}
type Handler<K extends keyof StoreEvents> = (payload: StoreEvents[K]) => void
// Heterogeneous handler sets keyed by event name. The `never` payload is the
// standard trick for a per-key event map; the public on/emit signatures keep the
// payload types sound at every call site.
const handlers = new Map<keyof StoreEvents, Set<(payload: never) => void>>()
/** Subscribe to a store event. Returns an unsubscribe function. */
export function on<K extends keyof StoreEvents>(event: K, handler: Handler<K>): () => void {
let set = handlers.get(event)
if (!set) {
set = new Set()
handlers.set(event, set)
}
const erased = handler as (payload: never) => void
set.add(erased)
return () => set.delete(erased)
}
/** Dispatch a store event to all current subscribers. */
export function emit<K extends keyof StoreEvents>(event: K, payload: StoreEvents[K]): void {
const set = handlers.get(event)
if (!set) return
for (const handler of set) (handler as Handler<K>)(payload)
}
+10 -6
View File
@@ -6,9 +6,10 @@ import {
type CollectionContext, type CollectionContext,
type MediaKind type MediaKind
} from '@shared/ipc' } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { useSettings } from './settings' import { useSettings } from './settings'
import { useHistory } from './history' import { useHistory } from './history'
import { useSources } from './sources' import { emit, on } from './coordinator'
import { newId } from '../id' import { newId } from '../id'
// Single-sourced from the IPC contract (L105) and re-exported so existing // Single-sourced from the IPC contract (L105) and re-exported so existing
@@ -99,10 +100,6 @@ export interface AddEntry {
opts?: AddOptions opts?: AddOptions
} }
// True when running in the standalone browser preview (no Electron preload).
// The real preload injects window.electron; the browser stub does not.
const PREVIEW = typeof window === 'undefined' || !window.electron
interface DownloadState { interface DownloadState {
items: DownloadItem[] items: DownloadItem[]
addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void
@@ -274,7 +271,9 @@ function recordCompletion(item: DownloadItem): void {
}) })
} }
if (item.mediaItemId) { if (item.mediaItemId) {
useSources.getState().markDownloaded(item.mediaItemId, item.filePath) // Tell the sources store to mark its MediaItem downloaded — via the event bus
// rather than a direct import, so downloads and sources don't form a cycle (C2).
emit('downloadCompleted', { mediaItemId: item.mediaItemId, filePath: item.filePath })
} }
} }
@@ -628,6 +627,11 @@ export const useDownloads = create<DownloadState>((set, get) => {
} }
}) })
// Consume the sources store's enqueue requests (C2): when the user downloads a
// channel's media items, sources emits the batch on the bus and we add it here —
// no direct import from sources, so the two stores stay acyclic.
on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries))
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives. // Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
// Session-only: the queue isn't persisted, so a schedule only fires while the app // Session-only: the queue isn't persisted, so a schedule only fires while the app
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times. // runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
+1 -3
View File
@@ -1,10 +1,8 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { ErrorLogEntry } from '@shared/ipc' import type { ErrorLogEntry } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError' import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Sample entries so the Diagnostics card has content during UI design. // Sample entries so the Diagnostics card has content during UI design.
const seed: ErrorLogEntry[] = PREVIEW const seed: ErrorLogEntry[] = PREVIEW
? [ ? [
+12 -5
View File
@@ -1,10 +1,8 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { HistoryEntry } from '@shared/ipc' import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError' import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Mock history so the History tab has content during UI design. // Mock history so the History tab has content during UI design.
const seed: HistoryEntry[] = PREVIEW const seed: HistoryEntry[] = PREVIEW
? [ ? [
@@ -44,6 +42,10 @@ const seed: HistoryEntry[] = PREVIEW
] ]
: [] : []
// Cap the optimistic in-memory list at the same shared limit main persists to
// (HISTORY_MAX_ENTRIES) so a long session can't grow it past what a reload would
// show (L141). De-duping by url as well as id keeps it consistent with main's add
// (M35), so the optimistic list matches the reload.
interface HistoryState { interface HistoryState {
entries: HistoryEntry[] entries: HistoryEntry[]
add: (entry: HistoryEntry) => void add: (entry: HistoryEntry) => void
@@ -58,7 +60,12 @@ export const useHistory = create<HistoryState>((set, get) => ({
entries: seed, entries: seed,
add: (entry) => { add: (entry) => {
set((s) => ({ entries: [entry, ...s.entries.filter((e) => e.id !== entry.id)] })) set((s) => ({
entries: [entry, ...s.entries.filter((e) => e.id !== entry.id && e.url !== entry.url)].slice(
0,
HISTORY_MAX_ENTRIES
)
}))
if (!PREVIEW) window.api.addHistory(entry).catch(logError('addHistory')) if (!PREVIEW) window.api.addHistory(entry).catch(logError('addHistory'))
}, },
+16 -42
View File
@@ -1,48 +1,22 @@
import { create } from 'zustand' import { create } from 'zustand'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc' import { DEFAULT_SETTINGS, type Settings } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError' import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload). // The pre-load fallback is the canonical DEFAULT_SETTINGS (single-sourced from the
const PREVIEW = typeof window === 'undefined' || !window.electron // shared contract, C1) — including theme:'system' (SR1) so there's no white-on-
// first-paint flash for a dark-mode user before the real settings arrive. In the
const FALLBACK: Settings = { // browser preview a couple of fields differ: placeholder folder paths, and
videoDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Video' : '', // onboarding pre-dismissed so design work isn't blocked behind the welcome screen
audioDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Audio' : '', // (a real first launch keeps DEFAULT_SETTINGS' false from main).
defaultKind: 'video', const FALLBACK: Settings = PREVIEW
defaultVideoQuality: 'Best available', ? {
defaultAudioQuality: 'Best', ...DEFAULT_SETTINGS,
maxConcurrent: 2, videoDir: 'C:\\Users\\you\\Documents\\Video',
filenameTemplate: '%(title)s.%(ext)s', audioDir: 'C:\\Users\\you\\Documents\\Audio',
// Mirror main's DEFAULTS (SR1): follow the OS theme until real settings load, hasCompletedOnboarding: true
// so there's no white-on-first-paint flash for a dark-mode user. }
theme: 'system', : DEFAULT_SETTINGS
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'stable',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: false,
// True in preview so design work isn't blocked behind the welcome screen;
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
hasCompletedOnboarding: PREVIEW,
minimizeToTray: false,
launchAtStartup: false,
sidebarCollapsed: false,
updateToken: ''
}
interface SettingsState extends Settings { interface SettingsState extends Settings {
/** true once persisted settings have loaded (always true in preview) */ /** true once persisted settings have loaded (always true in preview) */
+13 -6
View File
@@ -1,12 +1,10 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc' import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
import { useDownloads } from './downloads' import { isPreview as PREVIEW } from '../isPreview'
import { useSettings } from './settings' import { useSettings } from './settings'
import { emit, on } from './coordinator'
import { logError } from '../reportError' import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// --- Preview seed data ------------------------------------------------------ // --- Preview seed data ------------------------------------------------------
function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] { function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
@@ -185,8 +183,11 @@ export const useSources = create<SourcesState>((set, get) => ({
// Per-batch override (Library video/audio toggle) falls back to the global default (M27). // Per-batch override (Library video/audio toggle) falls back to the global default (M27).
const kind = kindOverride ?? settings.defaultKind const kind = kindOverride ?? settings.defaultKind
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
// One batched state update + pump, so queuing a whole channel stays O(n). // Hand the batch to the downloads store via the event bus (C2) rather than a
useDownloads.getState().addMany( // direct import. addMany applies it as one batched state update + pump, so
// queuing a whole channel stays O(n).
emit(
'enqueueDownloads',
items.map((it) => ({ items.map((it) => ({
url: it.url, url: it.url,
kind, kind,
@@ -268,6 +269,12 @@ export const useSources = create<SourcesState>((set, get) => ({
} }
})) }))
// When a download tied to one of our MediaItems finishes, the downloads store
// emits this on the bus (C2) — mark the item downloaded so the library reflects it.
on('downloadCompleted', ({ mediaItemId, filePath }) =>
useSources.getState().markDownloaded(mediaItemId, filePath)
)
// Load persisted sources on startup, and subscribe to live indexing progress. // Load persisted sources on startup, and subscribe to live indexing progress.
if (!PREVIEW) { if (!PREVIEW) {
window.api window.api
+1 -3
View File
@@ -1,10 +1,8 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { SystemThemeInfo } from '@shared/ipc' import type { SystemThemeInfo } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { useSettings } from './settings' import { useSettings } from './settings'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
const prefersDark = const prefersDark =
typeof window !== 'undefined' && window.matchMedia typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)') ? window.matchMedia('(prefers-color-scheme: dark)')
+1 -3
View File
@@ -1,10 +1,8 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { CommandTemplate } from '@shared/ipc' import type { CommandTemplate } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError' import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Sample templates so the Settings tab has content during UI design. // Sample templates so the Settings tab has content during UI design.
const seed: CommandTemplate[] = PREVIEW const seed: CommandTemplate[] = PREVIEW
? [ ? [
+61
View File
@@ -568,6 +568,60 @@ export interface Settings {
updateToken: string updateToken: string
} }
/**
* The canonical default Settings the single source of truth shared by the main
* electron-store defaults (main/settings.ts), the renderer's pre-load FALLBACK
* (store/settings.ts), and the browser-preview mock (mockApi.ts), so the full
* Settings object is no longer hand-maintained in three places (C1). Preview-only
* tweaks (placeholder folders, skip onboarding, demo channel) are layered on top
* of this at each call site.
*/
export const DEFAULT_SETTINGS: Settings = {
// Both blank by default → downloads land in Documents\Video / Documents\Audio
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
videoDir: '',
audioDir: '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
// Follow the OS theme on first launch (SR1) so a dark-mode Windows user isn't
// greeted by a bright white window despite full system-theme support.
theme: 'system',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
downloadArchive: false,
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
// configured channel so a stale binary can't silently cause YouTube 403s.
autoUpdateYtdlp: true,
// Default to stable so a nightly regression doesn't break downloads for
// everyone out of the box (SR2). Users who want the latest YouTube fixes
// can switch to nightly in Settings → Software.
ytdlpChannel: 'stable',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
// Default off so adding a watched channel doesn't silently fill the user's
// disk on first use (SR3). Enable explicitly once they know what it does.
autoDownloadNew: false,
hasCompletedOnboarding: false,
minimizeToTray: false,
launchAtStartup: false,
sidebarCollapsed: false,
updateToken: ''
}
/** /**
* A named, reusable set of extra yt-dlp CLI flags (Phase C "custom commands"), * A named, reusable set of extra yt-dlp CLI flags (Phase C "custom commands"),
* e.g. name: 'Write thumbnail + no mtime', args: '--write-thumbnail --no-mtime'. * e.g. name: 'Write thumbnail + no mtime', args: '--write-thumbnail --no-mtime'.
@@ -633,6 +687,13 @@ export interface HistoryEntry {
completedAt: number completedAt: number
} }
/**
* Cap on persisted history rows. Single-sourced here so the main store (history.ts)
* and the renderer's optimistic in-memory list (store/history.ts, L141) share one
* value instead of two hand-synced copies.
*/
export const HISTORY_MAX_ENTRIES = 500
/** /**
* A failed download or download-start attempt, persisted to errorlog.json * A failed download or download-start attempt, persisted to errorlog.json
* (newest-first) so the report survives the queue item being cleared * (newest-first) so the report survives the queue item being cleared
+19 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, afterEach } from 'vitest' import { describe, it, expect, afterEach, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync, readdirSync } from 'fs' import { mkdtempSync, rmSync, writeFileSync, readdirSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { join } from 'path' import { join } from 'path'
@@ -49,6 +49,24 @@ describe('jsonStore atomic write + safe read', () => {
expect(readJsonArraySafe(file, isRow)).toEqual([{ id: 'a' }, { id: 'c' }]) expect(readJsonArraySafe(file, isRow)).toEqual([{ id: 'a' }, { id: 'c' }])
}) })
it('reports success/failure instead of swallowing a bad write (R6)', () => {
const d = tmp()
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
// A normal write lands and reports true, with nothing logged.
expect(writeJsonAtomic(join(d, 'data.json'), [{ id: 'a' }])).toBe(true)
expect(err).not.toHaveBeenCalled()
// A target under a non-existent directory can't be written — it must report
// false (so a caller can warn/retry) and log, not throw or silently drop it.
expect(writeJsonAtomic(join(d, 'no', 'such', 'dir', 'data.json'), [{ id: 'a' }])).toBe(false)
expect(err).toHaveBeenCalledOnce()
} finally {
err.mockRestore()
}
// No stray .tmp left behind by the failed write.
expect(readdirSync(d)).toEqual(['data.json'])
})
it('backs up a corrupt file instead of silently wiping it (R2)', () => { it('backs up a corrupt file instead of silently wiping it (R2)', () => {
const d = tmp() const d = tmp()
const file = join(d, 'data.json') const file = join(d, 'data.json')