lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ electron protokoll.ts

12,6 KB Rohdatei
electron/protokoll.ts — 301 Zeilen
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 }