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
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
+34 -5
View File
@@ -47,6 +47,21 @@ interface 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
// 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)
}
+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 { 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 (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
// 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()
+23 -8
View File
@@ -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<T>(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<T>(
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[] {
+11 -45
View File
@@ -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
}
+6
View File
@@ -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'
+1 -3
View File
@@ -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'
+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 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:
'### 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
}
window.api = mockApi
}
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 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<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.
// 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.
+1 -3
View File
@@ -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
? [
+12 -5
View File
@@ -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<HistoryState>((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'))
},
+16 -42
View File
@@ -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) */
+13 -6
View File
@@ -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<SourcesState>((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<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.
if (!PREVIEW) {
window.api
+1 -3
View File
@@ -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)')
+1 -3
View File
@@ -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
? [
+61
View File
@@ -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
+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 { 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')