diff --git a/README.md b/README.md index 3ce092b..eb0741a 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,8 @@ Runtime files are stored in Electron's `userData` directory, including: - `package-logs/package_*.txt` - `item-logs/item_*.txt` +`audit.log` and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically. + ### Remote debug server For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails. @@ -204,12 +206,15 @@ 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. +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. By default, the support trace now auto-disables again after 2 hours so it does not stay enabled forever by accident. + +The app menu under `Hilfe` also includes a `Debug-Setup prüfen` action. It verifies the current host/port/token/AI-manifest/trace setup locally and shows the exact local and remote URLs that support tooling can use. Available endpoints after restart: - `GET /health` - `GET /meta` +- `GET /debug/setup` - `GET /host/diagnostics` - `GET /status` - `GET /settings` @@ -226,7 +231,7 @@ Available endpoints after restart: - `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 /trace/config?enable=1¬e=support&durationMinutes=120` - `GET /support/bundle` - `GET /diagnostics?package=Release&lines=150` @@ -243,9 +248,10 @@ Invoke-RestMethod "http://SERVER:9868/settings?token=YOUR_TOKEN" 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/debug/setup?token=YOUR_TOKEN" 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/trace/config?token=YOUR_TOKEN&enable=1¬e=support&durationMinutes=120" 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" diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index a8de449..fd66e60 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -37,10 +37,11 @@ import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } fro import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { encryptBackup, decryptBackup } from "./backup-crypto"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; +import { getDebugSetupCheck } from "./debug-setup"; 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"; +import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -198,13 +199,17 @@ export class AppController { return rotated; } + public getDebugSetupCheck(): DebugSetupCheckResult { + return getDebugSetupCheck(this.storagePaths.baseDir); + } + 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); + public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig { + const next = setTraceEnabled(enabled, note, durationMs); this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note }); return next; } diff --git a/src/main/audit-log.ts b/src/main/audit-log.ts index 93557ad..56f3004 100644 --- a/src/main/audit-log.ts +++ b/src/main/audit-log.ts @@ -3,6 +3,9 @@ import path from "node:path"; type AuditLevel = "INFO" | "WARN" | "ERROR"; +const AUDIT_LOG_MAX_FILE_BYTES = Number(process.env.RD_AUDIT_LOG_MAX_BYTES || 10 * 1024 * 1024); +const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30); + let auditLogPath: string | null = null; function sanitizeFieldValue(value: unknown): string { @@ -32,10 +35,46 @@ function formatFields(fields?: Record): string { return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; } +function rotateIfNeeded(filePath: string): void { + try { + const stat = fs.statSync(filePath); + if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + // ignore + } + fs.renameSync(filePath, backup); + } catch { + // ignore + } +} + +function cleanupOldBackup(filePath: string): void { + const backup = `${filePath}.old`; + try { + const stat = fs.statSync(backup); + const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + if (stat.mtimeMs < cutoff) { + fs.rmSync(backup, { force: true }); + } + } catch { + // ignore + } +} + export function initAuditLog(baseDir: string): void { auditLogPath = path.join(baseDir, "audit.log"); try { fs.mkdirSync(path.dirname(auditLogPath), { recursive: true }); + cleanupOldBackup(auditLogPath); + if (!fs.existsSync(auditLogPath)) { + fs.writeFileSync(auditLogPath, "", "utf8"); + } + rotateIfNeeded(auditLogPath); if (!fs.existsSync(auditLogPath)) { fs.writeFileSync(auditLogPath, "", "utf8"); } @@ -50,6 +89,10 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor return; } try { + rotateIfNeeded(auditLogPath); + if (!fs.existsSync(auditLogPath)) { + fs.writeFileSync(auditLogPath, "", "utf8"); + } fs.appendFileSync( auditLogPath, `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`, diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 47ed2d8..c6ccad7 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -4,6 +4,7 @@ import path from "node:path"; import crypto from "node:crypto"; import { APP_VERSION } from "./constants"; import { getAuditLogPath } from "./audit-log"; +import { getDebugSetupCheck } from "./debug-setup"; import { logger, getLogFilePath } from "./logger"; import { getItemLogPath as getPersistedItemLogPath } from "./item-log"; import { getSessionLogPath } from "./session-log"; @@ -11,7 +12,7 @@ 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 { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log"; import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import type { DownloadManager } from "./download-manager"; import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; @@ -31,6 +32,7 @@ type DebugEndpointDescriptor = { const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { method: "GET", path: "/health", description: "Basic health, uptime, and memory information." }, { method: "GET", path: "/meta", description: "Lists runtime metadata and all available endpoints." }, + { method: "GET", path: "/debug/setup", description: "Checks whether the local debug setup is configured for support." }, { method: "GET", path: "/host/diagnostics", description: "Returns Windows host crash and dump diagnostics." }, { 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." }, @@ -39,7 +41,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { 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: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", 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." }, @@ -236,6 +238,7 @@ 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 /debug/setup to quickly verify whether token, host, manifest, and trace files are in a good support state.", "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." ], @@ -270,6 +273,7 @@ function buildAiManifest(baseDir: string): Record { remoteBaseUrlTemplate: `http://:${bindPort}`, remoteHostHint }, + setupCheckEndpoint: "/debug/setup", askUserFor: [ "Server IP or DNS name, if remote access is required and not already known." ], @@ -470,6 +474,9 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi traceConfig: getTraceConfigPath(), traceLog: getTraceLogPath() }, + supportChecks: { + setup: "/debug/setup" + }, logPaths: { main: getLogFilePath(), audit: getAuditLogPath(), @@ -481,6 +488,11 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/debug/setup") { + jsonResponse(res, 200, getDebugSetupCheck(runtimeBaseDir)); + return; + } + if (pathname === "/host/diagnostics") { jsonResponse(res, 200, getWindowsHostDiagnostics()); return; @@ -554,11 +566,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi 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(); + const durationMinutesRaw = Number(url.searchParams.get("durationMinutes") || "120"); + const durationMinutes = Number.isFinite(durationMinutesRaw) && durationMinutesRaw > 0 + ? Math.min(Math.floor(durationMinutesRaw), 24 * 60) + : 120; + let config = getTraceConfig(); + if (enabled !== null) { + config = setTraceEnabled(enabled, note, durationMinutes * 60 * 1000); + } + const configPatch = { ...patch }; + delete configPatch.enabled; + if (Object.keys(configPatch).length > 0) { + config = updateTraceConfig(configPatch); + } if (Object.keys(patch).length > 0) { - logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note }); + logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note, durationMinutes }); } jsonResponse(res, 200, { path: getTraceConfigPath(), @@ -797,7 +819,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi debugServer: { host: bindHost, port: bindPort - } + }, + setup: getDebugSetupCheck(runtimeBaseDir) }, status: buildStatusPayload(snapshot), settings: buildRedactedSettingsPayload(readSupportSettings()), diff --git a/src/main/debug-setup.ts b/src/main/debug-setup.ts new file mode 100644 index 0000000..e431ca3 --- /dev/null +++ b/src/main/debug-setup.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; + +const DEFAULT_PORT = 9868; +const DEFAULT_HOST = "127.0.0.1"; +const AI_MANIFEST_FILE = "debug_ai_manifest.json"; + +function readToken(baseDir: string): string { + try { + return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim(); + } catch { + return ""; + } +} + +function readPort(baseDir: string): number { + try { + const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()); + if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) { + return raw; + } + } catch { + // ignore + } + return DEFAULT_PORT; +} + +function readHost(baseDir: string): string { + try { + const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim(); + if (!raw) { + return DEFAULT_HOST; + } + if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) { + return raw; + } + if (/^[a-z0-9.-]+$/i.test(raw)) { + return raw; + } + } catch { + // ignore + } + return DEFAULT_HOST; +} + +function readTraceConfig(baseDir: string): SupportTraceConfig { + const fallback: SupportTraceConfig = { + enabled: false, + includeMainLog: true, + includeAudit: true, + logDebugRequests: true, + autoDisableAt: null, + updatedAt: new Date(0).toISOString() + }; + try { + const filePath = path.join(baseDir, "trace_config.json"); + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial; + return { + enabled: Boolean(parsed.enabled), + includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog), + includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit), + logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests), + autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null, + updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt + }; + } catch { + return fallback; + } +} + +export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult { + const host = readHost(baseDir); + const port = readPort(baseDir); + const token = readToken(baseDir); + const tokenPath = path.join(baseDir, "debug_token.txt"); + const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE); + const traceConfigPath = path.join(baseDir, "trace_config.json"); + const traceLogPath = path.join(baseDir, "trace.log"); + const traceConfig = readTraceConfig(baseDir); + const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host); + const warnings: string[] = []; + const notes: string[] = []; + + if (!token) { + warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht."); + } + if (localOnly) { + warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen."); + } else { + notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein."); + } + if (!fs.existsSync(aiManifestPath)) { + warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird."); + } + if (!fs.existsSync(traceConfigPath)) { + warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert."); + } + if (traceConfig.enabled && !traceConfig.autoDisableAt) { + warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird."); + } + if (traceConfig.enabled && traceConfig.autoDisableAt) { + notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`); + } + notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen."); + + return { + enabled: Boolean(token), + host, + port, + localOnly, + tokenConfigured: Boolean(token), + tokenPath, + aiManifestPath, + aiManifestPresent: fs.existsSync(aiManifestPath), + traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null, + traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null, + traceEnabled: traceConfig.enabled, + traceAutoDisableAt: traceConfig.autoDisableAt, + warnings, + notes, + localUrls: { + health: `http://127.0.0.1:${port}/health?token=${token || ""}`, + meta: `http://127.0.0.1:${port}/meta?token=${token || ""}`, + diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || ""}` + }, + remoteUrlTemplates: { + health: `http://:${port}/health?token=${token || ""}`, + meta: `http://:${port}/meta?token=${token || ""}`, + diagnostics: `http://:${port}/diagnostics?token=${token || ""}` + } + }; +} diff --git a/src/main/main.ts b/src/main/main.ts index e8a59d6..ed10fd6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -538,16 +538,21 @@ function registerIpcHandlers(): void { } }); + ipcMain.handle(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck()); + ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig()); - ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string) => { + ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => { if (typeof enabled !== "boolean") { throw new Error("enabled muss ein Boolean sein"); } if (note !== undefined) { validateString(note, "note"); } - return controller.setTraceEnabled(enabled, note); + if (durationMinutes !== undefined && (!Number.isFinite(durationMinutes) || durationMinutes <= 0)) { + throw new Error("durationMinutes muss eine positive Zahl sein"); + } + return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined); }); ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => { diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index ac20257..504432a 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -3,6 +3,7 @@ import path from "node:path"; import AdmZip from "adm-zip"; import { APP_VERSION } from "./constants"; import { getAuditLogPath } from "./audit-log"; +import { getDebugSetupCheck } from "./debug-setup"; import { getLogFilePath } from "./logger"; import { getPackageLogPath } from "./package-log"; import { getSessionLogPath } from "./session-log"; @@ -89,6 +90,7 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime } }); + addJson(zip, "overview/debug-setup.json", getDebugSetupCheck(baseDir)); addJson(zip, "overview/history.json", { total: history.length, entries: history.map((entry) => summarizeHistoryEntry(entry)) @@ -115,8 +117,10 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old"); addFileIfExists(zip, getAuditLogPath(), "logs/audit.log"); + addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old"); addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); + addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); addDirectoryIfExists(zip, path.join(baseDir, "package-logs"), "logs/package-logs"); diff --git a/src/main/trace-log.ts b/src/main/trace-log.ts index 5779b58..8b8fe99 100644 --- a/src/main/trace-log.ts +++ b/src/main/trace-log.ts @@ -7,12 +7,16 @@ type TraceLevel = "INFO" | "WARN" | "ERROR"; const TRACE_LOG_FLUSH_INTERVAL_MS = 200; const TRACE_CONFIG_FILE = "trace_config.json"; +const TRACE_LOG_MAX_FILE_BYTES = Number(process.env.RD_TRACE_LOG_MAX_BYTES || 10 * 1024 * 1024); +const TRACE_LOG_RETENTION_DAYS = Number(process.env.RD_TRACE_LOG_RETENTION_DAYS || 30); +const TRACE_DEFAULT_AUTO_DISABLE_MS = Number(process.env.RD_TRACE_AUTO_DISABLE_MS || 2 * 60 * 60 * 1000); const DEFAULT_TRACE_CONFIG: SupportTraceConfig = { enabled: false, includeMainLog: true, includeAudit: true, logDebugRequests: true, + autoDisableAt: null, updatedAt: new Date(0).toISOString() }; @@ -21,6 +25,7 @@ let traceConfigPath: string | null = null; let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG }; let pendingLines: string[] = []; let flushTimer: NodeJS.Timeout | null = null; +let autoDisableTimer: NodeJS.Timeout | null = null; function sanitizeFieldValue(value: unknown): string { if (value === undefined || value === null) { @@ -62,6 +67,37 @@ function flushPending(): void { } } +function rotateIfNeeded(filePath: string): void { + try { + const stat = fs.statSync(filePath); + if (stat.size < TRACE_LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + // ignore + } + fs.renameSync(filePath, backup); + } catch { + // ignore + } +} + +function cleanupOldBackup(filePath: string): void { + const backup = `${filePath}.old`; + try { + const stat = fs.statSync(backup); + const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + if (stat.mtimeMs < cutoff) { + fs.rmSync(backup, { force: true }); + } + } catch { + // ignore + } +} + function scheduleFlush(): void { if (flushTimer) { return; @@ -76,6 +112,14 @@ function appendTraceLine(line: string): void { if (!traceLogPath) { return; } + rotateIfNeeded(traceLogPath); + if (!fs.existsSync(traceLogPath)) { + try { + fs.writeFileSync(traceLogPath, "", "utf8"); + } catch { + return; + } + } pendingLines.push(line); scheduleFlush(); } @@ -90,6 +134,9 @@ function normalizeTraceConfig(raw: unknown): SupportTraceConfig { 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), + autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim() + ? value.autoDisableAt + : null, updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim() ? value.updatedAt : DEFAULT_TRACE_CONFIG.updatedAt @@ -126,11 +173,58 @@ const mainLogListener = (line: string): void => { appendTraceLine(line); }; +function clearAutoDisableTimer(): void { + if (autoDisableTimer) { + clearTimeout(autoDisableTimer); + autoDisableTimer = null; + } +} + +function disableTraceDueToExpiry(): void { + clearAutoDisableTimer(); + if (!traceConfig.enabled) { + return; + } + traceConfig = normalizeTraceConfig({ + ...traceConfig, + enabled: false, + autoDisableAt: null, + updatedAt: new Date().toISOString() + }); + persistTraceConfig(); + appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`); +} + +function scheduleAutoDisable(): void { + clearAutoDisableTimer(); + if (!traceConfig.enabled || !traceConfig.autoDisableAt) { + return; + } + const until = Date.parse(traceConfig.autoDisableAt); + if (!Number.isFinite(until)) { + return; + } + const remainingMs = until - Date.now(); + if (remainingMs <= 0) { + disableTraceDueToExpiry(); + return; + } + autoDisableTimer = setTimeout(() => { + autoDisableTimer = null; + disableTraceDueToExpiry(); + }, Math.min(remainingMs, 2_147_483_647)); +} + 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 }); + cleanupOldBackup(traceLogPath); + if (!fs.existsSync(traceLogPath)) { + fs.writeFileSync(traceLogPath, "", "utf8"); + } + rotateIfNeeded(traceLogPath); if (!fs.existsSync(traceLogPath)) { fs.writeFileSync(traceLogPath, "", "utf8"); } @@ -144,6 +238,7 @@ export function initTraceLog(baseDir: string): void { return; } addLogListener(mainLogListener); + scheduleAutoDisable(); } export function getTraceLogPath(): string | null { @@ -171,13 +266,17 @@ export function updateTraceConfig(patch: Partial): SupportTr updatedAt: new Date().toISOString() }); persistTraceConfig(); + scheduleAutoDisable(); 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`); +export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig { + const autoDisableAt = enabled && durationMs > 0 + ? new Date(Date.now() + durationMs).toISOString() + : null; + const next = updateTraceConfig({ enabled, autoDisableAt }); + appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`); return next; } @@ -198,6 +297,7 @@ export function logTraceEvent( export function shutdownTraceLog(): void { removeLogListener(mainLogListener); + clearAutoDisableTimer(); if (!traceLogPath) { return; } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 69f304f..f7e125e 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -63,8 +63,9 @@ const api: ElectronApi = { 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), + getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK), getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG), - setTraceEnabled: (enabled: boolean, note?: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note), + setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes), 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), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c59f083..45617a2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5,6 +5,7 @@ import type { AppSettings, AppTheme, BandwidthScheduleEntry, + DebugSetupCheckResult, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider, @@ -49,8 +50,10 @@ interface ConfirmPromptState { title: string; message: string; confirmLabel: string; + cancelLabel?: string; danger?: boolean; details?: string; + detailsLabel?: string; } interface ContextMenuState { @@ -137,6 +140,44 @@ interface ConfiguredAccountEntry { debridLinkKeys: DebridLinkAccountKeyEntry[]; } +function buildDebugSetupDetails(setup: DebugSetupCheckResult): string { + const lines: string[] = [ + `Debug-Server aktiv: ${setup.enabled ? "ja" : "nein"}`, + `Host: ${setup.host}`, + `Port: ${setup.port}`, + `Token-Datei: ${setup.tokenPath}`, + `KI-Manifest: ${setup.aiManifestPresent ? "vorhanden" : "fehlt"} (${setup.aiManifestPath})`, + `Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`, + `Trace-Auto-Ende: ${setup.traceAutoDisableAt || "nicht gesetzt"}`, + "", + "Lokale URLs:", + setup.localUrls.health, + setup.localUrls.meta, + setup.localUrls.diagnostics, + "", + "Remote-Vorlagen:", + setup.remoteUrlTemplates.health, + setup.remoteUrlTemplates.meta, + setup.remoteUrlTemplates.diagnostics + ]; + + if (setup.warnings.length > 0) { + lines.push("", "Warnungen:"); + for (const warning of setup.warnings) { + lines.push(`- ${warning}`); + } + } + + if (setup.notes.length > 0) { + lines.push("", "Hinweise:"); + for (const note of setup.notes) { + lines.push(`- ${note}`); + } + } + + return lines.join("\n"); +} + const ACCOUNT_OPTIONS: AccountOption[] = [ { kind: "realdebrid-api", @@ -3413,14 +3454,34 @@ export function App(): ReactElement { closeMenus(); const nextEnabled = !supportTraceEnabled; await performQuickAction(async () => { - const result = await window.rd.setTraceEnabled(nextEnabled, "UI support toggle"); + const result = await window.rd.setTraceEnabled(nextEnabled, "UI support toggle", 120); setSupportTraceEnabled(result.enabled); - showToast(result.enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", 2400); + showToast(result.enabled ? "Support-Trace für 2 Stunden aktiviert" : "Support-Trace deaktiviert", 2600); }, (error) => { showToast(`Support-Trace fehlgeschlagen: ${String(error)}`, 2800); }); }; + const onRunDebugSetupCheck = async (): Promise => { + closeMenus(); + try { + const setup = await window.rd.getDebugSetupCheck(); + const warningText = setup.warnings.length > 0 ? `Warnungen: ${setup.warnings.length}` : "Keine akuten Warnungen"; + const reachabilityText = setup.localOnly ? "Nur lokal gebunden" : "Remote-fähig konfiguriert"; + const details = buildDebugSetupDetails(setup); + await askConfirmPrompt({ + title: "Debug-Setup prüfen", + message: `${warningText}\n${reachabilityText}\nHost: ${setup.host}:${setup.port}`, + confirmLabel: "Schließen", + cancelLabel: "Schließen", + details, + detailsLabel: "Details anzeigen" + }); + } catch (error) { + showToast(`Debug-Setup-Check fehlgeschlagen: ${String(error)}`, 3000); + } + }; + const onRotateDebugToken = async (): Promise => { closeMenus(); const confirmed = await askConfirmPrompt({ @@ -3767,6 +3828,9 @@ export function App(): ReactElement { + @@ -4855,12 +4919,12 @@ export function App(): ReactElement {

{confirmPrompt.message}

{confirmPrompt.details && (
- Changelog anzeigen + {confirmPrompt.detailsLabel || "Details anzeigen"}
{confirmPrompt.details}
)}
- +