/** * 统一日志基座(主进程): * - 结构化行:` [] k=v ...`,文件按天滚动 * (userData/logs/main-YYYY-MM-DD.log,保留 14 天) * - 级别:dev 默认 debug、打包默认 info;ASO_LOG_LEVEL=debug|info|warn|error 可覆盖 * - 子 logger:getLogger(scope) —— 各模块用固定 scope(plugin-host / agent-service / ipc / renderer / * browser),插件日志经 plugin-host 注入 scope=plugin 且带 pluginId 字段 * - 全局兜底:uncaughtException / unhandledRejection / will-quit 落盘 * * Agent 自诊断约定:日志文件路径固定,pi 侧可直接读文件排查(见 docs/plugins/06-self-diagnosis.md)。 */ import { app } from 'electron' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs' import { join } from 'node:path' export type LogLevel = 'debug' | 'info' | 'warn' | 'error' const LEVEL_ORDER: Record = { debug: 10, info: 20, warn: 30, error: 40 } const RETAIN_DAYS = 14 const MAX_LINE = 8 * 1024 let level: LogLevel = 'info' let logDir: string | null = null /** 当天文件名(含日期)缓存;跨天自动切换 */ let fileDay = '' let fileReady = false /** 文件不可用时保留最近日志(进程内仍可读) */ const memoryTail: string[] = [] function resolveLevel(): LogLevel { const env = process.env.ASO_LOG_LEVEL if (env === 'debug' || env === 'info' || env === 'warn' || env === 'error') return env return app.isPackaged ? 'info' : 'debug' } function ensureFile(now: Date): string | null { const day = now.toISOString().slice(0, 10) if (fileReady && fileDay === day && logDir) return join(logDir, `main-${day}.log`) try { if (!logDir) logDir = join(app.getPath('userData'), 'logs') if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) pruneOldLogs(logDir, day) fileDay = day fileReady = true return join(logDir, `main-${day}.log`) } catch { return null } } function pruneOldLogs(dir: string, today: string): void { try { for (const name of readdirSync(dir)) { const m = /^main-(\d{4}-\d{2}-\d{2})\.log$/.exec(name) if (!m) continue const ageDays = (Date.now() - statSync(join(dir, name)).mtimeMs) / 86_400_000 if (m[1] !== today && (ageDays > RETAIN_DAYS || m[1] > today)) unlinkSync(join(dir, name)) } } catch { // 清理失败不影响写日志 } } function fmtValue(v: unknown): string { if (v === undefined) return 'undefined' if (v === null) return 'null' if (v instanceof Error) return v.stack ? v.stack.replace(/\s+/g, ' ').slice(0, 600) : v.message if (typeof v === 'object') { try { return JSON.stringify(v).slice(0, 400) } catch { return '[object]' } } return String(v).slice(0, 400) } function write(level_: LogLevel, scope: string, message: string, kv?: Record): void { if (LEVEL_ORDER[level_] < LEVEL_ORDER[level]) return const now = new Date() const parts = [ now.toISOString(), level_.toUpperCase().padEnd(5), `[${scope}]`, message.slice(0, 500) ] if (kv) { for (const [k, v] of Object.entries(kv)) parts.push(`${k}=${fmtValue(v)}`) } const line = parts.join(' ').slice(0, MAX_LINE) // 控制台镜像(dev 终端可读) // eslint-disable-next-line no-console ;(level_ === 'error' ? console.error : level_ === 'warn' ? console.warn : console.log)(line) memoryTail.push(line) if (memoryTail.length > 500) memoryTail.shift() const file = ensureFile(now) if (file) { try { appendFileSync(file, line + '\n', 'utf8') } catch { fileReady = false } } } export interface Logger { debug(message: string, kv?: Record): void info(message: string, kv?: Record): void warn(message: string, kv?: Record): void error(message: string, kv?: Record): void } export function getLogger(scope: string): Logger { return { debug: (message, kv) => write('debug', scope, message, kv), info: (message, kv) => write('info', scope, message, kv), warn: (message, kv) => write('warn', scope, message, kv), error: (message, kv) => write('error', scope, message, kv) } } /** 最近日志(内存尾部;诊断 IPC 预留) */ export function recentLines(count = 100): string[] { return memoryTail.slice(-count) } export function initLogger(): void { level = resolveLevel() const log = getLogger('logger') process.on('uncaughtException', (err) => { log.error('uncaughtException', { err }) }) process.on('unhandledRejection', (reason) => { log.error('unhandledRejection', { err: reason }) }) app.on('will-quit', () => { log.info('app quit') }) log.info('logger init', { level, userData: app.getPath('userData') }) }