import { app } from 'electron'; import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'; import path from 'node:path'; import type { ErrorReport } from '../shared/ipc'; /** * Fehlerprotokoll. * * Anlass: Ein Anwender meldete einen Absturz, der sich nicht wiederholen liess. * Ohne Protokoll ist ein solcher Einzelfall nicht aufklaerbar - es gibt keine * Spur, an der man ansetzen koennte. Deshalb schreibt die Anwendung jetzt jeden * unbehandelten Fehler beider Prozesse und jedes Ende des Anzeigeprozesses in * eine Datei. * * Das Protokoll liegt im Benutzerdatenverzeichnis und ist darauf angelegt, * nur technische Angaben aufzunehmen - keine Projektinhalte. Bis 5.42.1 stand * das im Hilfefenster als Zusage ("enthält nur technische Angaben"); seit * 5.43.0 sagt die Kurzhilfe es so schwach, wie es belegt ist, und bittet, eine * Kopie ueber Hilfe -> "Fehlerprotokoll speichern …" einer Fehlermeldung * beizulegen: Die Meldungstexte werden nicht gefiltert, und ein Aufrufstapel * kann den Installationspfad und damit den Windows-Benutzernamen tragen * (docs/datenschutz.md, Abschnitt "Fehlerprotokoll"). * * WER DIE ZUSAGE EINHAELT * * Nicht diese Datei: `protokolliere` sieht einer Zeichenkette nicht an, woher * sie stammt, und filtert deshalb nichts. Die Zusage haelt, wer aufruft. Fuer * Text aus fremder Hand heisst das: Er gehoert nicht hierher. Ein Kartendienst * etwa zitiert in seiner Ausnahmemeldung ueblicherweise die gestellte Anfrage * samt BBOX - also die Koordinaten des geplanten Knotenpunkts; der Griff * `IPC.fetchMapImage` in electron/main.ts protokolliert deshalb nur die * technische Einordnung und zeigt die Meldung des Dienstes allein an der * Oberflaeche (Befund 31). * * WIE DIE DATEI ZUM ANWENDER KOMMT * * Hilfe -> "Fehlerprotokoll speichern …" schreibt eine Kopie an einen Ort, den * der Anwender im Dialog waehlt (`speichereProtokollkopie` in electron/main.ts, * Inhalt aus `protokollkopie` hier). Anlass: Je nach Installationsart leitet * Windows neue Dateien unter %APPDATA% in einen eigenen Bereich um, waehrend * `app.getPath('userData')` den unumgeleiteten Pfad nennt - das Programm liest * seine Datei dort, der Anwender findet sie im Explorer dort nicht. Bestand * %APPDATA%\lsa-planer-professional schon vorher - vom Setup oder vom * tragbaren Programm -, landen auch neue Dateien dort, und der Pfad stimmt * (gemessen am 17.09.2026 unter Windows 11; so auch docs/datenschutz.md). * * Die Kopie ist fuer jede Auslieferung und beide Faelle derselbe Weg. */ const MAX_BYTES = 1_000_000; let cachedPath: string | null = null; /** Pfad der Protokolldatei. */ export function protokollPfad(): string { if (cachedPath !== null) return cachedPath; const verzeichnis = app.getPath('userData'); try { mkdirSync(verzeichnis, { recursive: true }); } catch { /* Verzeichnis besteht bereits. */ } cachedPath = path.join(verzeichnis, 'fehlerprotokoll.log'); return cachedPath; } /** Schreibt eine Zeile in das Protokoll. Schlaegt niemals fehl. */ export function protokolliere(quelle: string, nachricht: string, stapel?: string): void { try { const datei = protokollPfad(); rotiereWennZuGross(datei); const zeit = new Date().toISOString(); const zeilen = [`[${zeit}] ${quelle}: ${einzeilig(nachricht)}`]; if (stapel !== undefined && stapel !== '') { for (const zeile of stapel.split('\n').slice(0, 12)) { zeilen.push(` ${zeile.trim()}`); } } appendFileSync(datei, `${zeilen.join('\n')}\n`, 'utf8'); } catch { // Ein Fehler beim Protokollieren darf niemals den Ablauf stoeren. } // Zusaetzlich auf die Konsole, damit ein Start aus der Kommandozeile alles zeigt. console.error(`${quelle}: ${einzeilig(nachricht)}`); } /** Uebernimmt eine Meldung aus dem Anzeigeprozess. */ export function protokolliereMeldung(bericht: ErrorReport): void { const zustand = bericht.zustand === undefined ? '' : ` | Zustand: ${Object.entries(bericht.zustand) .map(([k, v]) => `${k}=${String(v)}`) .join(', ')}`; protokolliere(`Anzeige/${bericht.quelle}`, `${bericht.nachricht}${zustand}`, bericht.stapel); } /** Angaben der Startzeile. */ export interface Startumgebung { version: string; electron: string; chromium: string; plattform: string; architektur: string; } /** * Inhalt der Startzeile - rein, damit er ohne laufendes Electron pruefbar ist. * * Nennt Fassung, Laufzeit und Plattform. Wie das Programm eingerichtet ist, * nennt die Zeile nicht; wo Windows Dateien des Benutzerprofils umleitet, * fuehrt "Fehlerprotokoll speichern …" trotzdem zur Datei (Kopf dieser Datei). */ export function startzeile(umgebung: Startumgebung): string { return ( `Version ${umgebung.version} · Electron ${umgebung.electron} · ` + `Chromium ${umgebung.chromium} · ${umgebung.plattform} ${umgebung.architektur}` ); } /** Vermerkt Programmstart und Umgebung - hilft beim Einordnen spaeterer Eintraege. */ export function protokolliereStart(): void { protokolliere( 'Start', startzeile({ version: app.getVersion(), electron: process.versions.electron, chromium: process.versions.chrome, plattform: process.platform, architektur: process.arch, }), ); } /** * Name der abgeloesten Protokolldatei. * * Eine Stelle fuer Rotation und Kopie: Benennte die Rotation anders, als die * Kopie liest, fehlte der aeltere Teil in der Kopie, ohne dass es jemand merkt. */ export function abgeloestesProtokoll(datei: string): string { return `${datei}.alt`; } /** Was in der Kopie steht, wenn es kein Protokoll gibt - sichtbarer Anwendertext. */ export const KEIN_PROTOKOLL = 'Kein Fehlerprotokoll vorhanden: Bis zum Anlegen dieser Kopie wurde nichts protokolliert.'; /** * Inhalt der Kopie, die Hilfe -> "Fehlerprotokoll speichern …" schreibt. * * Rein: Die Lesefunktion kommt herein, damit sich die Zusammensetzung ohne * Datentraeger pruefen laesst. `lies` liefert den Inhalt oder `null`, wenn es * die Datei nicht gibt; jeder andere Fehlschlag wirft und wird hier nicht * aufgefangen. * * WAS HINEINKOMMT: die abgeloeste Datei vor der laufenden, also in zeitlicher * Reihenfolge - sonst nichts. Kein Kopf mit Pfaden: Der Pfad des * Benutzerprofils traegt den Anmeldenamen, und die Kopie ist zum Weitergeben * da. Kein Vermerk ueber die Kopie selbst: Der Dateiname traegt das Datum, und * wer die Kopie beilegt, soll das Protokoll beilegen und keine Bearbeitung. * * GIBT ES KEIN PROTOKOLL - weder Datei noch Inhalt -, entsteht trotzdem eine * Kopie mit der einen Zeile `KEIN_PROTOKOLL`, und kein Hinweisdialog. Zwei * Gruende: Wer um das Protokoll gebeten wurde, kann damit tun, worum er gebeten * wurde, statt einen Dialog nachzuerzaehlen. Und der Fall ist selbst ein * Befund: `protokolliereStart` schreibt bei jedem Start eine Zeile, ein * fehlendes Protokoll heisst also, dass das Schreiben scheitert oder die Datei * entfernt wurde - genau das soll bei der Fehlersuche ankommen. Der Eintrag tut * damit in jedem Zustand dasselbe: Dialog, Datei. * * Ein LESEFEHLER ist etwas anderes als eine fehlende Datei und wirft weiter: * Eine Kopie "nichts protokolliert" ueber einer gesperrten Datei waere eine * falsche Auskunft. * * Beide Dateien werden unmittelbar nacheinander gelesen. Geschrieben und * rotiert wird nur im Hauptprozess und synchron (`protokolliere`); mit einer * synchronen Lesefunktion faellt zwischen die beiden Lesevorgaenge keine * Rotation. */ export function protokollkopie(datei: string, lies: (pfad: string) => string | null): string { const teile = [lies(abgeloestesProtokoll(datei)), lies(datei)].filter( (teil): teil is string => teil !== null && teil !== '', ); if (teile.length === 0) return `${KEIN_PROTOKOLL}\n`; return teile.map((teil) => (teil.endsWith('\n') ? teil : `${teil}\n`)).join(''); } /** * Liest eine Protokolldatei fuer die Kopie: Inhalt, oder `null`, wenn es sie * nicht gibt. * * Nur ENOENT heisst "gibt es nicht". Alles andere - gesperrt, kein Leserecht, * ein Ordner an der Stelle - wirft weiter; siehe `protokollkopie`. */ export function liesProtokolldatei(pfad: string): string | null { try { return readFileSync(pfad, 'utf8'); } catch (fehler) { if ((fehler as NodeJS.ErrnoException).code === 'ENOENT') return null; throw fehler; } } /** * Ist `ziel` eine der beiden Protokolldateien - die laufende `datei` oder die * abgeloeste daneben? * * Anlass (behoben mit 5.43.0): Die Kurzhilfe zeigt den Pfad des Protokolls * als auswaehlbaren Text. Wer ihn in den Speichern-Dialog der Kopie einfuegt * und das Ueberschreiben bestaetigt, liess `schreibeUnteilbar` das laufende * Protokoll durch "abgeloest + laufend" ersetzen; die naechste Kopie enthielt * den abgeloesten Teil doppelt und nicht mehr in zeitlicher Folge, und die * naechste Rotation kam frueher. Mit der abgeloesten Datei als Ziel wurde der * laufende Teil doppelt. * * ZWEI PRUEFUNGEN, weil keine allein traegt: * * 1. Die Dateikennung, `dev` und `ino` - als bigint, denn unter NTFS belegt * die Dateinummer 64 Bit (Satznummer und Folgenummer) und ist als `number` * oberhalb von 53 Bit nicht mehr genau. Sie erkennt dieselbe Datei unter * einem anderen Pfad: ueber eine Verzeichnisverknuepfung (nachgestellt in * tests/electron/protokollWeitergabe.test.ts) und unter einem * 8.3-Kurznamen (Benutzerordner als Kurzname gegen den langen Namen, am * 17.09.2026 unter Windows 11 nachgesehen; kein Pruefstand, weil nicht * jeder Datentraeger Kurznamen anlegt). Ob sie unter einem von Windows * umgeleiteten Pfad und unter dem, den das Programm kennt, gleich ist, ist * nicht nachgesehen. * 2. Der aufgeloeste Pfad, unter Windows ohne Unterscheidung von Gross- und * Kleinschreibung. Er traegt, wo es keine Kennung gibt: bei einer * abgeloesten Datei, die es noch nicht gibt. * * Nicht erkannt wird eine abgeloeste Datei, die es noch nicht gibt, unter einem * anderen Pfad als dem, den das Programm kennt: Sie hat weder eine Kennung noch * denselben Pfad. * * Nur ENOENT heisst "keine Kennung", wie bei `liesProtokolldatei`; jeder andere * Fehlschlag beim Nachsehen wirft weiter. Eine Dateinummer 0 gilt ebenfalls als * keine Kennung: Nicht jedes Dateisystem fuehrt Dateinummern, und gleiche * Kennungen hiessen dort nicht dieselbe Datei. Gesehen ist ein solcher * Datentraeger hier nicht - die Wache steht, damit eine Kopie auf einer * Netzfreigabe nicht an einer bedeutungslosen Gleichheit scheitert. * * Nachgesehen wird synchron und unmittelbar hintereinander; eine Rotation * (`protokolliere`, ebenfalls synchron) faellt nicht dazwischen. */ export function istProtokolldatei(ziel: string, datei: string): boolean { const protokolldateien = [datei, abgeloestesProtokoll(datei)]; const zielschluessel = pfadschluessel(ziel); if (protokolldateien.some((pfad) => pfadschluessel(pfad) === zielschluessel)) return true; const zielkennung = dateikennung(ziel); if (zielkennung === null) return false; return protokolldateien.some((pfad) => { const kennung = dateikennung(pfad); return kennung !== null && kennung.dev === zielkennung.dev && kennung.ino === zielkennung.ino; }); } /** Aufgeloester Pfad; unter Windows ohne Unterscheidung von Gross- und Kleinschreibung. */ function pfadschluessel(pfad: string): string { const aufgeloest = path.resolve(pfad); return process.platform === 'win32' ? aufgeloest.toLowerCase() : aufgeloest; } /** Datentraeger und Dateinummer, oder `null`, wo es keine gibt - siehe `istProtokolldatei`. */ function dateikennung(pfad: string): { dev: bigint; ino: bigint } | null { try { const { dev, ino } = statSync(pfad, { bigint: true }); return ino === 0n ? null : { dev, ino }; } catch (fehler) { if ((fehler as NodeJS.ErrnoException).code === 'ENOENT') return null; throw fehler; } } /** * Vorgeschlagener Dateiname der Kopie, mit oertlichem Datum. * * Oertlich und nicht nach Weltzeit: `toISOString` naennte in Deutschland * zwischen Mitternacht und ein beziehungsweise zwei Uhr den Vortag, und der * Anwender saehe ein Datum, das nicht seines ist. */ export function protokollDateiname(jetzt: Date): string { const jahr = String(jetzt.getFullYear()); const monat = String(jetzt.getMonth() + 1).padStart(2, '0'); const tag = String(jetzt.getDate()).padStart(2, '0'); return `LSA-Planer-Fehlerprotokoll-${jahr}-${monat}-${tag}.txt`; } function rotiereWennZuGross(datei: string): void { try { if (statSync(datei).size < MAX_BYTES) return; renameSync(datei, abgeloestesProtokoll(datei)); } catch { /* Datei besteht noch nicht oder laesst sich nicht umbenennen. */ } } function einzeilig(text: string): string { return text.replace(/\s*\n\s*/g, ' ⏎ ').slice(0, 2000); }