diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index adf1ce9..01c7d4a 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -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 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 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, @@ -176,15 +194,30 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n ## 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 `Settings` object is hand-maintained in 3 places (`main/settings.ts` `DEFAULTS`, `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 - `isPreview`. -- [ ] **C2 — Break the `downloads ↔ sources` store circular dependency.** `downloads.ts` + `isPreview`. *Fixed: `DEFAULT_SETTINGS` is now the canonical default in + [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()`. - 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 @@ -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 `yt-dlp.exe` on launch while a deep-link/queued download spawns the same binary — a Windows 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. -- [ ] **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). + *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 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). @@ -668,8 +710,12 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n rather than being rejected up front. - [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. -- [ ] **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. + *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:* @@ -1344,12 +1390,22 @@ cosmetics. `R` IDs. - [x] **R5 — Settings write failure is unhandled.** `setSettings` calls `store.set(...)` with no try/catch; on disk-full/read-only `electron-store` throws → the IPC rejects → the renderer's `setSettings().catch(() => {})` swallows it while the optimistic UI keeps the change (the disk-full form of M34). **Fix:** catch and report. -- [ ] **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, - M29). **Fix:** detect write failure and warn. -- [ ] **R7 — `safeStorage` unavailable ⇒ secrets silently stored as plaintext.** `encryptSecret` falls back + M29). **Fix:** detect write failure and warn. *Fixed (log half) in [jsonStore.ts](src/main/jsonStore.ts): + `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 - 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 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 diff --git a/src/main/download.ts b/src/main/download.ts index d1c6ec3..44662ac 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -47,6 +47,21 @@ interface ActiveDownload { const active = new Map() +// 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 // 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 @@ -269,7 +284,9 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star // settles, so the plaintext never lingers at rest. let cookiesFile: string | undefined 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 } function cleanupCookies(): void { @@ -337,11 +354,14 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star function bumpWatchdog(): void { clearWatchdog() 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 clearWatchdog() cleanupCookies() - active.delete(opts.id) + releaseActive(opts.id, rec) killTree(rec) 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 }) @@ -385,7 +405,7 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star settled = true clearWatchdog() cleanupCookies() - active.delete(opts.id) + releaseActive(opts.id, rec) // A paused download was killed on purpose — stay silent, like a cancel. if (!rec.canceled && !rec.paused) { send(wc, { type: 'error', id: opts.id, error: err.message }) @@ -399,7 +419,7 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star settled = true clearWatchdog() cleanupCookies() - active.delete(opts.id) + releaseActive(opts.id, rec) // Canceled: renderer already showed 'canceled'. Paused: renderer showed // 'paused' and keeps the .part for a later resume. Either way, no event. if (rec.canceled || rec.paused) return @@ -438,6 +458,11 @@ export function cancelDownload(id: string): void { const rec = active.get(id) if (!rec) return 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) } @@ -452,5 +477,9 @@ export function pauseDownload(id: string): void { const rec = active.get(id) if (!rec) return 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) } diff --git a/src/main/history.ts b/src/main/history.ts index b9fe1ea..954f9da 100644 --- a/src/main/history.ts +++ b/src/main/history.ts @@ -1,16 +1,16 @@ -import type { HistoryEntry } from '@shared/ipc' +import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc' import { isValidHistoryEntry } from './validation' import { createJsonStore } from './jsonStore' // 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 // writes, corruption backup, and caching come from the shared jsonStore (R1–R3). -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 // history.json can't feed the UI (or openPath) entries with the wrong shape — // 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[] { return store.read() diff --git a/src/main/jsonStore.ts b/src/main/jsonStore.ts index 4fda7a8..a1853a8 100644 --- a/src/main/jsonStore.ts +++ b/src/main/jsonStore.ts @@ -1,6 +1,6 @@ import { app } from 'electron' 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, @@ -55,16 +55,29 @@ export function readJsonArraySafe(path: string, isValid: (o: unknown) => o is * `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 * 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 - * read-only data dir just means nothing is persisted. + * machine-only stores to avoid inflating size/write time (R8). Returns whether the + * 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` try { writeFileSync(tmp, pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)) renameSync(tmp, path) - } catch { - /* best-effort; e.g. a read-only data dir */ + 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 { + /* best-effort temp cleanup */ + } + return false } } @@ -110,8 +123,10 @@ export function createJsonStore( timer = null } if (!dirty) return - dirty = false - writeJsonAtomic(filePath(), cache ?? [], pretty) + // Only clear the dirty flag once the write actually lands. A failed flush + // (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[] { diff --git a/src/main/settings.ts b/src/main/settings.ts index 4522207..13622ab 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -13,6 +13,7 @@ import { VIDEO_QUALITY_OPTIONS, AUDIO_QUALITY_OPTIONS, DEFAULT_DOWNLOAD_OPTIONS, + DEFAULT_SETTINGS, isYtdlpUpdateChannel, type Settings, type DownloadOptions, @@ -22,51 +23,11 @@ import { type VideoCodecPref } from '@shared/ipc' -const DEFAULTS: 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: '' -} +// The electron-store defaults are the canonical DEFAULT_SETTINGS from the shared +// contract — single-sourced so the renderer FALLBACK and the preview mock can't +// drift from main (C1). The rationale for individual defaults (SR1/SR2/SR3) lives +// alongside DEFAULT_SETTINGS in shared/ipc.ts. +const DEFAULTS: Settings = DEFAULT_SETTINGS /** * Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at @@ -197,6 +158,11 @@ function encryptSecret(plain: string): string { } catch { /* 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 } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ecc44b1..a25d778 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -12,6 +12,12 @@ import { LiveRegion } from './components/LiveRegion' import { getTheme, pageBackground } from './theme' import { useSettings } from './store/settings' 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 { useResolvedDark } from './store/systemTheme' import { logError } from './reportError' diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index d96776c..5ef8645 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -28,6 +28,7 @@ import { DismissRegular } from '@fluentui/react-icons' import type { MediaItem, Source, MediaKind } from '@shared/ipc' +import { isPreview as PREVIEW } from '../isPreview' import { useSources } from '../store/sources' import { useSettings } from '../store/settings' import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink' @@ -43,9 +44,6 @@ import { SegmentedControl } from './ui/SegmentedControl' import { useFocusStyles } from './ui/focusRing' 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. */ type ItemStatus = DownloadStatus | 'pending' diff --git a/src/renderer/src/isPreview.ts b/src/renderer/src/isPreview.ts new file mode 100644 index 0000000..9d1a723 --- /dev/null +++ b/src/renderer/src/isPreview.ts @@ -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 diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 3bd74d3..1f18d6a 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,279 +1,16 @@ import './assets/base.css' import React from 'react' import ReactDOM from 'react-dom/client' -import { DEFAULT_DOWNLOAD_OPTIONS, type Settings, type CommandTemplate } from '@shared/ipc' import App from './App' import { ErrorBoundary } from './components/ErrorBoundary' +import { mockApi } from './mockApi' // 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 -// design work. The store detects preview mode (no window.electron) and drives a -// fake progress ticker instead of calling these, so most are inert no-ops. -// In the real Electron app this branch is skipped — preload provides window.api. +// injected. Install the mock surface (mockApi.ts) so the UI renders and stays +// interactive during design work. In the real Electron app this branch is +// skipped — preload provides the real window.api. if (import.meta.env.DEV && !window.api) { - const MOCK_SETTINGS: Settings = { - 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: - '### What’s 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 - } + window.api = mockApi } ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/src/renderer/src/mockApi.ts b/src/renderer/src/mockApi.ts new file mode 100644 index 0000000..4440c67 --- /dev/null +++ b/src/renderer/src/mockApi.ts @@ -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: + '### What’s 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 +} diff --git a/src/renderer/src/store/coordinator.ts b/src/renderer/src/store/coordinator.ts new file mode 100644 index 0000000..95e78f2 --- /dev/null +++ b/src/renderer/src/store/coordinator.ts @@ -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 = (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 void>>() + +/** Subscribe to a store event. Returns an unsubscribe function. */ +export function on(event: K, handler: Handler): () => 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(event: K, payload: StoreEvents[K]): void { + const set = handlers.get(event) + if (!set) return + for (const handler of set) (handler as Handler)(payload) +} diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index b3cc604..6da1ce9 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -6,9 +6,10 @@ import { type CollectionContext, type MediaKind } from '@shared/ipc' +import { isPreview as PREVIEW } from '../isPreview' import { useSettings } from './settings' import { useHistory } from './history' -import { useSources } from './sources' +import { emit, on } from './coordinator' import { newId } from '../id' // Single-sourced from the IPC contract (L105) and re-exported so existing @@ -99,10 +100,6 @@ export interface AddEntry { 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 { items: DownloadItem[] addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void @@ -274,7 +271,9 @@ function recordCompletion(item: DownloadItem): void { }) } 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((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. // 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. diff --git a/src/renderer/src/store/errorlog.ts b/src/renderer/src/store/errorlog.ts index dda7b77..9e53799 100644 --- a/src/renderer/src/store/errorlog.ts +++ b/src/renderer/src/store/errorlog.ts @@ -1,10 +1,8 @@ import { create } from 'zustand' import type { ErrorLogEntry } from '@shared/ipc' +import { isPreview as PREVIEW } from '../isPreview' 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. const seed: ErrorLogEntry[] = PREVIEW ? [ diff --git a/src/renderer/src/store/history.ts b/src/renderer/src/store/history.ts index a123424..c37f4cd 100644 --- a/src/renderer/src/store/history.ts +++ b/src/renderer/src/store/history.ts @@ -1,10 +1,8 @@ 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' -// 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. 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 { entries: HistoryEntry[] add: (entry: HistoryEntry) => void @@ -58,7 +60,12 @@ export const useHistory = create((set, get) => ({ entries: seed, 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')) }, diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index f24e4d8..6455e7f 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -1,48 +1,22 @@ 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' -// True in the standalone browser preview (no Electron preload). -const PREVIEW = typeof window === 'undefined' || !window.electron - -const FALLBACK: Settings = { - videoDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Video' : '', - audioDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Audio' : '', - defaultKind: 'video', - defaultVideoQuality: 'Best available', - defaultAudioQuality: 'Best', - maxConcurrent: 2, - filenameTemplate: '%(title)s.%(ext)s', - // Mirror main's DEFAULTS (SR1): follow the OS theme until real settings load, - // so there's no white-on-first-paint flash for a dark-mode user. - 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: '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: '' -} +// The pre-load fallback is the canonical DEFAULT_SETTINGS (single-sourced from the +// 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 +// browser preview a couple of fields differ: placeholder folder paths, and +// onboarding pre-dismissed so design work isn't blocked behind the welcome screen +// (a real first launch keeps DEFAULT_SETTINGS' false from main). +const FALLBACK: Settings = PREVIEW + ? { + ...DEFAULT_SETTINGS, + videoDir: 'C:\\Users\\you\\Documents\\Video', + audioDir: 'C:\\Users\\you\\Documents\\Audio', + hasCompletedOnboarding: true + } + : DEFAULT_SETTINGS interface SettingsState extends Settings { /** true once persisted settings have loaded (always true in preview) */ diff --git a/src/renderer/src/store/sources.ts b/src/renderer/src/store/sources.ts index 76d58c0..4c27c3b 100644 --- a/src/renderer/src/store/sources.ts +++ b/src/renderer/src/store/sources.ts @@ -1,12 +1,10 @@ import { create } from 'zustand' import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc' -import { useDownloads } from './downloads' +import { isPreview as PREVIEW } from '../isPreview' import { useSettings } from './settings' +import { emit, on } from './coordinator' import { logError } from '../reportError' -// True in the standalone browser preview (no Electron preload). -const PREVIEW = typeof window === 'undefined' || !window.electron - // --- Preview seed data ------------------------------------------------------ function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] { @@ -185,8 +183,11 @@ export const useSources = create((set, get) => ({ // Per-batch override (Library video/audio toggle) falls back to the global default (M27). const kind = kindOverride ?? settings.defaultKind const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality - // One batched state update + pump, so queuing a whole channel stays O(n). - useDownloads.getState().addMany( + // Hand the batch to the downloads store via the event bus (C2) rather than a + // direct import. addMany applies it as one batched state update + pump, so + // queuing a whole channel stays O(n). + emit( + 'enqueueDownloads', items.map((it) => ({ url: it.url, kind, @@ -268,6 +269,12 @@ export const useSources = create((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. if (!PREVIEW) { window.api diff --git a/src/renderer/src/store/systemTheme.ts b/src/renderer/src/store/systemTheme.ts index 12095c9..cbe2fd8 100644 --- a/src/renderer/src/store/systemTheme.ts +++ b/src/renderer/src/store/systemTheme.ts @@ -1,10 +1,8 @@ import { create } from 'zustand' import type { SystemThemeInfo } from '@shared/ipc' +import { isPreview as PREVIEW } from '../isPreview' import { useSettings } from './settings' -// True in the standalone browser preview (no Electron preload). -const PREVIEW = typeof window === 'undefined' || !window.electron - const prefersDark = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') diff --git a/src/renderer/src/store/templates.ts b/src/renderer/src/store/templates.ts index 79175b0..f94b018 100644 --- a/src/renderer/src/store/templates.ts +++ b/src/renderer/src/store/templates.ts @@ -1,10 +1,8 @@ import { create } from 'zustand' import type { CommandTemplate } from '@shared/ipc' +import { isPreview as PREVIEW } from '../isPreview' 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. const seed: CommandTemplate[] = PREVIEW ? [ diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 1fbc978..ce1fd55 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -568,6 +568,60 @@ export interface Settings { 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"), * e.g. name: 'Write thumbnail + no mtime', args: '--write-thumbnail --no-mtime'. @@ -633,6 +687,13 @@ export interface HistoryEntry { 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 * (newest-first) so the report survives the queue item being cleared — diff --git a/test/jsonStore.test.ts b/test/jsonStore.test.ts index 2ac6ad1..7b4b1e0 100644 --- a/test/jsonStore.test.ts +++ b/test/jsonStore.test.ts @@ -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 { tmpdir } from 'os' import { join } from 'path' @@ -49,6 +49,24 @@ describe('jsonStore atomic write + safe read', () => { 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)', () => { const d = tmp() const file = join(d, 'data.json')