diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 894faf3..9a402a6 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -661,14 +661,14 @@ public async checkDebridAccounts(): Promise { return encryptBackup(JSON.stringify(payloadObj)); } - public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { + public async exportSupportBundle(): Promise<{ 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, { hostDiagnosticsMode: "cached" }), + buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }), defaultFileName: getSupportBundleDefaultFileName() }; } diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 905e625..e0fb34b 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -837,12 +837,17 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi 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); + buildSupportBundle(manager, runtimeBaseDir) + .then((body) => { + logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", { + fileName, + sizeBytes: body.length + }); + binaryResponse(res, 200, body, "application/zip", fileName); + }) + .catch((error) => { + jsonResponse(res, 500, { error: String((error as { message?: string })?.message || error) }); + }); return; } diff --git a/src/main/main.ts b/src/main/main.ts index 4930521..2fcd947 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -592,7 +592,7 @@ function registerIpcHandlers(): void { if (result.canceled || !result.filePath) { return { saved: false }; } - const exported = controller.exportSupportBundle(); + const exported = await controller.exportSupportBundle(); await fs.promises.writeFile(result.filePath, exported.buffer); return { saved: true, filePath: result.filePath }; }); diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index cf18f25..75d07c6 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import { promises as fsp } from "node:fs"; import path from "node:path"; import AdmZip from "adm-zip"; import { APP_VERSION } from "./constants"; @@ -20,9 +20,9 @@ import type { DownloadManager } from "./download-manager"; const AI_MANIFEST_FILE = "debug_ai_manifest.json"; -function safeReadJson(filePath: string): unknown { +async function safeReadJson(filePath: string): Promise { try { - return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown; } catch { return null; } @@ -32,42 +32,50 @@ 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)) { +async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise { + if (!sourcePath) { return; } - zip.addLocalFile(sourcePath, path.posix.dirname(zipPath), path.posix.basename(zipPath)); + try { + const buffer = await fsp.readFile(sourcePath); + zip.addFile(zipPath, buffer); + } catch { + } } -function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): void { - if (!fs.existsSync(dirPath)) { +async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise { + let entries; + try { + entries = await fsp.readdir(dirPath, { withFileTypes: true }); + } catch { 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); + await addDirectoryIfExists(zip, fullPath, zipPath); continue; } - zip.addLocalFile(fullPath, path.posix.dirname(zipPath), path.posix.basename(zipPath)); + await addFileIfExists(zip, fullPath, zipPath); } } -function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number { - if (!fs.existsSync(dirPath)) { +async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise { + let entries; + try { + entries = await fsp.readdir(dirPath, { withFileTypes: true }); + } catch { return 0; } const cutoff = Date.now() - maxAgeMs; let added = 0; - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; const fullPath = path.join(dirPath, entry.name); try { - if (fs.statSync(fullPath).mtimeMs >= cutoff) { - zip.addLocalFile(fullPath, zipRoot, entry.name); + if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) { + await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name)); added += 1; } } catch { } @@ -127,7 +135,7 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown { return getWindowsHostDiagnostics(); } -export function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Buffer { +export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise { const zip = new AdmZip(); const hostDiagnosticsMode = options.hostDiagnosticsMode || "full"; const storagePaths = createStoragePaths(baseDir); @@ -175,39 +183,39 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op const recentErrors = getRecentErrors(); addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }); - 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, getTraceConfigPath(), "runtime/trace_config.json"); + await addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`); + await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); + await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt"); + await 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, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old"); - addFileIfExists(zip, getRenameLogPath(), "logs/rename.log"); - addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old"); - addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt"); - addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); - addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); - addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); - addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log"); - addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old"); - addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log"); - addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old"); + await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); + await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old"); + await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log"); + await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old"); + await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log"); + await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old"); + await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt"); + await addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); + await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); + await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); + await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log"); + await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old"); + await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log"); + await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old"); const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000; - addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); - addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); - addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); + await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); + await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); + await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); for (const packageId of packageIds) { - addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`); + await 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`); + await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`); } - const aiManifest = safeReadJson(path.join(baseDir, AI_MANIFEST_FILE)); + const aiManifest = await safeReadJson(path.join(baseDir, AI_MANIFEST_FILE)); if (aiManifest) { addJson(zip, "overview/ai-manifest.json", aiManifest); } diff --git a/tests/support-bundle.test.ts b/tests/support-bundle.test.ts new file mode 100644 index 0000000..91307dd --- /dev/null +++ b/tests/support-bundle.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import AdmZip from "adm-zip"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildSupportBundle } from "../src/main/support-bundle"; +import type { DownloadManager } from "../src/main/download-manager"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { } + } +}); + +function fakeManager(): DownloadManager { + const snapshot = { + stats: {}, + session: { packages: {}, items: {}, packageOrder: [] }, + speedText: "", + etaText: "", + canStart: false, + canStop: false, + canPause: false + }; + return { + getSnapshot: () => snapshot, + getPackageLogPath: () => null, + getItemLogPath: () => null + } as unknown as DownloadManager; +} + +describe("buildSupportBundle (async, non-blocking)", () => { + it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); + tempDirs.push(root); + fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8"); + + const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" }); + expect(promise).toBeInstanceOf(Promise); + + const buffer = await promise; + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.length).toBeGreaterThan(0); + + const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName); + expect(entries).toContain("overview/meta.json"); + expect(entries).toContain("overview/settings.json"); + expect(entries).toContain("runtime/debug_host.txt"); + + const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt"); + expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test"); + }); + + it("does not block the event loop while building (a concurrent timer still fires)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); + tempDirs.push(root); + + let timerFired = false; + const timer = setTimeout(() => { timerFired = true; }, 0); + await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" }); + clearTimeout(timer); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(timerFired).toBe(true); + }); +});