lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 1 | import { app } from 'electron'; |
| 2 | import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'; |
| 3 | import path from 'node:path'; |
| 4 | import type { ErrorReport } from '../shared/ipc'; |
| 5 | |
| 6 | /** |
| 7 | * Fehlerprotokoll. |
| 8 | * |
| 9 | * Anlass: Ein Anwender meldete einen Absturz, der sich nicht wiederholen liess. |
| 10 | * Ohne Protokoll ist ein solcher Einzelfall nicht aufklaerbar - es gibt keine |
| 11 | * Spur, an der man ansetzen koennte. Deshalb schreibt die Anwendung jetzt jeden |
| 12 | * unbehandelten Fehler beider Prozesse und jedes Ende des Anzeigeprozesses in |
| 13 | * eine Datei. |
| 14 | * |
| 15 | * Das Protokoll liegt im Benutzerdatenverzeichnis und ist darauf angelegt, |
| 16 | * nur technische Angaben aufzunehmen - keine Projektinhalte. Bis 5.42.1 stand |
| 17 | * das im Hilfefenster als Zusage ("enthält nur technische Angaben"); seit |
| 18 | * 5.43.0 sagt die Kurzhilfe es so schwach, wie es belegt ist, und bittet, eine |
| 19 | * Kopie ueber Hilfe -> "Fehlerprotokoll speichern …" einer Fehlermeldung |
| 20 | * beizulegen: Die Meldungstexte werden nicht gefiltert, und ein Aufrufstapel |
| 21 | * kann den Installationspfad und damit den Windows-Benutzernamen tragen |
| 22 | * (docs/datenschutz.md, Abschnitt "Fehlerprotokoll"). |
| 23 | * |
| 24 | * WER DIE ZUSAGE EINHAELT |
| 25 | * |
| 26 | * Nicht diese Datei: `protokolliere` sieht einer Zeichenkette nicht an, woher |
| 27 | * sie stammt, und filtert deshalb nichts. Die Zusage haelt, wer aufruft. Fuer |
| 28 | * Text aus fremder Hand heisst das: Er gehoert nicht hierher. Ein Kartendienst |
| 29 | * etwa zitiert in seiner Ausnahmemeldung ueblicherweise die gestellte Anfrage |
| 30 | * samt BBOX - also die Koordinaten des geplanten Knotenpunkts; der Griff |
| 31 | * `IPC.fetchMapImage` in electron/main.ts protokolliert deshalb nur die |
| 32 | * technische Einordnung und zeigt die Meldung des Dienstes allein an der |
| 33 | * Oberflaeche (Befund 31). |
| 34 | * |
| 35 | * WIE DIE DATEI ZUM ANWENDER KOMMT |
| 36 | * |
| 37 | * Hilfe -> "Fehlerprotokoll speichern …" schreibt eine Kopie an einen Ort, den |
| 38 | * der Anwender im Dialog waehlt (`speichereProtokollkopie` in electron/main.ts, |
| 39 | * Inhalt aus `protokollkopie` hier). Anlass: Je nach Installationsart leitet |
| 40 | * Windows neue Dateien unter %APPDATA% in einen eigenen Bereich um, waehrend |
| 41 | * `app.getPath('userData')` den unumgeleiteten Pfad nennt - das Programm liest |
| 42 | * seine Datei dort, der Anwender findet sie im Explorer dort nicht. Bestand |
| 43 | * %APPDATA%\lsa-planer-professional schon vorher - vom Setup oder vom |
| 44 | * tragbaren Programm -, landen auch neue Dateien dort, und der Pfad stimmt |
| 45 | * (gemessen am 17.09.2026 unter Windows 11; so auch docs/datenschutz.md). |
| 46 | * |
| 47 | * Die Kopie ist fuer jede Auslieferung und beide Faelle derselbe Weg. |
| 48 | */ |
| 49 | |
| 50 | const MAX_BYTES = 1_000_000; |
| 51 | |
| 52 | let cachedPath: string | null = null; |
| 53 | |
| 54 | /** Pfad der Protokolldatei. */ |
| 55 | export function protokollPfad(): string { |
| 56 | if (cachedPath !== null) return cachedPath; |
| 57 | const verzeichnis = app.getPath('userData'); |
| 58 | try { |
| 59 | mkdirSync(verzeichnis, { recursive: true }); |
| 60 | } catch { |
| 61 | /* Verzeichnis besteht bereits. */ |
| 62 | } |
| 63 | cachedPath = path.join(verzeichnis, 'fehlerprotokoll.log'); |
| 64 | return cachedPath; |
| 65 | } |
| 66 | |
| 67 | /** Schreibt eine Zeile in das Protokoll. Schlaegt niemals fehl. */ |
| 68 | export function protokolliere(quelle: string, nachricht: string, stapel?: string): void { |
| 69 | try { |
| 70 | const datei = protokollPfad(); |
| 71 | rotiereWennZuGross(datei); |
| 72 | const zeit = new Date().toISOString(); |
| 73 | const zeilen = [`[${zeit}] ${quelle}: ${einzeilig(nachricht)}`]; |
| 74 | if (stapel !== undefined && stapel !== '') { |
| 75 | for (const zeile of stapel.split('\n').slice(0, 12)) { |
| 76 | zeilen.push(` ${zeile.trim()}`); |
| 77 | } |
| 78 | } |
| 79 | appendFileSync(datei, `${zeilen.join('\n')}\n`, 'utf8'); |
| 80 | } catch { |
| 81 | // Ein Fehler beim Protokollieren darf niemals den Ablauf stoeren. |
| 82 | } |
| 83 | // Zusaetzlich auf die Konsole, damit ein Start aus der Kommandozeile alles zeigt. |
| 84 | console.error(`${quelle}: ${einzeilig(nachricht)}`); |
| 85 | } |
| 86 | |
| 87 | /** Uebernimmt eine Meldung aus dem Anzeigeprozess. */ |
| 88 | export function protokolliereMeldung(bericht: ErrorReport): void { |
| 89 | const zustand = |
| 90 | bericht.zustand === undefined |
| 91 | ? '' |
| 92 | : ` | Zustand: ${Object.entries(bericht.zustand) |
| 93 | .map(([k, v]) => `${k}=${String(v)}`) |
| 94 | .join(', ')}`; |
| 95 | protokolliere(`Anzeige/${bericht.quelle}`, `${bericht.nachricht}${zustand}`, bericht.stapel); |
| 96 | } |
| 97 | |
| 98 | /** Angaben der Startzeile. */ |
| 99 | export interface Startumgebung { |
| 100 | version: string; |
| 101 | electron: string; |
| 102 | chromium: string; |
| 103 | plattform: string; |
| 104 | architektur: string; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Inhalt der Startzeile - rein, damit er ohne laufendes Electron pruefbar ist. |
| 109 | * |
| 110 | * Nennt Fassung, Laufzeit und Plattform. Wie das Programm eingerichtet ist, |
| 111 | * nennt die Zeile nicht; wo Windows Dateien des Benutzerprofils umleitet, |
| 112 | * fuehrt "Fehlerprotokoll speichern …" trotzdem zur Datei (Kopf dieser Datei). |
| 113 | */ |
| 114 | export function startzeile(umgebung: Startumgebung): string { |
| 115 | return ( |
| 116 | `Version ${umgebung.version} · Electron ${umgebung.electron} · ` + |
| 117 | `Chromium ${umgebung.chromium} · ${umgebung.plattform} ${umgebung.architektur}` |
| 118 | ); |
| 119 | } |
| 120 | |
| 121 | /** Vermerkt Programmstart und Umgebung - hilft beim Einordnen spaeterer Eintraege. */ |
| 122 | export function protokolliereStart(): void { |
| 123 | protokolliere( |
| 124 | 'Start', |
| 125 | startzeile({ |
| 126 | version: app.getVersion(), |
| 127 | electron: process.versions.electron, |
| 128 | chromium: process.versions.chrome, |
| 129 | plattform: process.platform, |
| 130 | architektur: process.arch, |
| 131 | }), |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Name der abgeloesten Protokolldatei. |
| 137 | * |
| 138 | * Eine Stelle fuer Rotation und Kopie: Benennte die Rotation anders, als die |
| 139 | * Kopie liest, fehlte der aeltere Teil in der Kopie, ohne dass es jemand merkt. |
| 140 | */ |
| 141 | export function abgeloestesProtokoll(datei: string): string { |
| 142 | return `${datei}.alt`; |
| 143 | } |
| 144 | |
| 145 | /** Was in der Kopie steht, wenn es kein Protokoll gibt - sichtbarer Anwendertext. */ |
| 146 | export const KEIN_PROTOKOLL = |
| 147 | 'Kein Fehlerprotokoll vorhanden: Bis zum Anlegen dieser Kopie wurde nichts protokolliert.'; |
| 148 | |
| 149 | /** |
| 150 | * Inhalt der Kopie, die Hilfe -> "Fehlerprotokoll speichern …" schreibt. |
| 151 | * |
| 152 | * Rein: Die Lesefunktion kommt herein, damit sich die Zusammensetzung ohne |
| 153 | * Datentraeger pruefen laesst. `lies` liefert den Inhalt oder `null`, wenn es |
| 154 | * die Datei nicht gibt; jeder andere Fehlschlag wirft und wird hier nicht |
| 155 | * aufgefangen. |
| 156 | * |
| 157 | * WAS HINEINKOMMT: die abgeloeste Datei vor der laufenden, also in zeitlicher |
| 158 | * Reihenfolge - sonst nichts. Kein Kopf mit Pfaden: Der Pfad des |
| 159 | * Benutzerprofils traegt den Anmeldenamen, und die Kopie ist zum Weitergeben |
| 160 | * da. Kein Vermerk ueber die Kopie selbst: Der Dateiname traegt das Datum, und |
| 161 | * wer die Kopie beilegt, soll das Protokoll beilegen und keine Bearbeitung. |
| 162 | * |
| 163 | * GIBT ES KEIN PROTOKOLL - weder Datei noch Inhalt -, entsteht trotzdem eine |
| 164 | * Kopie mit der einen Zeile `KEIN_PROTOKOLL`, und kein Hinweisdialog. Zwei |
| 165 | * Gruende: Wer um das Protokoll gebeten wurde, kann damit tun, worum er gebeten |
| 166 | * wurde, statt einen Dialog nachzuerzaehlen. Und der Fall ist selbst ein |
| 167 | * Befund: `protokolliereStart` schreibt bei jedem Start eine Zeile, ein |
| 168 | * fehlendes Protokoll heisst also, dass das Schreiben scheitert oder die Datei |
| 169 | * entfernt wurde - genau das soll bei der Fehlersuche ankommen. Der Eintrag tut |
| 170 | * damit in jedem Zustand dasselbe: Dialog, Datei. |
| 171 | * |
| 172 | * Ein LESEFEHLER ist etwas anderes als eine fehlende Datei und wirft weiter: |
| 173 | * Eine Kopie "nichts protokolliert" ueber einer gesperrten Datei waere eine |
| 174 | * falsche Auskunft. |
| 175 | * |
| 176 | * Beide Dateien werden unmittelbar nacheinander gelesen. Geschrieben und |
| 177 | * rotiert wird nur im Hauptprozess und synchron (`protokolliere`); mit einer |
| 178 | * synchronen Lesefunktion faellt zwischen die beiden Lesevorgaenge keine |
| 179 | * Rotation. |
| 180 | */ |
| 181 | export function protokollkopie(datei: string, lies: (pfad: string) => string | null): string { |
| 182 | const teile = [lies(abgeloestesProtokoll(datei)), lies(datei)].filter( |
| 183 | (teil): teil is string => teil !== null && teil !== '', |
| 184 | ); |
| 185 | if (teile.length === 0) return `${KEIN_PROTOKOLL}\n`; |
| 186 | return teile.map((teil) => (teil.endsWith('\n') ? teil : `${teil}\n`)).join(''); |
| 187 | } |
| 188 | |
| 189 | /** |
| 190 | * Liest eine Protokolldatei fuer die Kopie: Inhalt, oder `null`, wenn es sie |
| 191 | * nicht gibt. |
| 192 | * |
| 193 | * Nur ENOENT heisst "gibt es nicht". Alles andere - gesperrt, kein Leserecht, |
| 194 | * ein Ordner an der Stelle - wirft weiter; siehe `protokollkopie`. |
| 195 | */ |
| 196 | export function liesProtokolldatei(pfad: string): string | null { |
| 197 | try { |
| 198 | return readFileSync(pfad, 'utf8'); |
| 199 | } catch (fehler) { |
| 200 | if ((fehler as NodeJS.ErrnoException).code === 'ENOENT') return null; |
| 201 | throw fehler; |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Ist `ziel` eine der beiden Protokolldateien - die laufende `datei` oder die |
| 207 | * abgeloeste daneben? |
| 208 | * |
| 209 | * Anlass (behoben mit 5.43.0): Die Kurzhilfe zeigt den Pfad des Protokolls |
| 210 | * als auswaehlbaren Text. Wer ihn in den Speichern-Dialog der Kopie einfuegt |
| 211 | * und das Ueberschreiben bestaetigt, liess `schreibeUnteilbar` das laufende |
| 212 | * Protokoll durch "abgeloest + laufend" ersetzen; die naechste Kopie enthielt |
| 213 | * den abgeloesten Teil doppelt und nicht mehr in zeitlicher Folge, und die |
| 214 | * naechste Rotation kam frueher. Mit der abgeloesten Datei als Ziel wurde der |
| 215 | * laufende Teil doppelt. |
| 216 | * |
| 217 | * ZWEI PRUEFUNGEN, weil keine allein traegt: |
| 218 | * |
| 219 | * 1. Die Dateikennung, `dev` und `ino` - als bigint, denn unter NTFS belegt |
| 220 | * die Dateinummer 64 Bit (Satznummer und Folgenummer) und ist als `number` |
| 221 | * oberhalb von 53 Bit nicht mehr genau. Sie erkennt dieselbe Datei unter |
| 222 | * einem anderen Pfad: ueber eine Verzeichnisverknuepfung (nachgestellt in |
| 223 | * tests/electron/protokollWeitergabe.test.ts) und unter einem |
| 224 | * 8.3-Kurznamen (Benutzerordner als Kurzname gegen den langen Namen, am |
| 225 | * 17.09.2026 unter Windows 11 nachgesehen; kein Pruefstand, weil nicht |
| 226 | * jeder Datentraeger Kurznamen anlegt). Ob sie unter einem von Windows |
| 227 | * umgeleiteten Pfad und unter dem, den das Programm kennt, gleich ist, ist |
| 228 | * nicht nachgesehen. |
| 229 | * 2. Der aufgeloeste Pfad, unter Windows ohne Unterscheidung von Gross- und |
| 230 | * Kleinschreibung. Er traegt, wo es keine Kennung gibt: bei einer |
| 231 | * abgeloesten Datei, die es noch nicht gibt. |
| 232 | * |
| 233 | * Nicht erkannt wird eine abgeloeste Datei, die es noch nicht gibt, unter einem |
| 234 | * anderen Pfad als dem, den das Programm kennt: Sie hat weder eine Kennung noch |
| 235 | * denselben Pfad. |
| 236 | * |
| 237 | * Nur ENOENT heisst "keine Kennung", wie bei `liesProtokolldatei`; jeder andere |
| 238 | * Fehlschlag beim Nachsehen wirft weiter. Eine Dateinummer 0 gilt ebenfalls als |
| 239 | * keine Kennung: Nicht jedes Dateisystem fuehrt Dateinummern, und gleiche |
| 240 | * Kennungen hiessen dort nicht dieselbe Datei. Gesehen ist ein solcher |
| 241 | * Datentraeger hier nicht - die Wache steht, damit eine Kopie auf einer |
| 242 | * Netzfreigabe nicht an einer bedeutungslosen Gleichheit scheitert. |
| 243 | * |
| 244 | * Nachgesehen wird synchron und unmittelbar hintereinander; eine Rotation |
| 245 | * (`protokolliere`, ebenfalls synchron) faellt nicht dazwischen. |
| 246 | */ |
| 247 | export function istProtokolldatei(ziel: string, datei: string): boolean { |
| 248 | const protokolldateien = [datei, abgeloestesProtokoll(datei)]; |
| 249 | const zielschluessel = pfadschluessel(ziel); |
| 250 | if (protokolldateien.some((pfad) => pfadschluessel(pfad) === zielschluessel)) return true; |
| 251 | const zielkennung = dateikennung(ziel); |
| 252 | if (zielkennung === null) return false; |
| 253 | return protokolldateien.some((pfad) => { |
| 254 | const kennung = dateikennung(pfad); |
| 255 | return kennung !== null && kennung.dev === zielkennung.dev && kennung.ino === zielkennung.ino; |
| 256 | }); |
| 257 | } |
| 258 | |
| 259 | /** Aufgeloester Pfad; unter Windows ohne Unterscheidung von Gross- und Kleinschreibung. */ |
| 260 | function pfadschluessel(pfad: string): string { |
| 261 | const aufgeloest = path.resolve(pfad); |
| 262 | return process.platform === 'win32' ? aufgeloest.toLowerCase() : aufgeloest; |
| 263 | } |
| 264 | |
| 265 | /** Datentraeger und Dateinummer, oder `null`, wo es keine gibt - siehe `istProtokolldatei`. */ |
| 266 | function dateikennung(pfad: string): { dev: bigint; ino: bigint } | null { |
| 267 | try { |
| 268 | const { dev, ino } = statSync(pfad, { bigint: true }); |
| 269 | return ino === 0n ? null : { dev, ino }; |
| 270 | } catch (fehler) { |
| 271 | if ((fehler as NodeJS.ErrnoException).code === 'ENOENT') return null; |
| 272 | throw fehler; |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | /** |
| 277 | * Vorgeschlagener Dateiname der Kopie, mit oertlichem Datum. |
| 278 | * |
| 279 | * Oertlich und nicht nach Weltzeit: `toISOString` naennte in Deutschland |
| 280 | * zwischen Mitternacht und ein beziehungsweise zwei Uhr den Vortag, und der |
| 281 | * Anwender saehe ein Datum, das nicht seines ist. |
| 282 | */ |
| 283 | export function protokollDateiname(jetzt: Date): string { |
| 284 | const jahr = String(jetzt.getFullYear()); |
| 285 | const monat = String(jetzt.getMonth() + 1).padStart(2, '0'); |
| 286 | const tag = String(jetzt.getDate()).padStart(2, '0'); |
| 287 | return `LSA-Planer-Fehlerprotokoll-${jahr}-${monat}-${tag}.txt`; |
| 288 | } |
| 289 | |
| 290 | function rotiereWennZuGross(datei: string): void { |
| 291 | try { |
| 292 | if (statSync(datei).size < MAX_BYTES) return; |
| 293 | renameSync(datei, abgeloestesProtokoll(datei)); |
| 294 | } catch { |
| 295 | /* Datei besteht noch nicht oder laesst sich nicht umbenennen. */ |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | function einzeilig(text: string): string { |
| 300 | return text.replace(/\s*\n\s*/g, ' ⏎ ').slice(0, 2000); |
| 301 | } |