Add support bundle and trace tooling

This commit is contained in:
Sucukdeluxe 2026-03-09 02:15:32 +01:00
parent db6e7d81a4
commit f5f7f14104
14 changed files with 819 additions and 13 deletions

View File

@ -183,6 +183,8 @@ Runtime files are stored in Electron's `userData` directory, including:
- `rd_downloader.log` - `rd_downloader.log`
- `audit.log` - `audit.log`
- `debug_ai_manifest.json` - `debug_ai_manifest.json`
- `trace.log`
- `trace_config.json`
- `session-logs/session_*.txt` - `session-logs/session_*.txt`
- `package-logs/package_*.txt` - `package-logs/package_*.txt`
- `item-logs/item_*.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. 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: Available endpoints after restart:
- `GET /health` - `GET /health`
@ -218,9 +222,12 @@ Available endpoints after restart:
- `GET /log?lines=100&grep=keyword` - `GET /log?lines=100&grep=keyword`
- `GET /logs/main?lines=100&grep=keyword` - `GET /logs/main?lines=100&grep=keyword`
- `GET /logs/audit?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/session?lines=100&grep=keyword`
- `GET /logs/package?package=Release&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 /logs/item?item=episode.part2.rar&lines=100&grep=keyword`
- `GET /trace/config?enable=1&note=support`
- `GET /support/bundle`
- `GET /diagnostics?package=Release&lines=150` - `GET /diagnostics?package=Release&lines=150`
Authentication works with either: 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/stats?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20" 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/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&note=support"
Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200" 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/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200"
Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN" 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 ## Troubleshooting

View File

@ -34,10 +34,13 @@ import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session
import { MegaWebFallback } from "./mega-web-fallback"; 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 { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveHistory, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; 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 { encryptBackup, decryptBackup } from "./backup-crypto";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { buildAccountSummary, diffAccountSummary } from "./support-data"; 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<AppSettings>): Partial<AppSettings> { function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@ -77,6 +80,7 @@ export class AppController {
initPackageLogs(this.storagePaths.baseDir); initPackageLogs(this.storagePaths.baseDir);
initItemLogs(this.storagePaths.baseDir); initItemLogs(this.storagePaths.baseDir);
initAuditLog(this.storagePaths.baseDir); initAuditLog(this.storagePaths.baseDir);
initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths); this.settings = loadSettings(this.storagePaths);
const session = loadSession(this.storagePaths); const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({ this.megaWebFallback = new MegaWebFallback(() => ({
@ -180,8 +184,29 @@ export class AppController {
return getAuditLogPath(); 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<string, unknown>): void { private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
logAuditEvent(level, message, fields); 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>): AppSettings { public updateSettings(partial: Partial<AppSettings>): AppSettings {
@ -473,6 +498,18 @@ export class AppController {
return encryptBackup(payload); 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 } { public importBackup(data: Buffer): { restored: boolean; message: string } {
let parsed: Record<string, unknown>; let parsed: Record<string, unknown>;
try { try {
@ -569,6 +606,7 @@ export class AppController {
shutdownPackageLogs(); shutdownPackageLogs();
shutdownItemLogs(); shutdownItemLogs();
this.audit("INFO", "App beendet"); this.audit("INFO", "App beendet");
shutdownTraceLog();
shutdownAuditLog(); shutdownAuditLog();
logger.info("App beendet"); logger.info("App beendet");
} }

View File

@ -1,6 +1,7 @@
import http from "node:http"; import http from "node:http";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto";
import { APP_VERSION } from "./constants"; import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log"; import { getAuditLogPath } from "./audit-log";
import { logger, getLogFilePath } from "./logger"; import { logger, getLogFilePath } from "./logger";
@ -9,6 +10,8 @@ import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log"; import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; 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 { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager"; import type { DownloadManager } from "./download-manager";
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; 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: "/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/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/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/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/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: "/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&note=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: "/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: "/accounts", description: "Returns a redacted account/provider configuration summary." },
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." }, { 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: "/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: "/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: "/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." } { 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); 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 { function loadToken(baseDir: string): string {
const tokenPath = path.join(baseDir, "debug_token.txt"); const tokenPath = path.join(baseDir, "debug_token.txt");
try { try {
@ -132,6 +142,23 @@ function jsonResponse(res: http.ServerResponse, status: number, data: unknown):
res.end(body); 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 { function normalizeLinesParam(rawValue: string | null, fallback: number): number {
const parsed = Number(rawValue || String(fallback)); const parsed = Number(rawValue || String(fallback));
if (!Number.isFinite(parsed) || parsed <= 0) { 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)); 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 { function formatEndpointSummary(endpoint: DebugEndpointDescriptor): string {
return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`; return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`;
} }
@ -184,7 +236,8 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
"Read debug_token.txt and debug_port.txt from this runtime folder.", "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.", "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.", "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: { auth: {
required: true, required: true,
@ -200,6 +253,8 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
tokenFile: path.join(baseDir, "debug_token.txt"), tokenFile: path.join(baseDir, "debug_token.txt"),
mainLogFile: getLogFilePath(), mainLogFile: getLogFilePath(),
auditLogFile: getAuditLogPath(), auditLogFile: getAuditLogPath(),
traceLogFile: getTraceLogPath(),
traceConfigFile: getTraceConfigPath(),
sessionLogFile: getSessionLogPath(), sessionLogFile: getSessionLogPath(),
packageLogDir: path.join(baseDir, "package-logs"), packageLogDir: path.join(baseDir, "package-logs"),
itemLogDir: path.join(baseDir, "item-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<string, unknown> { function summarizeItem(item: DownloadItem): Record<string, unknown> {
return { return {
id: item.id, id: item.id,
@ -348,6 +416,16 @@ function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
} }
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { 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") { if (req.method === "OPTIONS") {
res.writeHead(204, { res.writeHead(204, {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
@ -359,13 +437,16 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
} }
if (!checkAuth(req)) { 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" }); jsonResponse(res, 401, { error: "Unauthorized" });
return; return;
} }
const url = new URL(req.url || "/", "http://localhost");
const pathname = url.pathname;
if (pathname === "/health") { if (pathname === "/health") {
jsonResponse(res, 200, { jsonResponse(res, 200, {
status: "ok", status: "ok",
@ -385,11 +466,15 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
port: bindPort port: bindPort
}, },
supportFiles: { supportFiles: {
aiManifest: getAiManifestPath() aiManifest: getAiManifestPath(),
traceConfig: getTraceConfigPath(),
traceLog: getTraceLogPath()
}, },
logPaths: { logPaths: {
main: getLogFilePath(), main: getLogFilePath(),
session: getSessionLogPath() audit: getAuditLogPath(),
session: getSessionLogPath(),
trace: getTraceLogPath()
}, },
endpoints: getEndpointSummaries() endpoints: getEndpointSummaries()
}); });
@ -422,6 +507,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; 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") { if (pathname === "/logs/session") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100); const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || ""; const grep = url.searchParams.get("grep") || "";
@ -435,6 +535,39 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
if (pathname === "/trace/config") {
const patch: Record<string, unknown> = {};
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 (pathname === "/logs/package") {
if (!manager) { if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" }); jsonResponse(res, 503, { error: "Manager not initialized" });
@ -626,6 +759,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; 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 (pathname === "/diagnostics") {
if (!manager) { if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" }); jsonResponse(res, 503, { error: "Manager not initialized" });
@ -673,6 +821,11 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
path: getAuditLogPath(), path: getAuditLogPath(),
lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep) lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep)
}, },
trace: {
path: getTraceLogPath(),
config: getTraceConfig(),
lines: filterLines(readLogTailFromFile(getTraceLogPath(), lineCount), grep)
},
session: { session: {
path: sessionLogPath, path: sessionLogPath,
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep) lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)

View File

@ -9,7 +9,8 @@ const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
const rotateCheckAtByFile = new Map<string, number>(); const rotateCheckAtByFile = new Map<string, number>();
type LogListener = (line: string) => void; type LogListener = (line: string) => void;
let logListener: LogListener | null = null; const logListeners = new Set<LogListener>();
let legacyLogListener: LogListener | null = null;
let pendingLines: string[] = []; let pendingLines: string[] = [];
let pendingChars = 0; let pendingChars = 0;
@ -18,7 +19,24 @@ let flushInFlight = false;
let exitHookAttached = false; let exitHookAttached = false;
export function setLogListener(listener: LogListener | null): void { 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 { export function configureLogger(baseDir: string): void {
@ -195,8 +213,8 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
pendingLines.push(line); pendingLines.push(line);
pendingChars += line.length; pendingChars += line.length;
if (logListener) { for (const listener of logListeners) {
try { logListener(line); } catch { /* ignore */ } try { listener(line); } catch { /* ignore */ }
} }
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {

View File

@ -490,11 +490,32 @@ function registerIpcHandlers(): void {
return { saved: true }; 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 () => { ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => {
const logPath = getLogFilePath(); const logPath = getLogFilePath();
await shell.openPath(logPath); 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 () => { ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => {
const logPath = controller.getSessionLogPath(); const logPath = controller.getSessionLogPath();
if (logPath) { 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) => { ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
const logPath = controller.getPackageLogPath(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) => { ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
validateString(itemId, "itemId"); validateString(itemId, "itemId");
const logPath = controller.getItemLogPath(itemId); const logPath = controller.getItemLogPath(itemId);

138
src/main/support-bundle.ts Normal file
View File

@ -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();
}

217
src/main/trace-log.ts Normal file
View File

@ -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, unknown>): 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<SupportTraceConfig>;
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>): 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<string, unknown>
): 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 };
}

View File

@ -56,10 +56,16 @@ const api: ElectronApi = {
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT), quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_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<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId), openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId), openItemLog: (itemId: string): Promise<void> => 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<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),

View File

@ -1284,6 +1284,7 @@ export function App(): ReactElement {
const actionBusyRef = useRef(false); const actionBusyRef = useRef(false);
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
const dragOverRef = useRef(false); const dragOverRef = useRef(false);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const [openMenu, setOpenMenu] = useState<string | null>(null); const [openMenu, setOpenMenu] = useState<string | null>(null);
@ -1558,6 +1559,11 @@ export function App(): ReactElement {
let unsubClipboard: (() => void) | null = null; let unsubClipboard: (() => void) | null = null;
let unsubUpdateInstallProgress: (() => void) | null = null; let unsubUpdateInstallProgress: (() => void) | null = null;
void window.rd.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined); 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) => { void window.rd.getSnapshot().then((state) => {
if (!mountedRef.current) { if (!mountedRef.current) {
return; return;
@ -3391,6 +3397,49 @@ export function App(): ReactElement {
}); });
}; };
const onExportSupportBundle = async (): Promise<void> => {
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<void> => {
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<void> => {
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 => { const onMenuRestart = (): void => {
closeMenus(); closeMenus();
void window.rd.restart(); void window.rd.restart();
@ -3702,9 +3751,26 @@ export function App(): ReactElement {
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}>
<span>Log öffnen</span> <span>Log öffnen</span>
</button> </button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}>
<span>Audit-Log öffnen</span>
</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}>
<span>Session-Log öffnen</span> <span>Session-Log öffnen</span>
</button> </button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openTraceLog().catch(() => {}); }}>
<span>Trace-Log öffnen</span>
</button>
<div className="menu-separator" />
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}>
<span>Support-Bundle exportieren</span>
</button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}>
<span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span>
</button>
<button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}>
<span>Debug-Token rotieren</span>
</button>
<div className="menu-separator" />
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
<span>Suche Aktualisierungen</span> <span>Suche Aktualisierungen</span>
</button> </button>

View File

@ -36,10 +36,16 @@ export const IPC_CHANNELS = {
QUIT: "app:quit", QUIT: "app:quit",
EXPORT_BACKUP: "app:export-backup", EXPORT_BACKUP: "app:export-backup",
IMPORT_BACKUP: "app:import-backup", IMPORT_BACKUP: "app:import-backup",
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
OPEN_LOG: "app:open-log", OPEN_LOG: "app:open-log",
OPEN_AUDIT_LOG: "app:open-audit-log",
OPEN_SESSION_LOG: "app:open-session-log", OPEN_SESSION_LOG: "app:open-session-log",
OPEN_TRACE_LOG: "app:open-trace-log",
OPEN_PACKAGE_LOG: "app:open-package-log", OPEN_PACKAGE_LOG: "app:open-package-log",
OPEN_ITEM_LOG: "app:open-item-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_REALDEBRID_LOGIN: "app:open-realdebrid-login",
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",

View File

@ -10,6 +10,7 @@ import type {
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
SupportTraceConfig,
UiSnapshot, UiSnapshot,
UpdateCheckResult, UpdateCheckResult,
UpdateInstallProgress, UpdateInstallProgress,
@ -51,10 +52,16 @@ export interface ElectronApi {
quit: () => Promise<void>; quit: () => Promise<void>;
exportBackup: () => Promise<{ saved: boolean }>; exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; message: string }>; importBackup: () => Promise<{ restored: boolean; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
openLog: () => Promise<void>; openLog: () => Promise<void>;
openAuditLog: () => Promise<void>;
openSessionLog: () => Promise<void>; openSessionLog: () => Promise<void>;
openTraceLog: () => Promise<void>;
openPackageLog: (packageId: string) => Promise<void>; openPackageLog: (packageId: string) => Promise<void>;
openItemLog: (itemId: string) => Promise<void>; openItemLog: (itemId: string) => Promise<void>;
getTraceConfig: () => Promise<SupportTraceConfig>;
setTraceEnabled: (enabled: boolean, note?: string) => Promise<SupportTraceConfig>;
rotateDebugToken: () => Promise<{ path: string }>;
openRealDebridLogin: () => Promise<void>; openRealDebridLogin: () => Promise<void>;
openAllDebridLogin: () => Promise<void>; openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>; importBestDebridCookies: () => Promise<number>;

View File

@ -337,6 +337,14 @@ export interface SessionStats {
queuedDownloads: number; queuedDownloads: number;
} }
export interface SupportTraceConfig {
enabled: boolean;
includeMainLog: boolean;
includeAudit: boolean;
logDebugRequests: boolean;
updatedAt: string;
}
export interface HistoryEntry { export interface HistoryEntry {
id: string; id: string;
name: string; name: string;

View File

@ -3,6 +3,7 @@ import http from "node:http";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { once } from "node:events"; import { once } from "node:events";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/main/windows-host-diagnostics", () => ({ 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 { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
import { startDebugServer, stopDebugServer } from "../src/main/debug-server"; import { startDebugServer, stopDebugServer } from "../src/main/debug-server";
import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; 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 { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage"; 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 { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
import type { DownloadManager } from "../src/main/download-manager"; import type { DownloadManager } from "../src/main/download-manager";
import type { UiSnapshot } from "../src/shared/types"; import type { UiSnapshot } from "../src/shared/types";
@ -233,12 +235,17 @@ async function createFixture() {
} }
logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" }); logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" });
initTraceLog(baseDir);
setTraceEnabled(true, "test-fixture");
logTraceEvent("INFO", "support", "TRACE-EVENT", { scope: "fixture" });
initSessionLog(baseDir); initSessionLog(baseDir);
const sessionLogPath = getSessionLogPath(); const sessionLogPath = getSessionLogPath();
if (!sessionLogPath) { if (!sessionLogPath) {
throw new Error("session log path missing"); throw new Error("session log path missing");
} }
fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8"); fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8");
logger.info("TRACE-MAIN-LINE");
initPackageLogs(baseDir); initPackageLogs(baseDir);
initItemLogs(baseDir); initItemLogs(baseDir);
@ -273,6 +280,7 @@ async function createFixture() {
startDebugServer(manager, baseDir); startDebugServer(manager, baseDir);
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health?token=${token}`); await waitForReady(`${baseUrl}/health?token=${token}`);
await new Promise((resolve) => setTimeout(resolve, 300));
return { return {
baseUrl, baseUrl,
@ -286,6 +294,7 @@ afterEach(() => {
shutdownSessionLog(); shutdownSessionLog();
shutdownPackageLogs(); shutdownPackageLogs();
shutdownItemLogs(); shutdownItemLogs();
shutdownTraceLog();
shutdownAuditLog(); shutdownAuditLog();
while (tempDirs.length > 0) { while (tempDirs.length > 0) {
const dir = tempDirs.pop(); const dir = tempDirs.pop();
@ -315,6 +324,7 @@ describe("debug-server", () => {
expect(payload.selectedPackage?.name).toBe("server-package"); expect(payload.selectedPackage?.name).toBe("server-package");
expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE"); expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE");
expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-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?.session?.lines || []).join("\n")).toContain("SESSION-LINE");
expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE"); expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE");
expect(payload.accounts?.realDebrid?.configured).toBe(true); expect(payload.accounts?.realDebrid?.configured).toBe(true);
@ -339,6 +349,8 @@ describe("debug-server", () => {
expect(metaResponse.ok).toBe(true); expect(metaResponse.ok).toBe(true);
const metaPayload = await metaResponse.json() as Record<string, any>; const metaPayload = await metaResponse.json() as Record<string, any>;
expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath); 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 () => { 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<string, any>; const auditPayload = await auditResponse.json() as Record<string, any>;
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE"); 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<string, any>;
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&note=test`);
expect(traceConfigResponse.ok).toBe(true);
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
expect(traceConfigPayload.config?.enabled).toBe(false);
const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`); const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`);
expect(settingsResponse.ok).toBe(true); expect(settingsResponse.ok).toBe(true);
const settingsPayload = await settingsResponse.json() as Record<string, any>; const settingsPayload = await settingsResponse.json() as Record<string, any>;
@ -415,6 +438,24 @@ describe("debug-server", () => {
expect(historyPayload.entries?.[0]?.urlCount).toBe(1); 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 () => { it("rejects unauthenticated requests", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/status`); const response = await fetch(`${fixture.baseUrl}/status`);

53
tests/trace-log.test.ts Normal file
View File

@ -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);
});
});