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:
+67
-11
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
+23
-8
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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
|
||||
? [
|
||||
|
||||
@@ -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'))
|
||||
},
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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,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,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
|
||||
? [
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user