519e522917
Diagnostics were invisible in a packaged build: main catches did a bare console.error (DevTools suppressed in prod, M31) and the renderer's logError (M29) wrote to an unreachable console. Add a small leveled logger (src/main/logger.ts) appending to <userData>/logs/aerofetch.log with size-capped rotation; all app/fs access is lazy+guarded so it's a safe no-op under tests. - Route the 5 main-process console.* catch sites (jsonStore write-fail, settings write-fail + plaintext-secret warn, cookie loadURL, yt-dlp auto-update) through the logger. - Add a fire-and-forget log:write IPC channel + preload logError() so the renderer's logError forwards failures to the main file sink (closes the M29 "sink" half the code comments deferred to CC8). mockApi + Api type updated. - Unit-test pure formatLine/composeMessage + no-op safety; update the R6 jsonStore test to assert against the logger. The user-facing toast half of CC8 ties to the global status surface (UI25/UX9). typecheck + 258 tests + lint + prettier green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
5.9 KiB
TypeScript
152 lines
5.9 KiB
TypeScript
import { app } from 'electron'
|
|
import { join } from 'path'
|
|
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, unlinkSync } from 'fs'
|
|
import { logger } from './logger'
|
|
|
|
/**
|
|
* One shared persistence layer for the hand-rolled JSON array stores (history,
|
|
* error log, templates, sources, media-items). Replaces the per-module
|
|
* read/parse/write copies (SIMP1) and fixes three data-safety blockers:
|
|
*
|
|
* - R1 -- atomic writes: write a temp file then `rename` over the target, so a
|
|
* crash / power-cut mid-write can never truncate the real file. (`electron-store`,
|
|
* used for settings, is already atomic; these stores were not.)
|
|
* - R2 -- corruption is no longer silent data loss: on a parse error the bad file
|
|
* is copied aside (`<file>.corrupt-<ts>`) before we fall back to empty, so a
|
|
* single bad byte can't wipe the user's history/sources from their perspective.
|
|
* - R3 -- an in-memory cache + debounced batched writes: the previous code
|
|
* re-read + re-parsed the entire (multi-MB) file and rewrote it synchronously
|
|
* on *every* download completion (≈O(n²) over a large channel, blocking the
|
|
* main thread). Reads now hit the cache; writes coalesce into one atomic flush.
|
|
*/
|
|
|
|
// Debounce window for batched writes. Long enough to coalesce a burst (e.g. many
|
|
// items enqueued/marked at once), short enough that data lands on disk promptly.
|
|
const FLUSH_DELAY_MS = 400
|
|
|
|
/**
|
|
* Read + validate a JSON array from `path`. Invalid rows are dropped. On a parse
|
|
* error the file is backed up to `<path>.corrupt-<timestamp>` before returning []
|
|
* so corruption is recoverable rather than a silent wipe (R2). A missing file is
|
|
* simply [] (first run), with no backup.
|
|
*/
|
|
export function readJsonArraySafe<T>(path: string, isValid: (o: unknown) => o is T): T[] {
|
|
let raw: string
|
|
try {
|
|
if (!existsSync(path)) return []
|
|
raw = readFileSync(path, 'utf8')
|
|
} catch {
|
|
return []
|
|
}
|
|
try {
|
|
const data = JSON.parse(raw)
|
|
return Array.isArray(data) ? data.filter(isValid) : []
|
|
} catch {
|
|
try {
|
|
copyFileSync(path, `${path}.corrupt-${Date.now()}`)
|
|
} catch {
|
|
/* best-effort -- if we can't back it up, still don't crash the read */
|
|
}
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Atomically write `value` as JSON to `path`: serialise to `<path>.tmp`, then
|
|
* `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). 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): boolean {
|
|
const tmp = `${path}.tmp`
|
|
try {
|
|
writeFileSync(tmp, pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value))
|
|
renameSync(tmp, path)
|
|
return true
|
|
} catch (e) {
|
|
// R6: previously a silent catch — a failed persist (disk full, read-only
|
|
// profile) dropped the change with no trace. It now lands in the file-backed
|
|
// diagnostic log (CC8); the user-facing toast ties to the global status surface (UI25/UX9).
|
|
logger.error(`failed to write ${path}`, e)
|
|
try {
|
|
if (existsSync(tmp)) unlinkSync(tmp)
|
|
} catch {
|
|
/* best-effort temp cleanup */
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
export interface JsonStore<T> {
|
|
/** All rows, validated; cached after the first read. */
|
|
read(): T[]
|
|
/** Replace all rows (capped), update the cache, and schedule an atomic flush. Returns the stored rows. */
|
|
write(items: T[]): T[]
|
|
/** Force any pending debounced write to disk synchronously (e.g. on quit). */
|
|
flush(): void
|
|
}
|
|
|
|
// Every store registers itself so app-quit can flush pending writes in one call.
|
|
const registry: JsonStore<unknown>[] = []
|
|
|
|
/**
|
|
* Create a cached, atomic JSON array store backed by `<userData>/<filename>`.
|
|
* `cap` bounds the row count (pass Infinity for none). The path is resolved
|
|
* lazily so importing this module never touches `app` before it's ready.
|
|
*/
|
|
export function createJsonStore<T>(
|
|
filename: string,
|
|
isValid: (o: unknown) => o is T,
|
|
cap: number,
|
|
// Pretty-print by default (small, hand-inspectable files); pass false for a
|
|
// large machine-only store to keep writes compact (R8).
|
|
pretty = true
|
|
): JsonStore<T> {
|
|
let cache: T[] | null = null
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
let dirty = false
|
|
|
|
const filePath = (): string => join(app.getPath('userData'), filename)
|
|
|
|
function read(): T[] {
|
|
if (cache === null) cache = readJsonArraySafe(filePath(), isValid)
|
|
return cache
|
|
}
|
|
|
|
function flush(): void {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
if (!dirty) return
|
|
// 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[] {
|
|
// Avoid an extra copy in the common under-cap case so a per-completion write
|
|
// stays O(1) rather than re-slicing the whole list each time (R3).
|
|
cache = items.length > cap ? items.slice(0, cap) : items
|
|
dirty = true
|
|
if (timer) clearTimeout(timer)
|
|
timer = setTimeout(flush, FLUSH_DELAY_MS)
|
|
return cache
|
|
}
|
|
|
|
const store: JsonStore<T> = { read, write, flush }
|
|
registry.push(store as JsonStore<unknown>)
|
|
return store
|
|
}
|
|
|
|
/** Synchronously flush every store's pending write -- call on app quit. */
|
|
export function flushAllStores(): void {
|
|
for (const s of registry) s.flush()
|
|
}
|