/** * Ein kleines Fehlerprotokoll auf der Platte. * * **Wozu.** Der Rückmeldeweg dieser Anwendung ist eine E-Mail mit einem * kopierbaren Text (`shared/meldetext.ts`). Für einen Fehler an einer Frage * genügt das. Für „es stürzt manchmal ab“ genügt es nicht: Bis 0.22.0 landeten * sämtliche Fehler des Hauptprozesses ausschließlich in `console.error` – und * die sieht im gepackten Programm niemand, weil es ohne Konsole startet. Wer * melden wollte, hatte nichts in der Hand, und wer die Meldung bearbeiten * sollte, bekam „irgendwann geht es einfach zu“. * * **Was das hier nicht ist.** Keine Telemetrie. Die Datei geht nirgendwohin; * die Anwendung hat keinen Weg nach außen, über den sie das könnte. Sie liegt * im `userData`-Verzeichnis, und wer sie mitschicken will, schickt sie von * Hand mit – so wie den Meldetext auch. * * **Was hineingeschrieben werden darf.** Dieselbe Strenge wie im Meldetext: * kein Profilname, kein Lernstand, keine gegebene Antwort, kein Dateipfad mit * Benutzernamen. Ein Absturzgrund und eine Fehlermeldung haben einen Zweck für * die Bearbeitung – ein Heimatverzeichnis hat ihn nicht. {@link entpersoniert} * nimmt Pfade heraus, bevor irgendetwas geschrieben wird, und zwar an genau * einer Stelle: Zwei Reinigungen an zwei Stellen laufen auseinander. * * **Warum keine Rotation über mehrere Dateien.** Eine Datei mit Obergrenze ist * hier das Richtige: Wer sie mitschicken soll, soll eine mitschicken, nicht * vier. Läuft sie über, wird die ältere Hälfte weggeschnitten – der Anfang * eines Fehlers ist selten so wichtig wie sein Ende. */ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; /** Dateiname im `userData`-Verzeichnis. */ export const PROTOKOLL_DATEI = 'fehlerprotokoll.txt'; /** * Obergrenze der Datei. * * Groß genug für die Vorgeschichte mehrerer Abstürze, klein genug, um sie * anzusehen und an eine E-Mail zu hängen. */ export const MAX_BYTES = 256 * 1024; /** Eine einzelne Zeile wird nie länger – sonst füllte ein Fehler die Datei. */ const MAX_ZEILE = 2000; /** Wohin geschrieben wird; wird beim Einrichten gesetzt. */ let ziel: string | null = null; /** * Nimmt Personenbezogenes aus einem Text. * * Betrifft in der Praxis genau eine Sorte Angabe, und die steckt in fast jeder * Fehlermeldung des Dateisystems: den Pfad zum Heimatverzeichnis, der unter * Windows wie unter macOS den Anmeldenamen enthält. Er wird durch `` * ersetzt – die Meldung bleibt lesbar, der Name bleibt hier. * * Zusätzlich fliegen Zeilenumbrüche heraus: Eine Zeile ist ein Eintrag. * Andernfalls ließe sich eine Fehlermeldung so wählen, dass sie im Protokoll * wie mehrere Einträge aussieht. */ export function entpersoniert(text: string, heim: string = homedir()): string { const flach = text.replace(/[\r\n]+/gu, ' ').trim(); if (heim === '') { return flach.slice(0, MAX_ZEILE); } /* Beide Trennzeichen: Node liefert Windows-Pfade mit Rückstrich, viele Meldungen aus Chromium und SQLite mit Schrägstrich. */ const varianten = [heim, heim.replace(/\\/gu, '/')]; let sauber = flach; for (const variante of varianten) { sauber = sauber.split(variante).join(''); } return sauber.slice(0, MAX_ZEILE); } /** Legt den Ablageort fest. Ohne diesen Aufruf wird nichts geschrieben. */ export function protokollEinrichten(userData: string): void { ziel = join(userData, PROTOKOLL_DATEI); } /** Der Ablageort, oder `null` – für die Anzeige im Systemzustand. */ export function protokollPfad(): string | null { return ziel; } /** Gegenstück für Tests. */ export function protokollVergessen(): void { ziel = null; } /** Schneidet die ältere Hälfte weg, wenn die Datei zu groß geworden ist. */ function kuerzenWennNoetig(pfad: string): void { if (!existsSync(pfad) || statSync(pfad).size <= MAX_BYTES) { return; } const inhalt = readFileSync(pfad, 'utf8'); const rest = inhalt.slice(Math.floor(inhalt.length / 2)); /* Am nächsten Zeilenanfang ansetzen, damit oben keine halbe Zeile steht. */ const ab = rest.indexOf('\n'); const gekuerzt = ab === -1 ? '' : rest.slice(ab + 1); const neben = `${pfad}.neu`; appendFileSync(neben, `[gekürzt] Ältere Einträge wurden entfernt.\n${gekuerzt}`, { encoding: 'utf8', flag: 'w', }); renameSync(neben, pfad); } /** * Schreibt eine Zeile. * * Wirft nie: Ein Protokoll, das die Anwendung zu Fall bringt, wäre schlimmer * als keines. Misslingt das Schreiben – volle Platte, fehlendes Recht –, * bleibt es bei der Konsolenausgabe des Aufrufers. */ export function protokollieren(bereich: string, meldung: string, jetzt: Date = new Date()): void { const pfad = ziel; if (pfad === null) { return; } try { mkdirSync(dirname(pfad), { recursive: true }); kuerzenWennNoetig(pfad); const zeile = `${jetzt.toISOString()} [${bereich}] ${entpersoniert(meldung)}\n`; appendFileSync(pfad, zeile, 'utf8'); } catch { /* Bewusst still: siehe oben. */ } } /** Fehlerobjekt oder beliebiger Wert als eine Zeile. */ export function fehlerZeile(fehler: unknown): string { if (fehler instanceof Error) { const erste = (fehler.stack ?? '').split('\n')[1]?.trim() ?? ''; return erste === '' ? fehler.message : `${fehler.message} — ${erste}`; } return String(fehler); }