diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 31f7364..1a28278 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -38,6 +38,7 @@ import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-ser import { encryptBackup, decryptBackup } from "./backup-crypto"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; +import { runStartupHealthCheck } from "./startup-health-check"; import { getDebugSetupCheck } from "./debug-setup"; import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; @@ -117,6 +118,38 @@ export class AppController { appVersion: APP_VERSION, runtimeDir: this.storagePaths.baseDir }); + // Startup Health-Check: surface problematic state early (missing download + // dir, low disk space, no provider configured, corrupted state file). + // Never blocks startup — findings go into the normal log + audit log so + // the user can diagnose issues before hitting them mid-download. + try { + const report = runStartupHealthCheck(this.settings, this.storagePaths); + if (report.errorCount > 0 || report.warnCount > 0) { + logger.warn(`Health-Check: ${report.errorCount} Fehler, ${report.warnCount} Warnungen, ${report.infoCount} Info`); + } else { + logger.info(`Health-Check: alles OK (${report.infoCount} Info)`); + } + for (const finding of report.findings) { + const line = finding.hint + ? `Health-Check [${finding.code}]: ${finding.message} — ${finding.hint}` + : `Health-Check [${finding.code}]: ${finding.message}`; + if (finding.severity === "ERROR") { + logger.error(line); + } else if (finding.severity === "WARN") { + logger.warn(line); + } else { + logger.info(line); + } + if (finding.severity !== "INFO") { + logAuditEvent(finding.severity, `Health-Check: ${finding.code}`, { + message: finding.message, + hint: finding.hint || "" + }); + } + } + } catch (err) { + logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`); + } startDebugServer(this.manager, this.storagePaths.baseDir); this.runtimeStatsTimer = setInterval(() => { this.manager.persistRuntimeStats(); diff --git a/src/main/startup-health-check.ts b/src/main/startup-health-check.ts new file mode 100644 index 0000000..cadc8cd --- /dev/null +++ b/src/main/startup-health-check.ts @@ -0,0 +1,220 @@ +import fs from "node:fs"; +import path from "node:path"; +import { AppSettings } from "../shared/types"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import { StoragePaths } from "./storage"; + +/** Startup Health-Check: runs once at app boot and surfaces potential problem + * states BEFORE the user hits them mid-download. + * + * Goals: + * - Warn on missing / unreachable download directory + * - Warn on low disk space (< 5 GB free) + * - Warn when no debrid provider is configured (app is effectively offline) + * - Warn when state file is suspiciously large (>50 MB → pruning recommended) + * + * Non-goals: blocking startup. The check only logs — the app continues. */ + +export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR"; + +export interface HealthCheckFinding { + severity: HealthCheckSeverity; + code: string; + message: string; + hint?: string; +} + +export interface HealthCheckReport { + findings: HealthCheckFinding[]; + errorCount: number; + warnCount: number; + infoCount: number; +} + +const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB +const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; // 50 MB + +function safeExists(p: string): boolean { + try { + return fs.existsSync(p); + } catch { + return false; + } +} + +function getFileSizeBytes(p: string): number { + try { + const stat = fs.statSync(p); + return stat.size; + } catch { + return 0; + } +} + +/** Attempt a tiny write-probe in the given directory. Returns true on + * success, false if the directory isn't writable. We write and immediately + * delete a uniquely-named temp file so we never leave garbage behind. */ +function isWritable(dir: string): boolean { + const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + try { + fs.writeFileSync(probe, "x", { encoding: "utf8" }); + fs.rmSync(probe, { force: true }); + return true; + } catch { + return false; + } +} + +/** Query free disk space for a given path. Returns null if unsupported or + * the query fails — callers treat null as "unknown" and skip the check. */ +function getFreeDiskSpaceBytes(target: string): number | null { + try { + // fs.statfsSync is available on Node 18.15+; on Windows it still maps to + // the underlying volume so it works for download dirs on any drive. + const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync; + if (typeof statfs !== "function") { + return null; + } + const result = statfs(target); + const bavail = BigInt(result.bavail); + const bsize = BigInt(result.bsize); + const free = bavail * bsize; + if (free > BigInt(Number.MAX_SAFE_INTEGER)) { + return Number.MAX_SAFE_INTEGER; + } + return Number(free); + } catch { + return null; + } +} + +function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } { + const providers: string[] = []; + if (settings.token?.trim() || settings.realDebridUseWebLogin) { + providers.push("Real-Debrid"); + } + if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) { + providers.push("AllDebrid"); + } + if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) { + providers.push("BestDebrid"); + } + if (settings.oneFichierApiKey?.trim()) { + providers.push("1Fichier"); + } + if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) { + providers.push("DDownload"); + } + if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) { + providers.push("LinkSnappy"); + } + const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); + if (dlKeys.length > 0) { + providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`); + } + const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || ""); + const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim()); + if (megaAccounts.length > 0) { + providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`); + } else if (legacyMegaConfigured) { + providers.push("Mega-Debrid"); + } + return { count: providers.length, providers }; +} + +/** Pure check function: takes inputs, returns findings. Kept side-effect-free + * so it's trivial to unit-test — the caller handles logging / persistence. */ +export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport { + const findings: HealthCheckFinding[] = []; + + // ── 1. Download directory ─────────────────────────────────────────────── + const outputDir = String(settings.outputDir || "").trim(); + if (!outputDir) { + findings.push({ + severity: "WARN", + code: "outputDir_missing", + message: "Kein Download-Ziel-Verzeichnis konfiguriert", + hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten." + }); + } else if (!safeExists(outputDir)) { + findings.push({ + severity: "WARN", + code: "outputDir_not_found", + message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`, + hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist." + }); + } else if (!isWritable(outputDir)) { + findings.push({ + severity: "ERROR", + code: "outputDir_not_writable", + message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`, + hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern." + }); + } else { + // Check available disk space only when the directory is actually usable + const freeBytes = getFreeDiskSpaceBytes(outputDir); + if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) { + const freeMb = Math.round(freeBytes / (1024 * 1024)); + findings.push({ + severity: "WARN", + code: "low_disk_space", + message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`, + hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen." + }); + } + } + + // ── 2. Provider-Credentials ───────────────────────────────────────────── + const { count, providers } = countConfiguredProviders(settings); + if (count === 0) { + findings.push({ + severity: "WARN", + code: "no_provider_configured", + message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren", + hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten." + }); + } else { + findings.push({ + severity: "INFO", + code: "providers_configured", + message: `Konfigurierte Provider: ${providers.join(", ")}` + }); + } + + // ── 3. State-File-Groesse ────────────────────────────────────────────── + if (safeExists(storagePaths.sessionFile)) { + const sizeBytes = getFileSizeBytes(storagePaths.sessionFile); + if (sizeBytes > LARGE_STATE_FILE_BYTES) { + const sizeMb = Math.round(sizeBytes / (1024 * 1024)); + findings.push({ + severity: "WARN", + code: "large_state_file", + message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`, + hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden." + }); + } + } + + // ── 4. Storage-Basis-Verzeichnis muss beschreibbar sein (fuer Logs) ──── + if (!safeExists(storagePaths.baseDir)) { + findings.push({ + severity: "ERROR", + code: "baseDir_missing", + message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`, + hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden." + }); + } else if (!isWritable(storagePaths.baseDir)) { + findings.push({ + severity: "ERROR", + code: "baseDir_not_writable", + message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`, + hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)." + }); + } + + const errorCount = findings.filter((f) => f.severity === "ERROR").length; + const warnCount = findings.filter((f) => f.severity === "WARN").length; + const infoCount = findings.filter((f) => f.severity === "INFO").length; + return { findings, errorCount, warnCount, infoCount }; +} diff --git a/tests/startup-health-check.test.ts b/tests/startup-health-check.test.ts new file mode 100644 index 0000000..482760f --- /dev/null +++ b/tests/startup-health-check.test.ts @@ -0,0 +1,137 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { createStoragePaths } from "../src/main/storage"; +import { runStartupHealthCheck } from "../src/main/startup-health-check"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +}); + +function makeTempBase(): { baseDir: string; outputDir: string; paths: ReturnType } { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-")); + tempDirs.push(baseDir); + const outputDir = path.join(baseDir, "downloads"); + fs.mkdirSync(outputDir, { recursive: true }); + return { + baseDir: path.join(baseDir, "runtime"), + outputDir, + paths: createStoragePaths(path.join(baseDir, "runtime")) + }; +} + +describe("runStartupHealthCheck", () => { + it("flags missing download directory", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + + const settings = { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(outputDir, "does-not-exist-subdir") + }; + const report = runStartupHealthCheck(settings, paths); + const codes = report.findings.map((f) => f.code); + expect(codes).toContain("outputDir_not_found"); + }); + + it("flags no-provider-configured when all credentials are empty", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + + const settings = { + ...defaultSettings(), + token: "", + megaLogin: "", + megaPassword: "", + megaCredentials: "", + allDebridToken: "", + bestToken: "", + oneFichierApiKey: "", + debridLinkApiKeys: "", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + const codes = report.findings.map((f) => f.code); + expect(codes).toContain("no_provider_configured"); + expect(report.warnCount).toBeGreaterThanOrEqual(1); + }); + + it("reports configured providers when at least one credential is set", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + + const settings = { + ...defaultSettings(), + token: "rd-token-here", + debridLinkApiKeys: "dl-key-a\ndl-key-b", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + const providersFinding = report.findings.find((f) => f.code === "providers_configured"); + expect(providersFinding).toBeDefined(); + expect(providersFinding?.message).toContain("Real-Debrid"); + expect(providersFinding?.message).toContain("Debrid-Link"); + expect(providersFinding?.message).toContain("2 Keys"); + }); + + it("flags large state files", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + // 60 MB dummy state file, threshold is 50 MB + fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0)); + + const settings = { + ...defaultSettings(), + token: "rd-token", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + const codes = report.findings.map((f) => f.code); + expect(codes).toContain("large_state_file"); + }); + + it("flags missing base dir as ERROR", () => { + const { outputDir, paths } = makeTempBase(); + // Intentionally DON'T create baseDir. + + const settings = { + ...defaultSettings(), + token: "rd-token", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + const codes = report.findings.map((f) => f.code); + expect(codes).toContain("baseDir_missing"); + expect(report.errorCount).toBeGreaterThanOrEqual(1); + }); + + it("passes cleanly when everything is healthy", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + + const settings = { + ...defaultSettings(), + token: "rd-token-here", + outputDir + }; + const report = runStartupHealthCheck(settings, paths); + expect(report.errorCount).toBe(0); + const codes = report.findings.map((f) => f.code); + expect(codes).not.toContain("outputDir_not_found"); + expect(codes).not.toContain("outputDir_not_writable"); + expect(codes).not.toContain("no_provider_configured"); + expect(codes).not.toContain("baseDir_missing"); + expect(codes).not.toContain("baseDir_not_writable"); + }); +});