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>
107 lines
4.2 KiB
TypeScript
107 lines
4.2 KiB
TypeScript
import { app } from 'electron'
|
|
import { join } from 'path'
|
|
import { appendFileSync, mkdirSync, renameSync, statSync } from 'fs'
|
|
import { LOG_MAX_BYTES } from './constants'
|
|
|
|
/**
|
|
* One small leveled, file-backed logger for the whole app (audit CC8). Before this,
|
|
* diagnostics were effectively invisible in a packaged build: main-process catches
|
|
* did a bare `console.error` (unreachable — the app menu/DevTools are suppressed in
|
|
* production, M31) and the renderer's `logError` (M29) wrote to a console nobody could
|
|
* open. Every catch now routes here and lands in `<userData>/logs/aerofetch.log`, so a
|
|
* field-reported failure is actually diagnosable.
|
|
*
|
|
* Scope: this is the diagnostic log. `errorlog.ts` remains separate — it's user-facing
|
|
* *download* failure data shown in Settings → Diagnostics, not app logging. The
|
|
* user-facing toast half of CC8 ties to the global status surface (UI25/UX9).
|
|
*
|
|
* All `app`/fs access is lazy and guarded so importing this module never touches
|
|
* `app` before it's ready — and so it's a safe no-op under unit tests, where
|
|
* `electron`'s `app` is undefined.
|
|
*/
|
|
|
|
export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
|
|
|
|
const LEVELS: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 }
|
|
|
|
// Write everything at or above this level to the file. `debug` is dev-only noise, so
|
|
// it never hits the file (but still prints to the dev console).
|
|
const FILE_THRESHOLD = LEVELS.info
|
|
|
|
/** Format one log line: `2026-07-01T08:30:00.000Z [ERROR] message`. Pure, for tests. */
|
|
export function formatLine(level: LogLevel, message: string, now: Date = new Date()): string {
|
|
return `${now.toISOString()} [${level.toUpperCase()}] ${message}`
|
|
}
|
|
|
|
/** Combine an operation label with an optional error/detail into one message. Pure. */
|
|
export function composeMessage(label: string, detail?: unknown): string {
|
|
if (detail === undefined) return label
|
|
const text = detail instanceof Error ? detail.message : String(detail)
|
|
return `${label}: ${text}`
|
|
}
|
|
|
|
// `undefined` = not yet resolved, `null` = resolution failed (no app/userData, e.g.
|
|
// under unit tests) so file logging is disabled; a string is the resolved path.
|
|
let cachedFile: string | null | undefined
|
|
function logFile(): string | null {
|
|
if (cachedFile !== undefined) return cachedFile
|
|
try {
|
|
const dir = join(app.getPath('userData'), 'logs')
|
|
mkdirSync(dir, { recursive: true })
|
|
cachedFile = join(dir, 'aerofetch.log')
|
|
} catch {
|
|
// No app / userData available (unit test, or app not ready) — disable the file sink.
|
|
cachedFile = null
|
|
}
|
|
return cachedFile
|
|
}
|
|
|
|
/** Dev mode, resolved lazily and guarded (app is undefined in the test env). */
|
|
function isDev(): boolean {
|
|
try {
|
|
return !app.isPackaged
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Rotate to `<file>.1` (single generation) once the live file passes the cap, so the
|
|
// log can't grow without bound. Best-effort: any fs error here is swallowed — logging
|
|
// must never throw into a catch handler.
|
|
function rotateIfNeeded(file: string): void {
|
|
try {
|
|
if (statSync(file).size > LOG_MAX_BYTES) renameSync(file, `${file}.1`)
|
|
} catch {
|
|
/* file missing (first write) or rename race — ignore */
|
|
}
|
|
}
|
|
|
|
function writeToFile(line: string): void {
|
|
const file = logFile()
|
|
if (!file) return
|
|
rotateIfNeeded(file)
|
|
try {
|
|
appendFileSync(file, line + '\n')
|
|
} catch {
|
|
/* disk full / read-only profile — nothing more we can safely do */
|
|
}
|
|
}
|
|
|
|
function emit(level: LogLevel, label: string, detail?: unknown): void {
|
|
const message = composeMessage(label, detail)
|
|
// Console in dev keeps the familiar `--inspect` workflow; the file is the sink that
|
|
// survives into a packaged build.
|
|
if (isDev()) {
|
|
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
|
fn(`[AeroFetch] ${message}`)
|
|
}
|
|
if (LEVELS[level] <= FILE_THRESHOLD) writeToFile(formatLine(level, message))
|
|
}
|
|
|
|
export const logger = {
|
|
error: (label: string, detail?: unknown): void => emit('error', label, detail),
|
|
warn: (label: string, detail?: unknown): void => emit('warn', label, detail),
|
|
info: (label: string, detail?: unknown): void => emit('info', label, detail),
|
|
debug: (label: string, detail?: unknown): void => emit('debug', label, detail)
|
|
}
|