From f5f7f141045b15a2ff3af61e162586f7ca0c698f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 9 Mar 2026 02:15:32 +0100 Subject: [PATCH] Add support bundle and trace tooling --- README.md | 12 +- src/main/app-controller.ts | 40 ++++++- src/main/debug-server.ts | 165 +++++++++++++++++++++++++++- src/main/logger.ts | 26 ++++- src/main/main.ts | 45 ++++++++ src/main/support-bundle.ts | 138 +++++++++++++++++++++++ src/main/trace-log.ts | 217 +++++++++++++++++++++++++++++++++++++ src/preload/preload.ts | 6 + src/renderer/App.tsx | 66 +++++++++++ src/shared/ipc.ts | 6 + src/shared/preload-api.ts | 7 ++ src/shared/types.ts | 8 ++ tests/debug-server.test.ts | 43 +++++++- tests/trace-log.test.ts | 53 +++++++++ 14 files changed, 819 insertions(+), 13 deletions(-) create mode 100644 src/main/support-bundle.ts create mode 100644 src/main/trace-log.ts create mode 100644 tests/trace-log.test.ts diff --git a/README.md b/README.md index 2a7a615..3ce092b 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,8 @@ Runtime files are stored in Electron's `userData` directory, including: - `rd_downloader.log` - `audit.log` - `debug_ai_manifest.json` +- `trace.log` +- `trace_config.json` - `session-logs/session_*.txt` - `package-logs/package_*.txt` - `item-logs/item_*.txt` @@ -202,6 +204,8 @@ Enable it by creating these files in the same runtime folder that contains `rd_d After startup, the app also writes `debug_ai_manifest.json` into the same runtime folder. This file is meant for support tooling and AI agents: it lists all available endpoints, the auth method, the related runtime files, and the one remaining external value the assistant may still need from you for remote access: the server IP or DNS name. +If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. + Available endpoints after restart: - `GET /health` @@ -218,9 +222,12 @@ Available endpoints after restart: - `GET /log?lines=100&grep=keyword` - `GET /logs/main?lines=100&grep=keyword` - `GET /logs/audit?lines=100&grep=keyword` +- `GET /logs/trace?lines=100&grep=keyword` - `GET /logs/session?lines=100&grep=keyword` - `GET /logs/package?package=Release&lines=100&grep=keyword` - `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword` +- `GET /trace/config?enable=1¬e=support` +- `GET /support/bundle` - `GET /diagnostics?package=Release&lines=150` Authentication works with either: @@ -237,12 +244,15 @@ Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20" Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200" +Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200" +Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1¬e=support" Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200" Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200" Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN" +Invoke-WebRequest "http://SERVER:9868/support/bundle?token=YOUR_TOKEN" -OutFile ".\\rd-support-bundle.zip" ``` -This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, package/session/item logs, and host-side Windows crash hints can be inspected remotely. +This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, trace data, package/session/item logs, host-side Windows crash hints, and even a full ZIP support bundle can be inspected remotely. ## Troubleshooting diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 0adb9ea..a8de449 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -34,10 +34,13 @@ import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session import { MegaWebFallback } from "./mega-web-fallback"; import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveHistory, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; -import { startDebugServer, stopDebugServer } from "./debug-server"; +import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { encryptBackup, decryptBackup } from "./backup-crypto"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { buildAccountSummary, diffAccountSummary } from "./support-data"; +import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; +import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; +import type { SupportTraceConfig } from "../shared/types"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -77,6 +80,7 @@ export class AppController { initPackageLogs(this.storagePaths.baseDir); initItemLogs(this.storagePaths.baseDir); initAuditLog(this.storagePaths.baseDir); + initTraceLog(this.storagePaths.baseDir); this.settings = loadSettings(this.storagePaths); const session = loadSession(this.storagePaths); this.megaWebFallback = new MegaWebFallback(() => ({ @@ -180,8 +184,29 @@ export class AppController { return getAuditLogPath(); } + public getTraceLogPath(): string | null { + return getTraceLogPath(); + } + + public getTraceConfig(): SupportTraceConfig { + return getTraceConfig(); + } + + public rotateDebugToken(): { path: string; token: string } { + const rotated = rotateDebugToken(this.storagePaths.baseDir); + this.audit("WARN", "Debug-Token rotiert", { path: rotated.path }); + return rotated; + } + private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record): void { logAuditEvent(level, message, fields); + logTraceEvent(level, "audit", message, fields); + } + + public setTraceEnabled(enabled: boolean, note = ""): SupportTraceConfig { + const next = setTraceEnabled(enabled, note); + this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note }); + return next; } public updateSettings(partial: Partial): AppSettings { @@ -473,6 +498,18 @@ export class AppController { return encryptBackup(payload); } + public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { + this.audit("INFO", "Support-Bundle exportiert"); + logTraceEvent("INFO", "support", "Support-Bundle erstellt", { + packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, + itemCount: Object.keys(this.manager.getSnapshot().session.items).length + }); + return { + buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir), + defaultFileName: getSupportBundleDefaultFileName() + }; + } + public importBackup(data: Buffer): { restored: boolean; message: string } { let parsed: Record; try { @@ -569,6 +606,7 @@ export class AppController { shutdownPackageLogs(); shutdownItemLogs(); this.audit("INFO", "App beendet"); + shutdownTraceLog(); shutdownAuditLog(); logger.info("App beendet"); } diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 49f9284..47ed2d8 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -1,6 +1,7 @@ import http from "node:http"; import fs from "node:fs"; import path from "node:path"; +import crypto from "node:crypto"; import { APP_VERSION } from "./constants"; import { getAuditLogPath } from "./audit-log"; import { logger, getLogFilePath } from "./logger"; @@ -9,6 +10,8 @@ import { getSessionLogPath } from "./session-log"; import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log"; import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; +import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; +import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, updateTraceConfig } from "./trace-log"; import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import type { DownloadManager } from "./download-manager"; import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; @@ -32,9 +35,11 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { method: "GET", path: "/log", queryExample: "lines=100&grep=keyword", description: "Legacy alias for the main application log tail." }, { method: "GET", path: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." }, { method: "GET", path: "/logs/audit", queryExample: "lines=100&grep=keyword", description: "Reads the audit log for support-relevant UI and admin actions." }, + { method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." }, { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, + { method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, { method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." }, @@ -43,6 +48,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." }, { method: "GET", path: "/items", queryExample: "status=downloading&package=Release", description: "Lists items and supports status/package filters." }, { method: "GET", path: "/session", queryExample: "package=Release", description: "Returns session-wide or package-scoped item state." }, + { method: "GET", path: "/support/bundle", description: "Downloads a ZIP support bundle with logs, diagnostics, and redacted state." }, { method: "GET", path: "/diagnostics", queryExample: "package=Release&lines=150", description: "Returns a combined support snapshot with logs, status, settings, accounts, stats, history, and host diagnostics." } ]; @@ -69,6 +75,10 @@ function getAiManifestPath(baseDir: string = runtimeBaseDir): string { return path.join(baseDir, AI_MANIFEST_FILE); } +function getDebugTokenPath(baseDir: string = runtimeBaseDir): string { + return path.join(baseDir, "debug_token.txt"); +} + function loadToken(baseDir: string): string { const tokenPath = path.join(baseDir, "debug_token.txt"); try { @@ -132,6 +142,23 @@ function jsonResponse(res: http.ServerResponse, status: number, data: unknown): res.end(body); } +function binaryResponse( + res: http.ServerResponse, + status: number, + body: Buffer, + contentType: string, + fileName?: string +): void { + res.writeHead(status, { + "Content-Type": contentType, + "Content-Length": String(body.length), + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-cache", + ...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {}) + }); + res.end(body); +} + function normalizeLinesParam(rawValue: string | null, fallback: number): number { const parsed = Number(rawValue || String(fallback)); if (!Number.isFinite(parsed) || parsed <= 0) { @@ -161,6 +188,31 @@ function filterLines(lines: string[], grep: string): string[] { return lines.filter((line) => line.toLowerCase().includes(pattern)); } +function toBooleanQuery(value: string | null): boolean | null { + if (value === null) { + return null; + } + if (/^(1|true|yes|on)$/i.test(value)) { + return true; + } + if (/^(0|false|no|off)$/i.test(value)) { + return false; + } + return null; +} + +function sanitizeRequestUrlForTrace(rawUrl: string): string { + try { + const url = new URL(rawUrl || "/", "http://localhost"); + if (url.searchParams.has("token")) { + url.searchParams.set("token", "***"); + } + return `${url.pathname}${url.search}`; + } catch { + return String(rawUrl || "/"); + } +} + function formatEndpointSummary(endpoint: DebugEndpointDescriptor): string { return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`; } @@ -184,7 +236,8 @@ function buildAiManifest(baseDir: string): Record { "Read debug_token.txt and debug_port.txt from this runtime folder.", "If remote access is needed, ask the user only for the server IP or DNS name.", "Call /meta first to confirm the server is reachable and to re-read the endpoint list.", - "Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /status, /packages, /items, /settings, /accounts, /stats, or /history." + "Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.", + "If a full handoff is needed, download /support/bundle as a ZIP." ], auth: { required: true, @@ -200,6 +253,8 @@ function buildAiManifest(baseDir: string): Record { tokenFile: path.join(baseDir, "debug_token.txt"), mainLogFile: getLogFilePath(), auditLogFile: getAuditLogPath(), + traceLogFile: getTraceLogPath(), + traceConfigFile: getTraceConfigPath(), sessionLogFile: getSessionLogPath(), packageLogDir: path.join(baseDir, "package-logs"), itemLogDir: path.join(baseDir, "item-logs"), @@ -233,6 +288,19 @@ function writeAiManifest(baseDir: string): void { } } +export function rotateDebugToken(baseDir: string = runtimeBaseDir): { path: string; token: string } { + const token = crypto.randomBytes(24).toString("hex"); + const tokenPath = getDebugTokenPath(baseDir); + fs.writeFileSync(tokenPath, `${token}\n`, "utf8"); + if (baseDir === runtimeBaseDir) { + authToken = token; + writeAiManifest(baseDir); + } + logger.info(`Debug-Server Token rotiert: ${tokenPath}`); + logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath }); + return { path: tokenPath, token }; +} + function summarizeItem(item: DownloadItem): Record { return { id: item.id, @@ -348,6 +416,16 @@ function buildStatusPayload(snapshot: UiSnapshot): Record { } function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const url = new URL(req.url || "/", "http://localhost"); + const pathname = url.pathname; + const traceConfig = getTraceConfig(); + if (traceConfig.enabled && traceConfig.logDebugRequests) { + logTraceEvent("INFO", "debug-http", "Request", { + method: req.method || "GET", + url: sanitizeRequestUrlForTrace(req.url || "/") + }); + } + if (req.method === "OPTIONS") { res.writeHead(204, { "Access-Control-Allow-Origin": "*", @@ -359,13 +437,16 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi } if (!checkAuth(req)) { + if (traceConfig.enabled && traceConfig.logDebugRequests) { + logTraceEvent("WARN", "debug-http", "Unauthorized request", { + method: req.method || "GET", + url: sanitizeRequestUrlForTrace(req.url || "/") + }); + } jsonResponse(res, 401, { error: "Unauthorized" }); return; } - const url = new URL(req.url || "/", "http://localhost"); - const pathname = url.pathname; - if (pathname === "/health") { jsonResponse(res, 200, { status: "ok", @@ -385,11 +466,15 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi port: bindPort }, supportFiles: { - aiManifest: getAiManifestPath() + aiManifest: getAiManifestPath(), + traceConfig: getTraceConfigPath(), + traceLog: getTraceLogPath() }, logPaths: { main: getLogFilePath(), - session: getSessionLogPath() + audit: getAuditLogPath(), + session: getSessionLogPath(), + trace: getTraceLogPath() }, endpoints: getEndpointSummaries() }); @@ -422,6 +507,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/logs/trace") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getTraceLogPath(); + const lines = filterLines(readLogTailFromFile(logPath, count), grep); + jsonResponse(res, 200, { + path: logPath, + configPath: getTraceConfigPath(), + config: getTraceConfig(), + lines, + count: lines.length + }); + return; + } + if (pathname === "/logs/session") { const count = normalizeLinesParam(url.searchParams.get("lines"), 100); const grep = url.searchParams.get("grep") || ""; @@ -435,6 +535,39 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/trace/config") { + const patch: Record = {}; + const enabled = toBooleanQuery(url.searchParams.get("enable")); + const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog")); + const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit")); + const logDebugRequests = toBooleanQuery(url.searchParams.get("logDebugRequests")); + if (enabled !== null) { + patch.enabled = enabled; + } + if (includeMainLog !== null) { + patch.includeMainLog = includeMainLog; + } + if (includeAudit !== null) { + patch.includeAudit = includeAudit; + } + if (logDebugRequests !== null) { + patch.logDebugRequests = logDebugRequests; + } + const note = String(url.searchParams.get("note") || "").trim(); + const config = Object.keys(patch).length > 0 + ? updateTraceConfig({ ...patch, ...(note ? { updatedAt: new Date().toISOString() } : {}) }) + : getTraceConfig(); + if (Object.keys(patch).length > 0) { + logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note }); + } + jsonResponse(res, 200, { + path: getTraceConfigPath(), + logPath: getTraceLogPath(), + config + }); + return; + } + if (pathname === "/logs/package") { if (!manager) { jsonResponse(res, 503, { error: "Manager not initialized" }); @@ -626,6 +759,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/support/bundle") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const fileName = getSupportBundleDefaultFileName(); + const body = buildSupportBundle(manager, runtimeBaseDir); + logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", { + fileName, + sizeBytes: body.length + }); + binaryResponse(res, 200, body, "application/zip", fileName); + return; + } + if (pathname === "/diagnostics") { if (!manager) { jsonResponse(res, 503, { error: "Manager not initialized" }); @@ -673,6 +821,11 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi path: getAuditLogPath(), lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep) }, + trace: { + path: getTraceLogPath(), + config: getTraceConfig(), + lines: filterLines(readLogTailFromFile(getTraceLogPath(), lineCount), grep) + }, session: { path: sessionLogPath, lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep) diff --git a/src/main/logger.ts b/src/main/logger.ts index dde812e..0c4f672 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -9,7 +9,8 @@ const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024; const rotateCheckAtByFile = new Map(); type LogListener = (line: string) => void; -let logListener: LogListener | null = null; +const logListeners = new Set(); +let legacyLogListener: LogListener | null = null; let pendingLines: string[] = []; let pendingChars = 0; @@ -18,7 +19,24 @@ let flushInFlight = false; let exitHookAttached = false; export function setLogListener(listener: LogListener | null): void { - logListener = listener; + if (legacyLogListener) { + logListeners.delete(legacyLogListener); + } + legacyLogListener = listener; + if (listener) { + logListeners.add(listener); + } +} + +export function addLogListener(listener: LogListener): void { + logListeners.add(listener); +} + +export function removeLogListener(listener: LogListener): void { + logListeners.delete(listener); + if (legacyLogListener === listener) { + legacyLogListener = null; + } } export function configureLogger(baseDir: string): void { @@ -195,8 +213,8 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void { pendingLines.push(line); pendingChars += line.length; - if (logListener) { - try { logListener(line); } catch { /* ignore */ } + for (const listener of logListeners) { + try { listener(line); } catch { /* ignore */ } } while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { diff --git a/src/main/main.ts b/src/main/main.ts index 17403bb..e8a59d6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -490,11 +490,32 @@ function registerIpcHandlers(): void { return { saved: true }; }); + ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { + const exported = controller.exportSupportBundle(); + const options = { + defaultPath: exported.defaultFileName, + filters: [{ name: "Support Bundle", extensions: ["zip"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false }; + } + await fs.promises.writeFile(result.filePath, exported.buffer); + return { saved: true, filePath: result.filePath }; + }); + ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { const logPath = getLogFilePath(); await shell.openPath(logPath); }); + ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => { + const logPath = controller.getAuditLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { const logPath = controller.getSessionLogPath(); if (logPath) { @@ -502,6 +523,13 @@ function registerIpcHandlers(): void { } }); + ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => { + const logPath = controller.getTraceLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); const logPath = controller.getPackageLogPath(packageId); @@ -510,6 +538,23 @@ function registerIpcHandlers(): void { } }); + ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig()); + + ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string) => { + if (typeof enabled !== "boolean") { + throw new Error("enabled muss ein Boolean sein"); + } + if (note !== undefined) { + validateString(note, "note"); + } + return controller.setTraceEnabled(enabled, note); + }); + + ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => { + const rotated = controller.rotateDebugToken(); + return { path: rotated.path }; + }); + ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { validateString(itemId, "itemId"); const logPath = controller.getItemLogPath(itemId); diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts new file mode 100644 index 0000000..ac20257 --- /dev/null +++ b/src/main/support-bundle.ts @@ -0,0 +1,138 @@ +import fs from "node:fs"; +import path from "node:path"; +import AdmZip from "adm-zip"; +import { APP_VERSION } from "./constants"; +import { getAuditLogPath } from "./audit-log"; +import { getLogFilePath } from "./logger"; +import { getPackageLogPath } from "./package-log"; +import { getSessionLogPath } from "./session-log"; +import { createStoragePaths, loadHistory, loadSettings } from "./storage"; +import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; +import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log"; +import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; +import type { DownloadManager } from "./download-manager"; + +const AI_MANIFEST_FILE = "debug_ai_manifest.json"; + +function safeReadJson(filePath: string): unknown { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + } catch { + return null; + } +} + +function addJson(zip: AdmZip, zipPath: string, value: unknown): void { + zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")); +} + +function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): void { + if (!sourcePath || !fs.existsSync(sourcePath)) { + return; + } + zip.addLocalFile(sourcePath, path.posix.dirname(zipPath), path.posix.basename(zipPath)); +} + +function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): void { + if (!fs.existsSync(dirPath)) { + return; + } + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const zipPath = path.posix.join(zipRoot, entry.name); + if (entry.isDirectory()) { + addDirectoryIfExists(zip, fullPath, zipPath); + continue; + } + zip.addLocalFile(fullPath, path.posix.dirname(zipPath), path.posix.basename(zipPath)); + } +} + +function formatTimestampForFileName(date: Date): string { + const y = date.getFullYear(); + const mo = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const h = String(date.getHours()).padStart(2, "0"); + const mi = String(date.getMinutes()).padStart(2, "0"); + const s = String(date.getSeconds()).padStart(2, "0"); + return `${y}-${mo}-${d}_${h}-${mi}-${s}`; +} + +export function getSupportBundleDefaultFileName(): string { + return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`; +} + +export function buildSupportBundle(manager: DownloadManager, baseDir: string): Buffer { + const zip = new AdmZip(); + const storagePaths = createStoragePaths(baseDir); + const settings = loadSettings(storagePaths); + const history = loadHistory(storagePaths); + const snapshot = manager.getSnapshot(); + const packageIds = Object.keys(snapshot.session.packages); + const itemIds = Object.keys(snapshot.session.items); + + addJson(zip, "overview/meta.json", { + appVersion: APP_VERSION, + generatedAt: new Date().toISOString(), + runtimeBaseDir: baseDir, + packageCount: packageIds.length, + itemCount: itemIds.length + }); + addJson(zip, "overview/status.json", snapshot.session); + addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings)); + addJson(zip, "overview/accounts.json", buildAccountSummary(settings)); + addJson(zip, "overview/stats.json", { + ...buildStatsPayload(snapshot), + allTime: { + totalDownloadedAllTime: settings.totalDownloadedAllTime, + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime + } + }); + addJson(zip, "overview/history.json", { + total: history.length, + entries: history.map((entry) => summarizeHistoryEntry(entry)) + }); + addJson(zip, "overview/packages.json", { + count: packageIds.length, + packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean) + }); + addJson(zip, "overview/items.json", { + count: itemIds.length, + items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean) + }); + addJson(zip, "overview/host-diagnostics.json", getWindowsHostDiagnostics()); + addJson(zip, "overview/trace-config.json", getTraceConfig()); + + addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`); + addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); + addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt"); + addFileIfExists(zip, storagePaths.configFile, "runtime/rd_downloader_config.json"); + addFileIfExists(zip, storagePaths.sessionFile, "runtime/rd_session_state.json"); + addFileIfExists(zip, storagePaths.historyFile, "runtime/rd_history.json"); + addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json"); + + addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); + addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old"); + addFileIfExists(zip, getAuditLogPath(), "logs/audit.log"); + addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); + addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); + + addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); + addDirectoryIfExists(zip, path.join(baseDir, "package-logs"), "logs/package-logs"); + addDirectoryIfExists(zip, path.join(baseDir, "item-logs"), "logs/item-logs"); + + for (const packageId of packageIds) { + addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`); + } + for (const itemId of itemIds) { + addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`); + } + + const aiManifest = safeReadJson(path.join(baseDir, AI_MANIFEST_FILE)); + if (aiManifest) { + addJson(zip, "overview/ai-manifest.json", aiManifest); + } + + return zip.toBuffer(); +} diff --git a/src/main/trace-log.ts b/src/main/trace-log.ts new file mode 100644 index 0000000..5779b58 --- /dev/null +++ b/src/main/trace-log.ts @@ -0,0 +1,217 @@ +import fs from "node:fs"; +import path from "node:path"; +import { addLogListener, removeLogListener } from "./logger"; +import type { SupportTraceConfig } from "../shared/types"; + +type TraceLevel = "INFO" | "WARN" | "ERROR"; + +const TRACE_LOG_FLUSH_INTERVAL_MS = 200; +const TRACE_CONFIG_FILE = "trace_config.json"; + +const DEFAULT_TRACE_CONFIG: SupportTraceConfig = { + enabled: false, + includeMainLog: true, + includeAudit: true, + logDebugRequests: true, + updatedAt: new Date(0).toISOString() +}; + +let traceLogPath: string | null = null; +let traceConfigPath: string | null = null; +let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG }; +let pendingLines: string[] = []; +let flushTimer: NodeJS.Timeout | null = null; + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +function flushPending(): void { + if (!traceLogPath || pendingLines.length === 0) { + return; + } + const chunk = pendingLines.join(""); + pendingLines = []; + try { + fs.appendFileSync(traceLogPath, chunk, "utf8"); + } catch { + // ignore + } +} + +function scheduleFlush(): void { + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flushPending(); + }, TRACE_LOG_FLUSH_INTERVAL_MS); +} + +function appendTraceLine(line: string): void { + if (!traceLogPath) { + return; + } + pendingLines.push(line); + scheduleFlush(); +} + +function normalizeTraceConfig(raw: unknown): SupportTraceConfig { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { ...DEFAULT_TRACE_CONFIG }; + } + const value = raw as Partial; + return { + enabled: Boolean(value.enabled), + includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog), + includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit), + logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests), + updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim() + ? value.updatedAt + : DEFAULT_TRACE_CONFIG.updatedAt + }; +} + +function loadTraceConfig(): SupportTraceConfig { + if (!traceConfigPath) { + return { ...DEFAULT_TRACE_CONFIG }; + } + try { + const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown; + return normalizeTraceConfig(parsed); + } catch { + return { ...DEFAULT_TRACE_CONFIG }; + } +} + +function persistTraceConfig(): void { + if (!traceConfigPath) { + return; + } + try { + fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8"); + } catch { + // ignore + } +} + +const mainLogListener = (line: string): void => { + if (!traceConfig.enabled || !traceConfig.includeMainLog) { + return; + } + appendTraceLine(line); +}; + +export function initTraceLog(baseDir: string): void { + traceLogPath = path.join(baseDir, "trace.log"); + traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE); + try { + fs.mkdirSync(baseDir, { recursive: true }); + if (!fs.existsSync(traceLogPath)) { + fs.writeFileSync(traceLogPath, "", "utf8"); + } + traceConfig = loadTraceConfig(); + persistTraceConfig(); + fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${new Date().toISOString()} ===\n`, "utf8"); + } catch { + traceLogPath = null; + traceConfigPath = null; + traceConfig = { ...DEFAULT_TRACE_CONFIG }; + return; + } + addLogListener(mainLogListener); +} + +export function getTraceLogPath(): string | null { + if (!traceLogPath) { + return null; + } + return fs.existsSync(traceLogPath) ? traceLogPath : null; +} + +export function getTraceConfigPath(): string | null { + if (!traceConfigPath) { + return null; + } + return fs.existsSync(traceConfigPath) ? traceConfigPath : null; +} + +export function getTraceConfig(): SupportTraceConfig { + return { ...traceConfig }; +} + +export function updateTraceConfig(patch: Partial): SupportTraceConfig { + traceConfig = normalizeTraceConfig({ + ...traceConfig, + ...patch, + updatedAt: new Date().toISOString() + }); + persistTraceConfig(); + appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig)}\n`); + return getTraceConfig(); +} + +export function setTraceEnabled(enabled: boolean, note = ""): SupportTraceConfig { + const next = updateTraceConfig({ enabled }); + appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note })}\n`); + return next; +} + +export function logTraceEvent( + level: TraceLevel, + category: string, + message: string, + fields?: Record +): void { + if (!traceConfig.enabled) { + return; + } + if (category === "audit" && !traceConfig.includeAudit) { + return; + } + appendTraceLine(`${new Date().toISOString()} [${level}] [${category}] ${message}${formatFields(fields)}\n`); +} + +export function shutdownTraceLog(): void { + removeLogListener(mainLogListener); + if (!traceLogPath) { + return; + } + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + flushPending(); + try { + fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${new Date().toISOString()} ===\n`, "utf8"); + } catch { + // ignore + } + traceLogPath = null; + traceConfigPath = null; + traceConfig = { ...DEFAULT_TRACE_CONFIG }; +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 71d8624..69f304f 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -56,10 +56,16 @@ const api: ElectronApi = { quit: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.QUIT), exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), + exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), + openAuditLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), openSessionLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), + openTraceLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG), openPackageLog: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId), openItemLog: (itemId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId), + getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG), + setTraceEnabled: (enabled: boolean, note?: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note), + rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN), openRealDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openAllDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0835a8c..c59f083 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1284,6 +1284,7 @@ export function App(): ReactElement { const actionBusyRef = useRef(false); const actionUnlockTimerRef = useRef | null>(null); const mountedRef = useRef(true); + const [supportTraceEnabled, setSupportTraceEnabled] = useState(false); const dragOverRef = useRef(false); const dragDepthRef = useRef(0); const [openMenu, setOpenMenu] = useState(null); @@ -1558,6 +1559,11 @@ export function App(): ReactElement { let unsubClipboard: (() => void) | null = null; let unsubUpdateInstallProgress: (() => void) | null = null; void window.rd.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined); + void window.rd.getTraceConfig().then((config) => { + if (mountedRef.current) { + setSupportTraceEnabled(config.enabled); + } + }).catch(() => undefined); void window.rd.getSnapshot().then((state) => { if (!mountedRef.current) { return; @@ -3391,6 +3397,49 @@ export function App(): ReactElement { }); }; + const onExportSupportBundle = async (): Promise => { + closeMenus(); + await performQuickAction(async () => { + const result = await window.rd.exportSupportBundle(); + if (result.saved) { + showToast("Support-Bundle exportiert", 2600); + } + }, (error) => { + showToast(`Support-Bundle fehlgeschlagen: ${String(error)}`, 2800); + }); + }; + + const onToggleSupportTrace = async (): Promise => { + closeMenus(); + const nextEnabled = !supportTraceEnabled; + await performQuickAction(async () => { + const result = await window.rd.setTraceEnabled(nextEnabled, "UI support toggle"); + setSupportTraceEnabled(result.enabled); + showToast(result.enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", 2400); + }, (error) => { + showToast(`Support-Trace fehlgeschlagen: ${String(error)}`, 2800); + }); + }; + + const onRotateDebugToken = async (): Promise => { + closeMenus(); + const confirmed = await askConfirmPrompt({ + title: "Debug-Token rotieren", + message: "Das aktuelle Debug-Token wird ersetzt. Externe Debug-Links mit altem Token funktionieren danach nicht mehr.", + confirmLabel: "Token rotieren", + danger: true + }); + if (!confirmed) { + return; + } + await performQuickAction(async () => { + const result = await window.rd.rotateDebugToken(); + showToast(`Debug-Token rotiert: ${result.path}`, 4200); + }, (error) => { + showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000); + }); + }; + const onMenuRestart = (): void => { closeMenus(); void window.rd.restart(); @@ -3702,9 +3751,26 @@ export function App(): ReactElement { + + +
+ + + +
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 0b93a16..f8cb7ea 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -36,10 +36,16 @@ export const IPC_CHANNELS = { QUIT: "app:quit", EXPORT_BACKUP: "app:export-backup", IMPORT_BACKUP: "app:import-backup", + EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle", OPEN_LOG: "app:open-log", + OPEN_AUDIT_LOG: "app:open-audit-log", OPEN_SESSION_LOG: "app:open-session-log", + OPEN_TRACE_LOG: "app:open-trace-log", OPEN_PACKAGE_LOG: "app:open-package-log", OPEN_ITEM_LOG: "app:open-item-log", + GET_TRACE_CONFIG: "app:get-trace-config", + SET_TRACE_ENABLED: "app:set-trace-enabled", + ROTATE_DEBUG_TOKEN: "app:rotate-debug-token", OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login", OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 4ddae41..9d2590b 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -10,6 +10,7 @@ import type { SessionStats, StartConflictEntry, StartConflictResolutionResult, + SupportTraceConfig, UiSnapshot, UpdateCheckResult, UpdateInstallProgress, @@ -51,10 +52,16 @@ export interface ElectronApi { quit: () => Promise; exportBackup: () => Promise<{ saved: boolean }>; importBackup: () => Promise<{ restored: boolean; message: string }>; + exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; openLog: () => Promise; + openAuditLog: () => Promise; openSessionLog: () => Promise; + openTraceLog: () => Promise; openPackageLog: (packageId: string) => Promise; openItemLog: (itemId: string) => Promise; + getTraceConfig: () => Promise; + setTraceEnabled: (enabled: boolean, note?: string) => Promise; + rotateDebugToken: () => Promise<{ path: string }>; openRealDebridLogin: () => Promise; openAllDebridLogin: () => Promise; importBestDebridCookies: () => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index 8a6a3ac..dde6c66 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -337,6 +337,14 @@ export interface SessionStats { queuedDownloads: number; } +export interface SupportTraceConfig { + enabled: boolean; + includeMainLog: boolean; + includeAudit: boolean; + logDebugRequests: boolean; + updatedAt: string; +} + export interface HistoryEntry { id: string; name: string; diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index 4aafb6a..d4c3c50 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -3,6 +3,7 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; import { once } from "node:events"; +import AdmZip from "adm-zip"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../src/main/windows-host-diagnostics", () => ({ @@ -43,10 +44,11 @@ import { defaultSettings } from "../src/main/constants"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log"; import { startDebugServer, stopDebugServer } from "../src/main/debug-server"; import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; -import { configureLogger, getLogFilePath } from "../src/main/logger"; +import { configureLogger, getLogFilePath, logger } from "../src/main/logger"; import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage"; +import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log"; import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys"; import type { DownloadManager } from "../src/main/download-manager"; import type { UiSnapshot } from "../src/shared/types"; @@ -233,12 +235,17 @@ async function createFixture() { } logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" }); + initTraceLog(baseDir); + setTraceEnabled(true, "test-fixture"); + logTraceEvent("INFO", "support", "TRACE-EVENT", { scope: "fixture" }); + initSessionLog(baseDir); const sessionLogPath = getSessionLogPath(); if (!sessionLogPath) { throw new Error("session log path missing"); } fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8"); + logger.info("TRACE-MAIN-LINE"); initPackageLogs(baseDir); initItemLogs(baseDir); @@ -273,6 +280,7 @@ async function createFixture() { startDebugServer(manager, baseDir); const baseUrl = `http://127.0.0.1:${port}`; await waitForReady(`${baseUrl}/health?token=${token}`); + await new Promise((resolve) => setTimeout(resolve, 300)); return { baseUrl, @@ -286,6 +294,7 @@ afterEach(() => { shutdownSessionLog(); shutdownPackageLogs(); shutdownItemLogs(); + shutdownTraceLog(); shutdownAuditLog(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -315,6 +324,7 @@ describe("debug-server", () => { expect(payload.selectedPackage?.name).toBe("server-package"); expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE"); expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE"); + expect((payload.logs?.trace?.lines || []).join("\n")).toContain("TRACE-EVENT"); expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE"); expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE"); expect(payload.accounts?.realDebrid?.configured).toBe(true); @@ -339,6 +349,8 @@ describe("debug-server", () => { expect(metaResponse.ok).toBe(true); const metaPayload = await metaResponse.json() as Record; expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath); + expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath()); + expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath()); }); it("serves package details and package log by package query", async () => { @@ -386,6 +398,17 @@ describe("debug-server", () => { const auditPayload = await auditResponse.json() as Record; expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE"); + const traceResponse = await fetch(`${fixture.baseUrl}/logs/trace?token=${fixture.token}&lines=50`); + expect(traceResponse.ok).toBe(true); + const tracePayload = await traceResponse.json() as Record; + expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT"); + expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE"); + + const traceConfigResponse = await fetch(`${fixture.baseUrl}/trace/config?token=${fixture.token}&enable=0¬e=test`); + expect(traceConfigResponse.ok).toBe(true); + const traceConfigPayload = await traceConfigResponse.json() as Record; + expect(traceConfigPayload.config?.enabled).toBe(false); + const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`); expect(settingsResponse.ok).toBe(true); const settingsPayload = await settingsResponse.json() as Record; @@ -415,6 +438,24 @@ describe("debug-server", () => { expect(historyPayload.entries?.[0]?.urlCount).toBe(1); }); + it("downloads a support bundle zip", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`); + expect(response.ok).toBe(true); + expect(response.headers.get("content-type")).toContain("application/zip"); + + const buffer = Buffer.from(await response.arrayBuffer()); + const zip = new AdmZip(buffer); + const entries = zip.getEntries().map((entry) => entry.entryName); + expect(entries).toContain("overview/settings.json"); + expect(entries).toContain("overview/accounts.json"); + expect(entries).toContain("overview/trace-config.json"); + expect(entries).toContain("logs/audit.log"); + expect(entries).toContain("logs/trace.log"); + expect(entries).toContain("runtime/debug_ai_manifest.json"); + expect(entries).not.toContain("runtime/debug_token.txt"); + }); + it("rejects unauthenticated requests", async () => { const fixture = await createFixture(); const response = await fetch(`${fixture.baseUrl}/status`); diff --git a/tests/trace-log.test.ts b/tests/trace-log.test.ts new file mode 100644 index 0000000..e343bf7 --- /dev/null +++ b/tests/trace-log.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { configureLogger, logger } from "../src/main/logger"; +import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; +import { getTraceConfig, getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownSessionLog(); + shutdownTraceLog(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("trace-log", () => { + it("captures main log lines and explicit trace events when enabled", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-")); + tempDirs.push(baseDir); + + configureLogger(baseDir); + initTraceLog(baseDir); + initSessionLog(baseDir); + setTraceEnabled(true, "test"); + + logger.info("TRACE-MAIN-CAPTURE"); + logTraceEvent("INFO", "audit", "TRACE-AUDIT-CAPTURE", { source: "test" }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + const traceLogPath = getTraceLogPath(); + const sessionLogPath = getSessionLogPath(); + const traceConfigPath = getTraceConfigPath(); + expect(traceLogPath).not.toBeNull(); + expect(sessionLogPath).not.toBeNull(); + expect(traceConfigPath).not.toBeNull(); + + const traceContent = fs.readFileSync(traceLogPath!, "utf8"); + expect(traceContent).toContain("Trace-Log Start"); + expect(traceContent).toContain("TRACE-MAIN-CAPTURE"); + expect(traceContent).toContain("TRACE-AUDIT-CAPTURE"); + + const sessionContent = fs.readFileSync(sessionLogPath!, "utf8"); + expect(sessionContent).toContain("TRACE-MAIN-CAPTURE"); + + const traceConfig = getTraceConfig(); + expect(traceConfig.enabled).toBe(true); + expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true); + }); +});