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 `/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 = { 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 `.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) }