368 lines
10 KiB
JavaScript
368 lines
10 KiB
JavaScript
|
|
/**
|
||
|
|
* Crash Log Bridge - Captures main-process errors and writes them to local log files.
|
||
|
|
*
|
||
|
|
* Log files are stored as JSONL (one JSON object per line) under
|
||
|
|
* {userData}/crash-logs/crash-YYYY-MM-DD.log so that appending is cheap and
|
||
|
|
* atomic. Files older than 30 days are pruned on startup.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const fs = require("node:fs");
|
||
|
|
const path = require("node:path");
|
||
|
|
const os = require("node:os");
|
||
|
|
const {
|
||
|
|
appendTerminalPerfLogLine,
|
||
|
|
configureTerminalPerformanceDiagnostics,
|
||
|
|
} = require("./terminalPerformanceDiagnostics.cjs");
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// State
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
let logDir = null;
|
||
|
|
let electronApp = null;
|
||
|
|
let electronShell = null;
|
||
|
|
let sessionsMap = null;
|
||
|
|
|
||
|
|
const LOG_RETENTION_DAYS = 30;
|
||
|
|
const TERMINAL_PERF_DEBUG_ENV_KEYS = [
|
||
|
|
"NETCATTY_TERMINAL_PERF_DEBUG",
|
||
|
|
"NETCATTY_TERMINAL_DEBUG",
|
||
|
|
];
|
||
|
|
const TERMINAL_PERF_LOG_PREFIX = "[Netcatty Terminal Perf]";
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
function ensureLogDir() {
|
||
|
|
if (logDir) return logDir;
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Try the stored app reference first, then fall back to requiring electron
|
||
|
|
// directly so crash logging works even before init() is called.
|
||
|
|
let userDataPath = null;
|
||
|
|
if (electronApp) {
|
||
|
|
userDataPath = electronApp.getPath("userData");
|
||
|
|
} else {
|
||
|
|
try {
|
||
|
|
const { app } = require("node:electron");
|
||
|
|
userDataPath = app?.getPath?.("userData") ?? null;
|
||
|
|
} catch {
|
||
|
|
try {
|
||
|
|
const { app } = require("electron");
|
||
|
|
userDataPath = app?.getPath?.("userData") ?? null;
|
||
|
|
} catch {
|
||
|
|
// Electron not available yet
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!userDataPath) return null;
|
||
|
|
|
||
|
|
logDir = path.join(userDataPath, "crash-logs");
|
||
|
|
if (!fs.existsSync(logDir)) {
|
||
|
|
fs.mkdirSync(logDir, { recursive: true });
|
||
|
|
}
|
||
|
|
return logDir;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function todayFileName() {
|
||
|
|
const d = new Date();
|
||
|
|
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
|
|
return `crash-${ymd}.log`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildEntry(source, err, extra) {
|
||
|
|
const error = err instanceof Error ? err : new Error(String(err ?? "unknown"));
|
||
|
|
|
||
|
|
let mem;
|
||
|
|
try {
|
||
|
|
const m = process.memoryUsage();
|
||
|
|
mem = {
|
||
|
|
rss: Math.round(m.rss / 1048576),
|
||
|
|
heapUsed: Math.round(m.heapUsed / 1048576),
|
||
|
|
heapTotal: Math.round(m.heapTotal / 1048576),
|
||
|
|
};
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract extra properties from the error object (code, errno, syscall, etc.)
|
||
|
|
const errorMeta = {};
|
||
|
|
for (const key of ["code", "errno", "syscall", "hostname", "port", "signal", "level"]) {
|
||
|
|
if (error[key] !== undefined) {
|
||
|
|
errorMeta[key] = error[key];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
source,
|
||
|
|
message: error.message || String(err),
|
||
|
|
stack: error.stack || undefined,
|
||
|
|
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
|
||
|
|
extra: extra || undefined,
|
||
|
|
pid: process.pid,
|
||
|
|
platform: process.platform,
|
||
|
|
arch: process.arch,
|
||
|
|
version: electronApp?.getVersion?.() ?? "unknown",
|
||
|
|
electronVersion: process.versions?.electron ?? "unknown",
|
||
|
|
osVersion: os.release(),
|
||
|
|
memoryMB: mem,
|
||
|
|
activeSessionCount: sessionsMap?.size ?? -1,
|
||
|
|
uptimeSeconds: Math.round(process.uptime()),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Public API
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Write a crash/error entry to today's log file (sync, safe for use in
|
||
|
|
* uncaughtException handlers).
|
||
|
|
*/
|
||
|
|
function captureError(source, err, extra) {
|
||
|
|
try {
|
||
|
|
const dir = ensureLogDir();
|
||
|
|
if (!dir) return;
|
||
|
|
|
||
|
|
const entry = buildEntry(source, err, extra);
|
||
|
|
const filePath = path.join(dir, todayFileName());
|
||
|
|
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
|
||
|
|
} catch {
|
||
|
|
// Never throw from the crash logger itself.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function captureDiagnostic(source, message, extra) {
|
||
|
|
captureError(source, new Error(String(message || "diagnostic")), extra);
|
||
|
|
}
|
||
|
|
|
||
|
|
function shouldMirrorTerminalPerfDiagnostics() {
|
||
|
|
return TERMINAL_PERF_DEBUG_ENV_KEYS.some((key) => process.env[key] === "1");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Delete log files older than LOG_RETENTION_DAYS.
|
||
|
|
*/
|
||
|
|
function pruneOldLogs() {
|
||
|
|
try {
|
||
|
|
const dir = ensureLogDir();
|
||
|
|
if (!dir) return;
|
||
|
|
|
||
|
|
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
||
|
|
const files = fs.readdirSync(dir);
|
||
|
|
|
||
|
|
for (const file of files) {
|
||
|
|
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
|
||
|
|
try {
|
||
|
|
const filePath = path.join(dir, file);
|
||
|
|
const stat = fs.statSync(filePath);
|
||
|
|
if (stat.mtimeMs < cutoff) {
|
||
|
|
fs.unlinkSync(filePath);
|
||
|
|
console.log(`[CrashLog] Pruned old log: ${file}`);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// skip
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// skip
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// IPC handlers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Count newlines in a file by streaming instead of reading entire content.
|
||
|
|
*/
|
||
|
|
async function countLines(filePath) {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
let count = 0;
|
||
|
|
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
|
||
|
|
stream.on("data", (chunk) => {
|
||
|
|
for (let i = 0; i < chunk.length; i++) {
|
||
|
|
if (chunk[i] === "\n") count++;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
stream.on("end", () => resolve(count));
|
||
|
|
stream.on("error", () => resolve(0));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function listLogs() {
|
||
|
|
const dir = ensureLogDir();
|
||
|
|
if (!dir) return [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
const files = await fs.promises.readdir(dir);
|
||
|
|
const results = [];
|
||
|
|
|
||
|
|
for (const file of files) {
|
||
|
|
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
|
||
|
|
try {
|
||
|
|
const filePath = path.join(dir, file);
|
||
|
|
const stat = await fs.promises.stat(filePath);
|
||
|
|
const entryCount = await countLines(filePath);
|
||
|
|
results.push({
|
||
|
|
fileName: file,
|
||
|
|
date: file.replace("crash-", "").replace(".log", ""),
|
||
|
|
size: stat.size,
|
||
|
|
entryCount,
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
// skip unreadable files
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sort newest first
|
||
|
|
results.sort((a, b) => b.date.localeCompare(a.date));
|
||
|
|
return results;
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const MAX_READ_ENTRIES = 500;
|
||
|
|
// Read up to ~256KB from the tail of the file to cap memory/CPU usage
|
||
|
|
const MAX_TAIL_BYTES = 256 * 1024;
|
||
|
|
|
||
|
|
async function readLog(fileName) {
|
||
|
|
const dir = ensureLogDir();
|
||
|
|
if (!dir) return [];
|
||
|
|
|
||
|
|
// Validate fileName to prevent path traversal
|
||
|
|
if (!/^crash-\d{4}-\d{2}-\d{2}\.log$/.test(fileName)) return [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
const filePath = path.join(dir, fileName);
|
||
|
|
const stat = await fs.promises.stat(filePath);
|
||
|
|
|
||
|
|
let content;
|
||
|
|
if (stat.size > MAX_TAIL_BYTES) {
|
||
|
|
// Only read the tail of the file
|
||
|
|
const buf = Buffer.alloc(MAX_TAIL_BYTES);
|
||
|
|
const fd = await fs.promises.open(filePath, "r");
|
||
|
|
try {
|
||
|
|
await fd.read(buf, 0, MAX_TAIL_BYTES, stat.size - MAX_TAIL_BYTES);
|
||
|
|
} finally {
|
||
|
|
await fd.close();
|
||
|
|
}
|
||
|
|
const raw = buf.toString("utf-8");
|
||
|
|
// Drop the first partial line
|
||
|
|
const firstNewline = raw.indexOf("\n");
|
||
|
|
content = firstNewline >= 0 ? raw.slice(firstNewline + 1) : raw;
|
||
|
|
} else {
|
||
|
|
content = await fs.promises.readFile(filePath, "utf-8");
|
||
|
|
}
|
||
|
|
|
||
|
|
const lines = content.split("\n").filter(Boolean);
|
||
|
|
// Only parse the last MAX_READ_ENTRIES lines
|
||
|
|
const tail = lines.slice(-MAX_READ_ENTRIES);
|
||
|
|
const entries = [];
|
||
|
|
for (const line of tail) {
|
||
|
|
try {
|
||
|
|
entries.push(JSON.parse(line));
|
||
|
|
} catch {
|
||
|
|
// skip malformed lines
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return entries;
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function clearLogs() {
|
||
|
|
const dir = ensureLogDir();
|
||
|
|
if (!dir) return { deletedCount: 0 };
|
||
|
|
|
||
|
|
let deletedCount = 0;
|
||
|
|
try {
|
||
|
|
const files = await fs.promises.readdir(dir);
|
||
|
|
for (const file of files) {
|
||
|
|
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
|
||
|
|
try {
|
||
|
|
await fs.promises.unlink(path.join(dir, file));
|
||
|
|
deletedCount++;
|
||
|
|
} catch {
|
||
|
|
// skip
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// skip
|
||
|
|
}
|
||
|
|
return { deletedCount };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function openDir() {
|
||
|
|
const dir = ensureLogDir();
|
||
|
|
if (!dir || !electronShell?.openPath) return { success: false };
|
||
|
|
try {
|
||
|
|
const errorMessage = await electronShell.openPath(dir);
|
||
|
|
// shell.openPath resolves to an error string on failure, empty string on success
|
||
|
|
return { success: !errorMessage };
|
||
|
|
} catch {
|
||
|
|
return { success: false };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Lifecycle
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
function init(deps) {
|
||
|
|
const { electronModule, sessions } = deps;
|
||
|
|
const { app, shell } = electronModule || {};
|
||
|
|
electronApp = app;
|
||
|
|
electronShell = shell;
|
||
|
|
sessionsMap = sessions || null;
|
||
|
|
|
||
|
|
ensureLogDir();
|
||
|
|
try {
|
||
|
|
const userDataPath = electronApp?.getPath?.("userData");
|
||
|
|
configureTerminalPerformanceDiagnostics({ userDataPath });
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
pruneOldLogs();
|
||
|
|
|
||
|
|
console.log(`[CrashLog] Crash log directory: ${logDir}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function registerHandlers(ipcMain) {
|
||
|
|
ipcMain.handle("netcatty:crashLogs:list", async () => listLogs());
|
||
|
|
ipcMain.handle("netcatty:crashLogs:read", async (_event, { fileName }) => readLog(fileName));
|
||
|
|
ipcMain.handle("netcatty:crashLogs:clear", async () => clearLogs());
|
||
|
|
ipcMain.handle("netcatty:crashLogs:openDir", async () => openDir());
|
||
|
|
ipcMain.handle("netcatty:diagnostics:log", async (_event, payload) => {
|
||
|
|
const source = typeof payload?.source === "string" && payload.source.trim()
|
||
|
|
? payload.source.trim()
|
||
|
|
: "renderer-diagnostic";
|
||
|
|
const message = typeof payload?.message === "string" && payload.message.trim()
|
||
|
|
? payload.message.trim()
|
||
|
|
: "diagnostic";
|
||
|
|
if (source === "terminal-perf" && message.startsWith(TERMINAL_PERF_LOG_PREFIX)) {
|
||
|
|
appendTerminalPerfLogLine(message);
|
||
|
|
if (shouldMirrorTerminalPerfDiagnostics()) {
|
||
|
|
console.info(message);
|
||
|
|
}
|
||
|
|
return { success: true };
|
||
|
|
}
|
||
|
|
captureDiagnostic(source, message, payload?.extra);
|
||
|
|
return { success: true };
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
init,
|
||
|
|
captureError,
|
||
|
|
captureDiagnostic,
|
||
|
|
registerHandlers,
|
||
|
|
};
|