/** * Persistenz der Nutzereinstellungen als JSON-Datei im `userData`-Verzeichnis. * * Bewusst ohne zusätzliche Abhängigkeit: es geht um wenige Bytes, und jede * eingelesene Datei wird streng validiert, bevor sie verwendet wird. */ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { app } from 'electron'; import { anzeigegroesseBereinigen } from '../shared/ansicht'; import { fensterlageBereinigen } from '../shared/fensterlage'; import { sprechtempoBereinigen } from '../shared/sprechtempo'; import { textabstandBereinigen } from '../shared/textabstand'; import { EINSTELLUNGEN_STANDARD, type Einstellungen } from '../shared/ipc'; import { istThemeAuswahl } from '../shared/theme'; function dateipfad(): string { return join(app.getPath('userData'), 'einstellungen.json'); } /** * Nimmt beliebige Eingaben entgegen (Datei-Inhalt ODER IPC-Nutzlast aus dem * Renderer) und gibt garantiert ein gültiges Einstellungsobjekt zurück. */ export function einstellungenBereinigen(roh: unknown): Einstellungen { if (typeof roh !== 'object' || roh === null) { return EINSTELLUNGEN_STANDARD; } const kandidat = roh as Record; const thema = kandidat['thema']; const profilId = kandidat['profilId']; const anzeigegroesse = kandidat['anzeigegroesse']; const optionenMischen = kandidat['optionenMischen']; const antwortzahlVerbergen = kandidat['antwortzahlVerbergen']; const vorlesen = kandidat['vorlesen']; const vorlesenAutomatisch = kandidat['vorlesenAutomatisch']; const vorlesenErklaerung = kandidat['vorlesenErklaerungBeiFehler']; const zuschnittGefragt = kandidat['zuschnittGefragt']; const tastenkuerzel = kandidat['tastenkuerzel']; const letzteSicherung = kandidat['letzteSicherung']; /* Ein Zeitpunkt, der sich nicht lesen lässt, ist kein Zeitpunkt. Er wird weggelassen statt auf „jetzt“ gesetzt: Die Karte sagt dann „noch nie gesichert“, und das ist die harmlosere der beiden Unwahrheiten. */ const gueltigeSicherung = typeof letzteSicherung === 'string' && !Number.isNaN(Date.parse(letzteSicherung)); const fensterlage = fensterlageBereinigen(kandidat['fenster']); const sitzungsumfang = kandidat['sitzungsumfang']; /* Dieselbe Spanne, die `Lernstand.sitzung` annimmt – eine Einstellung, die der Kern danach zurückweist, wäre eine Falle. */ const gueltigerUmfang = typeof sitzungsumfang === 'number' && Number.isInteger(sitzungsumfang) && sitzungsumfang >= 1 && sitzungsumfang <= 1000; const gueltigeProfilId = typeof profilId === 'number' && Number.isInteger(profilId) && profilId > 0; return { thema: istThemeAuswahl(thema) ? thema : EINSTELLUNGEN_STANDARD.thema, ...(gueltigerUmfang ? { sitzungsumfang } : {}), /* Beide Schwierigkeitsregler fallen auf „aus" zurück – dieselbe Aussage wie in EINSTELLUNGEN_STANDARD. Stünde hier ein anderer Rückfallwert, hebelte er den Standard aus, sobald das Feld dort einmal fehlt. */ optionenMischen: typeof optionenMischen === 'boolean' ? optionenMischen : (EINSTELLUNGEN_STANDARD.optionenMischen ?? false), antwortzahlVerbergen: typeof antwortzahlVerbergen === 'boolean' ? antwortzahlVerbergen : (EINSTELLUNGEN_STANDARD.antwortzahlVerbergen ?? false), vorlesen: typeof vorlesen === 'boolean' ? vorlesen : (EINSTELLUNGEN_STANDARD.vorlesen ?? false), vorlesenAutomatisch: typeof vorlesenAutomatisch === 'boolean' ? vorlesenAutomatisch : (EINSTELLUNGEN_STANDARD.vorlesenAutomatisch ?? false), vorlesenErklaerungBeiFehler: typeof vorlesenErklaerung === 'boolean' ? vorlesenErklaerung : (EINSTELLUNGEN_STANDARD.vorlesenErklaerungBeiFehler ?? false), zuschnittGefragt: typeof zuschnittGefragt === 'boolean' ? zuschnittGefragt : (EINSTELLUNGEN_STANDARD.zuschnittGefragt ?? false), /* Bereinigt auf eine gültige Stufe: Ein Wert aus einer früheren Fassung oder von Hand in die Datei geschrieben soll die Anwendung nicht in eine unbedienbare Größe zwingen. */ anzeigegroesse: anzeigegroesseBereinigen(anzeigegroesse), /* Wie die Anzeigegröße auf eine gültige Stufe gebracht: Ein Wert aus einer früheren Fassung oder von Hand in die Datei geschrieben soll die Darstellung nicht auf einen Zustand bringen, den es nicht gibt. */ textabstand: textabstandBereinigen(kandidat['textabstand']), sprechtempo: sprechtempoBereinigen(kandidat['sprechtempo']), /* Wie `tastenkuerzel` nur durchgereicht, nicht aufgefüllt: Ohne gespeicherte Lage soll das Fenster die Vorgabe bekommen, nicht eine erfundene Größe. */ ...(fensterlage === null ? {} : { fenster: fensterlage }), // `exactOptionalPropertyTypes`: das Feld darf nur gesetzt werden, wenn es // wirklich einen Wert hat. ...(gueltigeProfilId ? { profilId } : {}), /* Wie `profilId` nur durchgereicht, NICHT aufgefüllt: Das Fehlen des Feldes ist hier selbst eine Aussage – „noch nicht entschieden“ – und steuert die Voreinstellung nach erkanntem Hilfsmittel. Ein Rückfallwert machte daraus stillschweigend eine Entscheidung. */ ...(typeof tastenkuerzel === 'boolean' ? { tastenkuerzel } : {}), /* Ebenfalls nicht aufgefüllt: Das Fehlen heißt „noch nie gesichert“ und ist eine Aussage, kein fehlender Wert. */ ...(gueltigeSicherung ? { letzteSicherung } : {}), }; } export function einstellungenLesen(): Einstellungen { const pfad = dateipfad(); if (!existsSync(pfad)) { return EINSTELLUNGEN_STANDARD; } try { return einstellungenBereinigen(JSON.parse(readFileSync(pfad, 'utf8'))); } catch (fehler) { console.warn('[einstellungen] Datei nicht lesbar, verwende Standardwerte:', fehler); return EINSTELLUNGEN_STANDARD; } } /** * Schreibt Einstellungen – als Änderung, nicht als Ersatz. * * Der Renderer schickt nur die Felder, die er ändern will; sie werden über den * gespeicherten Stand gelegt. Ohne dieses Zusammenlegen würde jede * Themenumschaltung alle übrigen Einstellungen auf die Standardwerte * zurücksetzen, weil {@link einstellungenBereinigen} fehlende Felder auffüllt. * Heute folgenlos – sobald aber eine zweite Einstellung wirklich benutzt wird, * ein stiller Datenverlust. * * Wirft, wenn nicht geschrieben werden konnte. Das ist der Unterschied zu * vorher: Ein voller Datenträger oder ein schreibgeschütztes Verzeichnis blieb * unbemerkt – die Umstellung griff sofort und war nach dem Neustart wieder * weg, ohne dass je etwas gemeldet wurde. * * **Geschrieben wird über eine Nebendatei.** Ginge es geradewegs in die * Zieldatei, hinterließe ein Abbruch mitten im Schreiben – Stromausfall, * voller Datenträger nach dem halben Puffer – eine verstümmelte Datei. * {@link einstellungenLesen} fiele dann sauber auf die Werksvorgaben zurück, * aber der Nutzer verlöre wortlos Farbschema, Anzeigegröße, Vorlesen, * Tastenkürzel und das zuletzt benutzte Profil. `rename` innerhalb desselben * Verzeichnisses ist unter Windows wie unter POSIX unteilbar: Es liegt * entweder der alte oder der neue Stand da, nie ein halber. Das war der * einzige nicht unteilbare Schreibweg der Anwendung – der Lernstand ist * transaktional, die Sicherung nutzt `VACUUM INTO`. */ export function einstellungenSchreiben(roh: unknown): Einstellungen { const aenderung = typeof roh === 'object' && roh !== null ? roh : {}; const bereinigt = einstellungenBereinigen({ ...einstellungenLesen(), ...aenderung }); const pfad = dateipfad(); const neben = `${pfad}.neu`; try { mkdirSync(dirname(pfad), { recursive: true }); writeFileSync(neben, `${JSON.stringify(bereinigt, null, 2)}\n`, 'utf8'); renameSync(neben, pfad); } catch (fehler: unknown) { /* Die Nebendatei nicht liegen lassen: Ein halb geschriebener Rest neben der gültigen Datei verwirrt beim Nachsehen und belegt Platz, den es gerade nicht gab. Scheitert auch das, bleibt es dabei – der ursprüngliche Fehler ist der, der gemeldet gehört. */ try { rmSync(neben, { force: true }); } catch { /* bewusst still */ } const grund = fehler instanceof Error ? fehler.message : String(fehler); /* `cause` hängt den ursprünglichen Fehler an, statt ihn wegzuwerfen. Die lesbare Meldung geht an den Nutzer, der Systemfehlercode (ENOSPC, EACCES) und die Aufrufliste bleiben für die Fehlersuche erhalten – genau die Angaben, die bei „konnte nicht gespeichert werden“ zählen. */ throw new Error(`Die Einstellungen konnten nicht gespeichert werden: ${grund}`, { cause: fehler, }); } return bereinigt; }