From 251c41ca6c9b2aec4efea5b3d3cbdd3543d68ee5 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 1 Jun 2026 13:14:55 +0200 Subject: [PATCH] Renaming-Logging: lueckenloses Desktop-Protokoll pro Sitzung + Post-Rename-Verifikation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Goal: bei kuenftigen Renaming-Problemen eine vollstaendige, sofort auffindbare Uebersicht — JEDER Umbenenn-/Verschiebevorgang protokolliert UND danach verifiziert (liegt die Datei wirklich unter dem Zielnamen? Quelle weg? richtige Schreibweise?). - NEU desktop-rename-log.ts: pro Sitzung /Downloader-Log/rename-session_.txt; Ordner selbstheilend (mkdir recursive vor jedem Write -> auch nach Loeschung zur Laufzeit sofort wieder da). Synchroner Append, Schreibfehler verschluckt (bricht nie einen Download). - verifyRename (sync) + verifyRenameAsync (Hot-Path): prueft Ziel-Existenz, echten On-Disk-Namen (case-genau via readdir), Quell-Abwesenheit; Level INFO/WARN/ERROR. Nutzt denselben \?\-Long- Path-Prefix wie der echte Rename (sonst falsche Urteile auf langen Scene-Pfaden). - download-manager: renamePathWithExdevFallback = verifizierter Wrapper um die unveraenderte Raw-Logik (deckt alle Media-Renames ab) + 3 Sync-Sites (startup-Dedup, Deobfuskation, Suffix-Fix) via logVerifiedRenameSync; logRenameProcess spiegelt ins Desktop-Log. - app-controller init/shutdown (getPath("desktop") gegen Startup-Crash abgesichert); support-bundle packt das Log mit ein. Adversarialer Review-Workflow (4 Linsen) fand + behoben: Long-Path-Verify-Bug (falsches OK maskiert halb-fertigen Move), readdir-Fehler-False-OK, sync-I/O im Hot-Path, getPath-Guard, Test-Temp-Cleanup. tsc 9 (Baseline), 663 Tests (+7 neue), Build gruen. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/app-controller.ts | 19 ++ src/main/desktop-rename-log.ts | 318 +++++++++++++++++++++++++++++++ src/main/download-manager.ts | 106 ++++++++++- src/main/support-bundle.ts | 2 + tasks/lessons.md | 22 +++ tests/desktop-rename-log.test.ts | 129 +++++++++++++ 6 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 src/main/desktop-rename-log.ts create mode 100644 tests/desktop-rename-log.test.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 1ba98fd..5c4b152 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -45,6 +45,7 @@ import { runStartupHealthCheck } from "./startup-health-check"; import { getDebugSetupCheck } from "./debug-setup"; import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; +import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog } from "./desktop-rename-log"; import { buildAccountSummary, diffAccountSummary } from "./support-data"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; @@ -91,6 +92,19 @@ export class AppController { initAuditLog(this.storagePaths.baseDir); initAccountRotationLog(this.storagePaths.baseDir); initRenameLog(this.storagePaths.baseDir); + // Session-eigenes Rename-Protokoll auf dem Desktop (eigener Ordner Downloader-Log, + // selbstheilend) — luekenlose Uebersicht + Post-Rename-Verifikation fuer kuenftige + // Renaming-Diagnose, getrennt von den userData-Logs. app.getPath("desktop") wird vom + // Aufrufer ausgewertet (ausserhalb der modul-internen try/catch) — der "desktop"- + // Known-Folder kann in Headless-/Service-Account-Setups scheitern, daher hier gegen + // einen Startup-Crash absichern (initDesktopRenameLog behandelt null als no-op). + let desktopDir: string | null = null; + try { + desktopDir = app.getPath("desktop"); + } catch { + desktopDir = null; + } + initDesktopRenameLog(desktopDir); initTraceLog(this.storagePaths.baseDir); this.settings = loadSettings(this.storagePaths); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); @@ -237,6 +251,10 @@ export class AppController { return getRenameLogPath(); } + public getDesktopRenameLogPath(): string | null { + return getDesktopRenameLogPath(); + } + public getTraceLogPath(): string | null { return getTraceLogPath(); } @@ -756,6 +774,7 @@ public async checkDebridAccounts(): Promise { shutdownPackageLogs(); shutdownItemLogs(); shutdownRenameLog(); + shutdownDesktopRenameLog(); this.audit("INFO", "App beendet"); shutdownTraceLog(); shutdownAccountRotationLog(); diff --git a/src/main/desktop-rename-log.ts b/src/main/desktop-rename-log.ts new file mode 100644 index 0000000..c168c70 --- /dev/null +++ b/src/main/desktop-rename-log.ts @@ -0,0 +1,318 @@ +import fs from "node:fs"; +import path from "node:path"; +import { logTimestamp } from "./log-timestamp"; + +/** + * Session-eigenes Rename-Protokoll auf dem DESKTOP des Nutzers. + * + * Ziel (User-Anforderung): bei zukuenftigen Renaming-Problemen eine luekenlose, + * sofort auffindbare Uebersicht haben — JEDER Umbenenn-/Verschiebevorgang wird + * protokolliert UND danach verifiziert (liegt die Datei wirklich unter dem + * Zielnamen auf der Platte? ist die Quelle weg?). Nur weil fs.rename "ok" meldet, + * heisst das nicht, dass das Ergebnis stimmt (Gross-/Kleinschreibung, Unicode- + * Normalisierung, halb-fertiger EXDEV-Copy ohne geloeschte Quelle, ...). + * + * - Pro Programm-Sitzung eine eigene Datei: /Downloader-Log/rename-session_.txt + * - Der Ordner wird beim Start angelegt UND vor JEDEM Schreibvorgang selbstheilend + * neu angelegt (mkdir recursive) — wird er zur Laufzeit geloescht, ist er beim + * naechsten Rename sofort wieder da, inkl. neu geschriebenem Session-Header. + * - Synchroner Append (wie rename-log.ts), kein gepufferter Flush: Renames sind + * selten genug, und so gibt es kein "geloescht-waehrend-Flush"-Zeitfenster. + * - Schlaegt das Logging fehl, wird der Fehler verschluckt — Logging darf einen + * Download niemals abbrechen. + */ + +type DesktopRenameLevel = "INFO" | "WARN" | "ERROR"; + +const FOLDER_NAME = "Downloader-Log"; + +let logDir: string | null = null; +let logFilePath: string | null = null; +let sessionHeader = ""; + +/** Lokaler Zeitstempel fuer den DATEINAMEN (keine Doppelpunkte — unter Windows + * in Dateinamen verboten): YYYY-MM-DD_HH-MM-SS in lokaler Zeit. */ +function fileTimestamp(date: Date = new Date()): string { + const pad = (value: number): string => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_` + + `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`; +} + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +/** Stellt sicher, dass Ordner UND Session-Datei existieren (selbstheilend, auch + * wenn beides zur Laufzeit geloescht wurde). Gibt false zurueck, wenn das + * Logging nicht initialisiert ist oder das Anlegen scheitert. */ +function ensureWritable(): boolean { + if (!logDir || !logFilePath) { + return false; + } + try { + fs.mkdirSync(logDir, { recursive: true }); + if (!fs.existsSync(logFilePath)) { + fs.writeFileSync(logFilePath, sessionHeader, "utf8"); + } + return true; + } catch { + return false; + } +} + +/** Initialisiert das Desktop-Rename-Log fuer diese Sitzung. `desktopDir` ist der + * Desktop-Pfad (app.getPath("desktop")). Faellt still auf no-op zurueck, wenn der + * Pfad fehlt oder nicht beschreibbar ist. */ +export function initDesktopRenameLog(desktopDir: string | null | undefined): void { + try { + const base = String(desktopDir || "").trim(); + if (!base) { + logDir = null; + logFilePath = null; + return; + } + logDir = path.join(base, FOLDER_NAME); + logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`); + sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n` + + "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n" + + "und verifiziert nach jedem Vorgang, ob die Datei wirklich unter dem Zielnamen auf der\n" + + "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n"; + fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(logFilePath, sessionHeader, "utf8"); + } catch { + logDir = null; + logFilePath = null; + } +} + +/** Schreibt eine Zeile ins Desktop-Rename-Log. Tut nichts, wenn nicht + * initialisiert; verschluckt jeden Schreibfehler (darf nie einen Download + * abbrechen). */ +export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record): void { + if (!ensureWritable() || !logFilePath) { + return; + } + try { + fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8"); + } catch { + // Logging darf einen Download niemals abbrechen. + } +} + +export function getDesktopRenameLogPath(): string | null { + if (!logFilePath) { + return null; + } + try { + return fs.existsSync(logFilePath) ? logFilePath : null; + } catch { + return null; + } +} + +export function shutdownDesktopRenameLog(): void { + if (ensureWritable() && logFilePath) { + try { + fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8"); + } catch { + // ignore + } + } + logDir = null; + logFilePath = null; +} + +export interface RenameVerification { + /** Gesamtergebnis: Datei liegt unter dem EXAKT erwarteten Namen vor und (sofern kein + * In-Place-Rename) die Quelle ist verschwunden. */ + ok: boolean; + /** Empfohlenes Log-Level: ERROR (Rename nicht vollzogen / falscher Name), + * WARN (vollzogen, aber Schreibweise nicht pruefbar), INFO (alles ok). */ + level: "INFO" | "WARN" | "ERROR"; + /** Zieldatei (egal welche Schreibweise) auf der Platte vorhanden? */ + targetExists: boolean; + /** Tatsaechlicher Name auf der Platte (Gross-/Kleinschreibung wie wirklich + * gespeichert), oder null wenn nicht gefunden / Verzeichnis nicht lesbar. */ + onDiskName: string | null; + /** onDiskName === erwarteter Zielname (exakt, case-sensitive)? */ + nameMatches: boolean; + /** Quelldatei verschwunden (Rename wirklich vollzogen, kein halb-fertiger Copy)? */ + sourceGone: boolean; + /** Groesse der Zieldatei in Bytes, oder null. */ + targetSize: number | null; + /** Menschenlesbarer Grund, wenn nicht sauber INFO. */ + reason: string; +} + +/** Repliziert download-manager.toWindowsLongPathIfNeeded (ein Import waere zirkulaer: + * download-manager -> desktop-rename-log). Node fs-Aufrufe scheitern unter Windows fuer + * absolute Pfade >=248 Zeichen, sofern nicht mit \\?\ / \\?\UNC\ praefixiert — und genau + * solche langen Scene-Release-Pfade benennt diese App um. OHNE dieses Prefix wuerden + * statSync/readdirSync in der Verifikation auf langen Pfaden faelschlich scheitern + * (falsches "Ziel nicht gefunden" UND falsches "Quelle weg" -> falsches OK, das einen + * halb-fertigen Verschiebevorgang maskiert). */ +function toLongPath(filePath: string): string { + const absolute = path.resolve(String(filePath || "")); + if (process.platform !== "win32") { + return absolute; + } + if (!absolute || absolute.startsWith("\\\\?\\")) { + return absolute; + } + if (absolute.length < 248) { + return absolute; + } + if (absolute.startsWith("\\\\")) { + return `\\\\?\\UNC\\${absolute.slice(2)}`; + } + return `\\\\?\\${absolute}`; +} + +/** Echter On-Disk-Name (korrekte Schreibweise) fuer `requested` aus den + * Verzeichnis-Eintraegen, oder null wenn das Verzeichnis nicht lesbar war + * (entries===null) bzw. nichts passt. */ +function resolveOnDiskName(requested: string, entries: string[] | null): string | null { + if (entries === null) { + return null; + } + const requestedLower = requested.toLowerCase(); + return entries.find((entry) => entry === requested) + || entries.find((entry) => entry.toLowerCase() === requestedLower) + || requested; +} + +/** Baut das Verifikations-Ergebnis aus den (sync ODER async) erhobenen Roh-Fakten. + * `dirEntries`=null bedeutet "Zielverzeichnis war nicht lesbar". */ +function buildVerification( + sourcePath: string, + targetPath: string, + facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean } +): RenameVerification { + const requested = path.basename(targetPath); + const dirReadFailed = facts.targetExists && facts.dirEntries === null; + const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null; + + // In-Place-Rename (reine Gross-/Kleinschreibungs-Korrektur auf case-insensitivem FS): + // Quelle == Ziel -> "Quelle weg" gilt nicht. + const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase(); + const sourceGone = samePath ? true : !facts.sourceExists; + const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested; + + const problems: string[] = []; + let level: "INFO" | "WARN" | "ERROR" = "INFO"; + if (!facts.targetExists) { + problems.push("Zieldatei nach Rename NICHT gefunden"); + level = "ERROR"; + } else if (!dirReadFailed && !nameMatches) { + problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`); + level = "ERROR"; + } + if (!samePath && facts.targetExists && !sourceGone) { + problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)"); + level = "ERROR"; + } + if (level === "INFO" && dirReadFailed) { + // Datei da + Quelle weg, aber Schreibweise ungeprueft — KEIN stilles OK. + problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert"); + level = "WARN"; + } + + return { + ok: level === "INFO", + level, + targetExists: facts.targetExists, + onDiskName, + nameMatches, + sourceGone, + targetSize: facts.targetSize, + reason: problems.join("; ") + }; +} + +/** Verifiziert NACH einem Rename SYNCHRON, ob das Ergebnis wirklich stimmt — der Kern + * der User-Anforderung ("nur weil er renaming sagt heisst es nicht das es klappt"). + * Fuer die synchronen Rename-Sites (startup-Dedup, Suffix-Fix, Deobfuskation). Rein + * lesend, wirft nie. fs-Aufrufe ueber toLongPath (lange Windows-Pfade!). */ +export function verifyRename(sourcePath: string, targetPath: string): RenameVerification { + const longTarget = toLongPath(targetPath); + let targetExists = false; + let targetSize: number | null = null; + try { + const stat = fs.statSync(longTarget); + targetExists = true; + targetSize = stat.size; + } catch { + targetExists = false; + } + let dirEntries: string[] | null = null; + if (targetExists) { + try { + dirEntries = fs.readdirSync(path.dirname(longTarget)); + } catch { + dirEntries = null; + } + } + let sourceExists = false; + try { + fs.statSync(toLongPath(sourcePath)); + sourceExists = true; + } catch { + sourceExists = false; + } + return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists }); +} + +/** Asynchrone Verifikation — fuer den Media-Rename-Hot-Path (renamePathWithExdevFallback), + * damit KEIN synchrones statSync/readdirSync den Electron-Main-Loop in Saison-Pack- + * Rename-Schleifen blockiert (Projekt-Regel: kein sync I/O in Hot Paths). Wirft nie. */ +export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise { + const longTarget = toLongPath(targetPath); + let targetExists = false; + let targetSize: number | null = null; + try { + const stat = await fs.promises.stat(longTarget); + targetExists = true; + targetSize = stat.size; + } catch { + targetExists = false; + } + let dirEntries: string[] | null = null; + if (targetExists) { + try { + dirEntries = await fs.promises.readdir(path.dirname(longTarget)); + } catch { + dirEntries = null; + } + } + let sourceExists = false; + try { + await fs.promises.stat(toLongPath(sourcePath)); + sourceExists = true; + } catch { + sourceExists = false; + } + return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists }); +} diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 2ddf9c0..fee74a0 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -61,6 +61,7 @@ import type { RotationEvent } from "../shared/types"; import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log"; import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log"; import { logRenameEvent as writeRenameLogEvent } from "./rename-log"; +import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerification } from "./desktop-rename-log"; import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage"; import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils"; @@ -830,6 +831,17 @@ function isIgnorableEmptyDirFileName(fileName: string): boolean { return EMPTY_DIR_IGNORED_FILE_NAMES.has(normalized) || EMPTY_DIR_IGNORED_FILE_RE.test(normalized); } +/** Kopfzeile fuer eine Rename-Verifikation im Desktop-Log (passend zum Level). */ +function verifyHeadline(v: RenameVerification): string { + if (v.ok) { + return "Rename verifiziert"; + } + if (v.level === "WARN") { + return "Rename vollzogen, aber Schreibweise nicht verifiziert"; + } + return "Rename meldet OK, aber Verifikation FEHLGESCHLAGEN"; +} + function toWindowsLongPathIfNeeded(filePath: string): string { const absolute = path.resolve(String(filePath || "")); if (process.platform !== "win32") { @@ -2054,6 +2066,13 @@ export class DownloadManager extends EventEmitter { ...(matchedBy ? { matchedBy } : {}), ...fields }); + // Spiegeln ins Desktop-Rename-Log (luekenlose Sitzungs-Uebersicht beim User). + logDesktopRename(level, `[${stage}] ${message}`, { + paket: pkg.name, + ...(item ? { datei: item.fileName } : {}), + ...(matchedBy ? { matchedBy } : {}), + ...fields + }); if (item) { this.logItemOnly(item, level, message, { stage, @@ -3638,7 +3657,56 @@ export class DownloadManager extends EventEmitter { } } - private async renamePathWithExdevFallback(sourcePath: string, targetPath: string): Promise { + /** Verify+Log fuer SYNCHRONE Rename-Sites (startup-Dedup, Suffix-Fix, Deobfuskation), + * die nicht ueber renamePathWithExdevFallback laufen. Nach erfolgreichem renameSync + * aufrufen — verifiziert das On-Disk-Ergebnis und protokolliert es ins Desktop-Log. */ + private logVerifiedRenameSync(label: string, sourcePath: string, targetPath: string, extraFields?: Record): void { + const v = verifyRename(sourcePath, targetPath); + logDesktopRename(v.level, `${label}: ${verifyHeadline(v)}`, { + ...(extraFields || {}), + source: path.basename(sourcePath), + requested: path.basename(targetPath), + onDisk: v.onDiskName, + targetDir: path.dirname(targetPath), + sourceGone: v.sourceGone, + sizeBytes: v.targetSize, + ...(v.ok ? {} : { grund: v.reason }) + }); + } + + /** Verifizierter Wrapper um jeden Media-Rename/-Move: protokolliert den Vorgang + * ins Desktop-Rename-Log UND verifiziert danach, dass die Datei wirklich unter + * dem Zielnamen auf der Platte liegt (Quelle weg, korrekte Schreibweise). Nur weil + * fs.rename "ok" meldet, ist der Rename noch nicht bewiesen. Verifikations-Fehler + * werden NUR geloggt (kein throw) — reine Beobachtbarkeit, keine Verhaltensaenderung. */ + private async renamePathWithExdevFallback(sourcePath: string, targetPath: string, ctx?: { label?: string; fields?: Record }): Promise { + const label = ctx?.label || "rename"; + try { + await this.renamePathWithExdevFallbackRaw(sourcePath, targetPath); + } catch (error) { + logDesktopRename("ERROR", `${label}: Rename fehlgeschlagen`, { + ...(ctx?.fields || {}), + source: path.basename(sourcePath), + sourceDir: path.dirname(sourcePath), + target: path.basename(targetPath), + error: compactErrorText(error) + }); + throw error; + } + const v = await verifyRenameAsync(sourcePath, targetPath); + logDesktopRename(v.level, `${label}: ${verifyHeadline(v)}`, { + ...(ctx?.fields || {}), + source: path.basename(sourcePath), + requested: path.basename(targetPath), + onDisk: v.onDiskName, + targetDir: path.dirname(targetPath), + sourceGone: v.sourceGone, + sizeBytes: v.targetSize, + ...(v.ok ? {} : { grund: v.reason }) + }); + } + + private async renamePathWithExdevFallbackRaw(sourcePath: string, targetPath: string): Promise { const sourceFsPath = toWindowsLongPathIfNeeded(sourcePath); const targetFsPath = toWindowsLongPathIfNeeded(targetPath); // Transient lock codes — antivirus scan, Windows Search Indexer, OneDrive @@ -3736,7 +3804,7 @@ export class DownloadManager extends EventEmitter { continue; } try { - await this.renamePathWithExdevFallback(sourceCompanionPath, targetCompanionPath); + await this.renamePathWithExdevFallback(sourceCompanionPath, targetCompanionPath, { label: "companion" }); logger.info(`Auto-Rename Companion: ${entryName} -> ${newCompanionName}`); if (pkg) { this.logPackageForPackage(pkg, "INFO", "Auto-Rename Companion umbenannt", { @@ -4346,7 +4414,7 @@ export class DownloadManager extends EventEmitter { // Route through renamePathWithExdevFallback so we get the long-path / // UNC handling AND the transient-error retry for free. try { - await this.renamePathWithExdevFallback(sourcePath, targetPath); + await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "auto-rename (Schreibweise)" }); renamedCount += 1; if (pkg) { const resolved = resolveRenameItem(targetPath); @@ -4418,7 +4486,7 @@ export class DownloadManager extends EventEmitter { } try { - await this.renamePathWithExdevFallback(sourcePath, targetPath); + await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "auto-rename" }); if (pkg) { this.logPackageForPackage(pkg, "INFO", "Auto-Rename durchgeführt", { sourcePath, @@ -4455,7 +4523,7 @@ export class DownloadManager extends EventEmitter { continue; } try { - await this.renamePathWithExdevFallback(sourcePath, fallbackPath); + await this.renamePathWithExdevFallback(sourcePath, fallbackPath, { label: "auto-rename (Pfadlaenge-Fallback)" }); logger.warn(`Auto-Rename Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(fallbackPath)}`); renamed += 1; if (pkg) { @@ -4513,7 +4581,7 @@ export class DownloadManager extends EventEmitter { } private async moveFileWithExdevFallback(sourcePath: string, targetPath: string): Promise { - await this.renamePathWithExdevFallback(sourcePath, targetPath); + await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "mkv-move" }); } private async cleanupNonMkvResidualFiles(rootDir: string, targetDir: string): Promise { @@ -4615,7 +4683,7 @@ export class DownloadManager extends EventEmitter { if (await this.existsAsync(candidate)) { continue; } - await this.renamePathWithExdevFallback(targetPath, candidate); + await this.renamePathWithExdevFallback(targetPath, candidate, { label: "mkv-move (Konflikt-Aufloesung)" }); moved = true; break; } @@ -6343,8 +6411,14 @@ export class DownloadManager extends EventEmitter { try { fs.renameSync(duplicateTargetPath, canonicalPath); canonicalExists = true; + this.logVerifiedRenameSync("startup-dedup", duplicateTargetPath, canonicalPath); logger.info(`startupDuplicateMerge: ${path.basename(duplicateTargetPath)} → ${canonicalBaseName}`); } catch (err) { + logDesktopRename("ERROR", "startup-dedup: Rename fehlgeschlagen", { + source: path.basename(duplicateTargetPath), + target: canonicalBaseName, + error: compactErrorText(err) + }); logger.warn(`startupDuplicateMerge: Umbenennung fehlgeschlagen ${duplicateTargetPath}: ${compactErrorText(err)}`); } } else if (duplicateExists && canonicalExists && primaryWins) { @@ -6358,8 +6432,14 @@ export class DownloadManager extends EventEmitter { fs.rmSync(canonicalPath, { force: true }); fs.renameSync(duplicateTargetPath, canonicalPath); canonicalExists = true; + this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath); logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`); } catch (err) { + logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", { + source: path.basename(duplicateTargetPath), + target: canonicalBaseName, + error: compactErrorText(err) + }); logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`); } } @@ -6971,6 +7051,7 @@ export class DownloadManager extends EventEmitter { } await fs.promises.rename(filePath, newPath); + this.logVerifiedRenameSync("deobfuskation", filePath, newPath, { paket: pkg.name, signatur: sig }); item.fileName = newName; item.targetPath = newPath; // Update the path lookup @@ -6984,6 +7065,11 @@ export class DownloadManager extends EventEmitter { signature: sig }); } catch (err) { + logDesktopRename("ERROR", "deobfuskation: Rename fehlgeschlagen", { + source: path.basename(filePath), + paket: pkg.name, + error: compactErrorText(err as Error) + }); logger.warn(`Deobfuskation fehlgeschlagen: ${filePath}: ${compactErrorText(err as Error)}`); } } @@ -7146,6 +7232,7 @@ export class DownloadManager extends EventEmitter { } try { fs.renameSync(tp, originalPath); + this.logVerifiedRenameSync("suffix-fix", tp, originalPath); this.reservedTargetPaths.delete(pathKey(tp)); this.reservedTargetPaths.set(originalKey, item.id); this.claimedTargetPathByItem.set(item.id, originalPath); @@ -7154,6 +7241,11 @@ export class DownloadManager extends EventEmitter { fixed += 1; logger.info(`fixDuplicateSuffix: ${path.basename(tp)} → ${originalName}`); } catch (err) { + logDesktopRename("ERROR", "suffix-fix: Rename fehlgeschlagen", { + source: path.basename(tp), + target: originalName, + error: compactErrorText(err) + }); logger.warn(`fixDuplicateSuffix: Umbenennung fehlgeschlagen ${tp}: ${compactErrorText(err)}`); } } diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 0cdbe99..d902653 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -7,6 +7,7 @@ import { getDebugSetupCheck } from "./debug-setup"; import { getLogFilePath } from "./logger"; import { getPackageLogPath } from "./package-log"; import { getRenameLogPath } from "./rename-log"; +import { getDesktopRenameLogPath } from "./desktop-rename-log"; import { getSessionLogPath } from "./session-log"; import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; @@ -181,6 +182,7 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op 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"); diff --git a/tasks/lessons.md b/tasks/lessons.md index 7d6daf9..9bcabfe 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -135,3 +135,25 @@ Streak (3× hintereinander leer → geparkt), nicht der einmalige Wortlaut. Zähler. Ein E2E-Test muss eine ECHTE leere Antwort durch den realen Einstiegspunkt (`unrestrictWithAccounts` → `classifyAccountFailure` → catch → Park) treiben, sonst bleibt unbewiesen, dass der Produktionspfad das Signal überhaupt setzt. + +## 2026-06-01 — Ein Verifizierer muss dieselbe Pfad-Normalisierung nutzen wie die verifizierte Operation + +**Muster:** Neues Renaming-Logging sollte nach jedem Rename verifizieren, ob die Datei +wirklich unter dem Zielnamen liegt. `verifyRename` machte statSync/readdirSync auf den +ROHEN Pfaden — der echte Rename lief aber über `toWindowsLongPathIfNeeded` (\?\-Prefix +ab >=248 Zeichen). Bei langen Scene-Release-Pfaden (genau das, was die App routinemäßig +umbenennt) scheiterten die rohen fs-Calls → falsches „Ziel nicht gefunden" UND — schlimmer — +die Quell-Prüfung scheiterte ebenfalls → `sourceGone` fälschlich true → **falsches „OK"**, +das einen halb-fertigen Verschiebevorgang maskiert. Der Diagnose-Log hätte genau die +schwersten Fälle vergiftet. (Adversarialer Review-Workflow fand es, Confidence 0.8.) + +**Regel:** Wenn Code eine Operation VERIFIZIERT, muss er exakt dieselbe Pfad-/Encoding-/ +Normalisierung verwenden wie die Operation selbst (hier: \?\-Long-Path-Prefix). Sonst +mis-reportet der Verifizierer still — und am verlässlichsten bei den Edge-Cases, die man +eigentlich fangen wollte. Ein falsches OK in einem Diagnose-Log ist schlimmer als ein +falsches ERROR. Zusatz: readdir-Fehler darf nicht zu „Schreibweise ok" degradieren +(stilles False-OK) → eigenes WARN-Level „nicht verifizierbar". + +**Meta:** Bei einem Feature, dessen ganzer Zweck Beobachtbarkeit/Verifikation ist, lohnt +ein adversarialer Review mit Fokus „würde die Verifikation auf der ECHTEN Last (lange +Pfade, case-insensitive FS, EXDEV) korrekt urteilen?" — nicht nur „kompiliert + Happy-Path-Test". diff --git a/tests/desktop-rename-log.test.ts b/tests/desktop-rename-log.test.ts new file mode 100644 index 0000000..f4f9d2f --- /dev/null +++ b/tests/desktop-rename-log.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + getDesktopRenameLogPath, + initDesktopRenameLog, + logDesktopRename, + shutdownDesktopRenameLog, + verifyRename +} from "../src/main/desktop-rename-log"; + +const createdTmpDirs: string[] = []; + +function tmpDesktop(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-log-")); + createdTmpDirs.push(dir); + return dir; +} + +afterEach(() => { + shutdownDesktopRenameLog(); + for (const dir of createdTmpDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } + } + createdTmpDirs.length = 0; +}); + +describe("desktop-rename-log", () => { + it("creates the Downloader-Log folder + session file on init and appends formatted lines", () => { + const desktop = tmpDesktop(); + initDesktopRenameLog(desktop); + + const logPath = getDesktopRenameLogPath(); + expect(logPath).toBeTruthy(); + expect(path.dirname(logPath as string).endsWith("Downloader-Log")).toBe(true); + expect(fs.existsSync(logPath as string)).toBe(true); + + logDesktopRename("INFO", "Test-Rename", { source: "a.mkv", requested: "b.mkv" }); + const content = fs.readFileSync(logPath as string, "utf8"); + expect(content).toContain("Rename-Session gestartet"); + expect(content).toContain("Test-Rename"); + expect(content).toContain("source=a.mkv"); + expect(content).toContain("requested=b.mkv"); + expect(content).toMatch(/\[INFO\]/); + }); + + it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => { + // Genau die User-Anforderung: Ordner zur Laufzeit geloescht -> beim naechsten + // Rename automatisch wieder da, selbst wenn das Programm offen ist. + const desktop = tmpDesktop(); + initDesktopRenameLog(desktop); + const logPath = getDesktopRenameLogPath() as string; + logDesktopRename("INFO", "ZeileA"); + + fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true }); + expect(fs.existsSync(logPath)).toBe(false); + + // Naechster Vorgang muss Ordner UND Datei (mit Header) selbstheilend neu anlegen. + logDesktopRename("INFO", "ZeileB"); + expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true); + expect(fs.existsSync(logPath)).toBe(true); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).toContain("Rename-Session gestartet"); + expect(content).toContain("ZeileB"); + }); + + it("is a silent no-op when initialized without a desktop path (never throws)", () => { + initDesktopRenameLog(""); + expect(getDesktopRenameLogPath()).toBeNull(); + expect(() => logDesktopRename("INFO", "egal")).not.toThrow(); + }); + + it("verifyRename: ok when the target exists under the exact name and the source is gone", () => { + const dir = tmpDesktop(); + const source = path.join(dir, "scn-xyz.part1.rar"); + const target = path.join(dir, "Movie.2024.German.1080p.part1.rar"); + fs.writeFileSync(target, "data"); // Post-Rename-Zustand: Ziel da, Quelle weg. + + const v = verifyRename(source, target); + expect(v.ok).toBe(true); + expect(v.level).toBe("INFO"); + expect(v.targetExists).toBe(true); + expect(v.onDiskName).toBe("Movie.2024.German.1080p.part1.rar"); + expect(v.nameMatches).toBe(true); + expect(v.sourceGone).toBe(true); + expect(v.targetSize).toBe(4); + }); + + it("verifyRename: FAILS when the target is missing although rename reported success", () => { + const dir = tmpDesktop(); + const v = verifyRename(path.join(dir, "src.rar"), path.join(dir, "never-created.rar")); + expect(v.ok).toBe(false); + expect(v.level).toBe("ERROR"); + expect(v.targetExists).toBe(false); + expect(v.reason).toMatch(/nicht gefunden/i); + }); + + it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => { + // EXDEV-Copy gelang, aber rm(source) schlug fehl -> Ziel da, Quelle auch noch. + const dir = tmpDesktop(); + const source = path.join(dir, "src.rar"); + const target = path.join(dir, "dst.rar"); + fs.writeFileSync(source, "x"); + fs.writeFileSync(target, "x"); + + const v = verifyRename(source, target); + expect(v.ok).toBe(false); + expect(v.level).toBe("ERROR"); + expect(v.sourceGone).toBe(false); + expect(v.reason).toMatch(/Quelldatei existiert noch/i); + }); + + it("verifyRename: an in-place rename (same path) is ok and does not flag a lingering source", () => { + const dir = tmpDesktop(); + const p = path.join(dir, "file.mkv"); + fs.writeFileSync(p, "x"); + + const v = verifyRename(p, p); + expect(v.ok).toBe(true); + expect(v.targetExists).toBe(true); + expect(v.nameMatches).toBe(true); + }); +});