Harden audit findings: correctness, type-safety, Windows conventions & polish
Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified — typecheck + 234 tests + eslint + prettier green). Correctness / bugs: - B3: match the release checksum to the asset's filename line (no wrong-hash verify) - B4: newline-safe metadata probe (one --print with a unit-separator delimiter) - B5 / L88: guard the meta event against canceled items; progress no longer promotes a queued item outside pump() - B7: cookie-login promise always resolves (handles destroy-without-close) - L146: trim parser rejects >2 colon-group times; M36: Library selection counts only actionable rows - L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message, schedule picker min, dead-code/comment cleanup Type safety: - Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed) Resilience / Windows / metadata: - R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth) - W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window; L147 dead macOS branches removed - CL1: shared stdout markers; package/builder metadata (license, homepage, repository, copyright, tsbuildinfo glob) Copy / docs / tests: - M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled - New unit tests for L35 (isValidMediaItem) and L36 (compareVersions) This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard work it builds on: background running + auto-download, library clipboard detection, tray, binary management & library scale, credential encryption at rest, the shared jsonStore and ui/ primitives, and the eslint/prettier tooling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync } from 'fs'
|
||||
|
||||
/**
|
||||
* 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 pretty 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). Best-effort: a read-only data dir just means nothing is persisted.
|
||||
*/
|
||||
export function writeJsonAtomic(path: string, value: unknown): void {
|
||||
const tmp = `${path}.tmp`
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify(value, null, 2))
|
||||
renameSync(tmp, path)
|
||||
} catch {
|
||||
/* best-effort; e.g. a read-only data dir */
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
): 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
|
||||
dirty = false
|
||||
writeJsonAtomic(filePath(), cache ?? [])
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user