waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src main protokoll.ts

5,4 KB Rohdatei
app/src/main/protokoll.ts — 144 Zeilen
1 /**
2 * Ein kleines Fehlerprotokoll auf der Platte.
3 *
4 * **Wozu.** Der Rückmeldeweg dieser Anwendung ist eine E-Mail mit einem
5 * kopierbaren Text (`shared/meldetext.ts`). Für einen Fehler an einer Frage
6 * genügt das. Für „es stürzt manchmal ab“ genügt es nicht: Bis 0.22.0 landeten
7 * sämtliche Fehler des Hauptprozesses ausschließlich in `console.error` – und
8 * die sieht im gepackten Programm niemand, weil es ohne Konsole startet. Wer
9 * melden wollte, hatte nichts in der Hand, und wer die Meldung bearbeiten
10 * sollte, bekam „irgendwann geht es einfach zu“.
11 *
12 * **Was das hier nicht ist.** Keine Telemetrie. Die Datei geht nirgendwohin;
13 * die Anwendung hat keinen Weg nach außen, über den sie das könnte. Sie liegt
14 * im `userData`-Verzeichnis, und wer sie mitschicken will, schickt sie von
15 * Hand mit – so wie den Meldetext auch.
16 *
17 * **Was hineingeschrieben werden darf.** Dieselbe Strenge wie im Meldetext:
18 * kein Profilname, kein Lernstand, keine gegebene Antwort, kein Dateipfad mit
19 * Benutzernamen. Ein Absturzgrund und eine Fehlermeldung haben einen Zweck für
20 * die Bearbeitung – ein Heimatverzeichnis hat ihn nicht. {@link entpersoniert}
21 * nimmt Pfade heraus, bevor irgendetwas geschrieben wird, und zwar an genau
22 * einer Stelle: Zwei Reinigungen an zwei Stellen laufen auseinander.
23 *
24 * **Warum keine Rotation über mehrere Dateien.** Eine Datei mit Obergrenze ist
25 * hier das Richtige: Wer sie mitschicken soll, soll eine mitschicken, nicht
26 * vier. Läuft sie über, wird die ältere Hälfte weggeschnitten – der Anfang
27 * eines Fehlers ist selten so wichtig wie sein Ende.
28 */
29
30 import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
31 import { homedir } from 'node:os';
32 import { dirname, join } from 'node:path';
33
34 /** Dateiname im `userData`-Verzeichnis. */
35 export const PROTOKOLL_DATEI = 'fehlerprotokoll.txt';
36
37 /**
38 * Obergrenze der Datei.
39 *
40 * Groß genug für die Vorgeschichte mehrerer Abstürze, klein genug, um sie
41 * anzusehen und an eine E-Mail zu hängen.
42 */
43 export const MAX_BYTES = 256 * 1024;
44
45 /** Eine einzelne Zeile wird nie länger – sonst füllte ein Fehler die Datei. */
46 const MAX_ZEILE = 2000;
47
48 /** Wohin geschrieben wird; wird beim Einrichten gesetzt. */
49 let ziel: string | null = null;
50
51 /**
52 * Nimmt Personenbezogenes aus einem Text.
53 *
54 * Betrifft in der Praxis genau eine Sorte Angabe, und die steckt in fast jeder
55 * Fehlermeldung des Dateisystems: den Pfad zum Heimatverzeichnis, der unter
56 * Windows wie unter macOS den Anmeldenamen enthält. Er wird durch `<Benutzer>`
57 * ersetzt – die Meldung bleibt lesbar, der Name bleibt hier.
58 *
59 * Zusätzlich fliegen Zeilenumbrüche heraus: Eine Zeile ist ein Eintrag.
60 * Andernfalls ließe sich eine Fehlermeldung so wählen, dass sie im Protokoll
61 * wie mehrere Einträge aussieht.
62 */
63 export function entpersoniert(text: string, heim: string = homedir()): string {
64 const flach = text.replace(/[\r\n]+/gu, ' ').trim();
65 if (heim === '') {
66 return flach.slice(0, MAX_ZEILE);
67 }
68
69 /* Beide Trennzeichen: Node liefert Windows-Pfade mit Rückstrich, viele
70 Meldungen aus Chromium und SQLite mit Schrägstrich. */
71 const varianten = [heim, heim.replace(/\\/gu, '/')];
72 let sauber = flach;
73 for (const variante of varianten) {
74 sauber = sauber.split(variante).join('<Benutzer>');
75 }
76 return sauber.slice(0, MAX_ZEILE);
77 }
78
79 /** Legt den Ablageort fest. Ohne diesen Aufruf wird nichts geschrieben. */
80 export function protokollEinrichten(userData: string): void {
81 ziel = join(userData, PROTOKOLL_DATEI);
82 }
83
84 /** Der Ablageort, oder `null` – für die Anzeige im Systemzustand. */
85 export function protokollPfad(): string | null {
86 return ziel;
87 }
88
89 /** Gegenstück für Tests. */
90 export function protokollVergessen(): void {
91 ziel = null;
92 }
93
94 /** Schneidet die ältere Hälfte weg, wenn die Datei zu groß geworden ist. */
95 function kuerzenWennNoetig(pfad: string): void {
96 if (!existsSync(pfad) || statSync(pfad).size <= MAX_BYTES) {
97 return;
98 }
99
100 const inhalt = readFileSync(pfad, 'utf8');
101 const rest = inhalt.slice(Math.floor(inhalt.length / 2));
102 /* Am nächsten Zeilenanfang ansetzen, damit oben keine halbe Zeile steht. */
103 const ab = rest.indexOf('\n');
104 const gekuerzt = ab === -1 ? '' : rest.slice(ab + 1);
105
106 const neben = `${pfad}.neu`;
107 appendFileSync(neben, `[gekürzt] Ältere Einträge wurden entfernt.\n${gekuerzt}`, {
108 encoding: 'utf8',
109 flag: 'w',
110 });
111 renameSync(neben, pfad);
112 }
113
114 /**
115 * Schreibt eine Zeile.
116 *
117 * Wirft nie: Ein Protokoll, das die Anwendung zu Fall bringt, wäre schlimmer
118 * als keines. Misslingt das Schreiben – volle Platte, fehlendes Recht –,
119 * bleibt es bei der Konsolenausgabe des Aufrufers.
120 */
121 export function protokollieren(bereich: string, meldung: string, jetzt: Date = new Date()): void {
122 const pfad = ziel;
123 if (pfad === null) {
124 return;
125 }
126
127 try {
128 mkdirSync(dirname(pfad), { recursive: true });
129 kuerzenWennNoetig(pfad);
130 const zeile = `${jetzt.toISOString()} [${bereich}] ${entpersoniert(meldung)}\n`;
131 appendFileSync(pfad, zeile, 'utf8');
132 } catch {
133 /* Bewusst still: siehe oben. */
134 }
135 }
136
137 /** Fehlerobjekt oder beliebiger Wert als eine Zeile. */
138 export function fehlerZeile(fehler: unknown): string {
139 if (fehler instanceof Error) {
140 const erste = (fehler.stack ?? '').split('\n')[1]?.trim() ?? '';
141 return erste === '' ? fehler.message : `${fehler.message} — ${erste}`;
142 }
143 return String(fehler);
144 }