waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | } |