waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src main einstellungen.ts
| 1 | /** |
| 2 | * Persistenz der Nutzereinstellungen als JSON-Datei im `userData`-Verzeichnis. |
| 3 | * |
| 4 | * Bewusst ohne zusätzliche Abhängigkeit: es geht um wenige Bytes, und jede |
| 5 | * eingelesene Datei wird streng validiert, bevor sie verwendet wird. |
| 6 | */ |
| 7 | |
| 8 | import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; |
| 9 | import { dirname, join } from 'node:path'; |
| 10 | |
| 11 | import { app } from 'electron'; |
| 12 | |
| 13 | import { anzeigegroesseBereinigen } from '../shared/ansicht'; |
| 14 | import { fensterlageBereinigen } from '../shared/fensterlage'; |
| 15 | import { sprechtempoBereinigen } from '../shared/sprechtempo'; |
| 16 | import { textabstandBereinigen } from '../shared/textabstand'; |
| 17 | import { EINSTELLUNGEN_STANDARD, type Einstellungen } from '../shared/ipc'; |
| 18 | import { istThemeAuswahl } from '../shared/theme'; |
| 19 | |
| 20 | function dateipfad(): string { |
| 21 | return join(app.getPath('userData'), 'einstellungen.json'); |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * Nimmt beliebige Eingaben entgegen (Datei-Inhalt ODER IPC-Nutzlast aus dem |
| 26 | * Renderer) und gibt garantiert ein gültiges Einstellungsobjekt zurück. |
| 27 | */ |
| 28 | export function einstellungenBereinigen(roh: unknown): Einstellungen { |
| 29 | if (typeof roh !== 'object' || roh === null) { |
| 30 | return EINSTELLUNGEN_STANDARD; |
| 31 | } |
| 32 | |
| 33 | const kandidat = roh as Record<string, unknown>; |
| 34 | |
| 35 | const thema = kandidat['thema']; |
| 36 | const profilId = kandidat['profilId']; |
| 37 | const anzeigegroesse = kandidat['anzeigegroesse']; |
| 38 | const optionenMischen = kandidat['optionenMischen']; |
| 39 | const antwortzahlVerbergen = kandidat['antwortzahlVerbergen']; |
| 40 | const vorlesen = kandidat['vorlesen']; |
| 41 | const vorlesenAutomatisch = kandidat['vorlesenAutomatisch']; |
| 42 | const vorlesenErklaerung = kandidat['vorlesenErklaerungBeiFehler']; |
| 43 | const zuschnittGefragt = kandidat['zuschnittGefragt']; |
| 44 | const tastenkuerzel = kandidat['tastenkuerzel']; |
| 45 | const letzteSicherung = kandidat['letzteSicherung']; |
| 46 | |
| 47 | /* Ein Zeitpunkt, der sich nicht lesen lässt, ist kein Zeitpunkt. Er wird |
| 48 | weggelassen statt auf „jetzt“ gesetzt: Die Karte sagt dann „noch nie |
| 49 | gesichert“, und das ist die harmlosere der beiden Unwahrheiten. */ |
| 50 | const gueltigeSicherung = |
| 51 | typeof letzteSicherung === 'string' && !Number.isNaN(Date.parse(letzteSicherung)); |
| 52 | |
| 53 | const fensterlage = fensterlageBereinigen(kandidat['fenster']); |
| 54 | |
| 55 | const sitzungsumfang = kandidat['sitzungsumfang']; |
| 56 | /* Dieselbe Spanne, die `Lernstand.sitzung` annimmt – eine Einstellung, die |
| 57 | der Kern danach zurückweist, wäre eine Falle. */ |
| 58 | const gueltigerUmfang = |
| 59 | typeof sitzungsumfang === 'number' && |
| 60 | Number.isInteger(sitzungsumfang) && |
| 61 | sitzungsumfang >= 1 && |
| 62 | sitzungsumfang <= 1000; |
| 63 | |
| 64 | const gueltigeProfilId = |
| 65 | typeof profilId === 'number' && Number.isInteger(profilId) && profilId > 0; |
| 66 | |
| 67 | return { |
| 68 | thema: istThemeAuswahl(thema) ? thema : EINSTELLUNGEN_STANDARD.thema, |
| 69 | ...(gueltigerUmfang ? { sitzungsumfang } : {}), |
| 70 | /* Beide Schwierigkeitsregler fallen auf „aus" zurück – dieselbe Aussage |
| 71 | wie in EINSTELLUNGEN_STANDARD. Stünde hier ein anderer Rückfallwert, |
| 72 | hebelte er den Standard aus, sobald das Feld dort einmal fehlt. */ |
| 73 | optionenMischen: |
| 74 | typeof optionenMischen === 'boolean' |
| 75 | ? optionenMischen |
| 76 | : (EINSTELLUNGEN_STANDARD.optionenMischen ?? false), |
| 77 | antwortzahlVerbergen: |
| 78 | typeof antwortzahlVerbergen === 'boolean' |
| 79 | ? antwortzahlVerbergen |
| 80 | : (EINSTELLUNGEN_STANDARD.antwortzahlVerbergen ?? false), |
| 81 | vorlesen: typeof vorlesen === 'boolean' ? vorlesen : (EINSTELLUNGEN_STANDARD.vorlesen ?? false), |
| 82 | vorlesenAutomatisch: |
| 83 | typeof vorlesenAutomatisch === 'boolean' |
| 84 | ? vorlesenAutomatisch |
| 85 | : (EINSTELLUNGEN_STANDARD.vorlesenAutomatisch ?? false), |
| 86 | vorlesenErklaerungBeiFehler: |
| 87 | typeof vorlesenErklaerung === 'boolean' |
| 88 | ? vorlesenErklaerung |
| 89 | : (EINSTELLUNGEN_STANDARD.vorlesenErklaerungBeiFehler ?? false), |
| 90 | zuschnittGefragt: |
| 91 | typeof zuschnittGefragt === 'boolean' |
| 92 | ? zuschnittGefragt |
| 93 | : (EINSTELLUNGEN_STANDARD.zuschnittGefragt ?? false), |
| 94 | /* Bereinigt auf eine gültige Stufe: Ein Wert aus einer früheren Fassung |
| 95 | oder von Hand in die Datei geschrieben soll die Anwendung nicht in |
| 96 | eine unbedienbare Größe zwingen. */ |
| 97 | anzeigegroesse: anzeigegroesseBereinigen(anzeigegroesse), |
| 98 | /* Wie die Anzeigegröße auf eine gültige Stufe gebracht: Ein Wert aus |
| 99 | einer früheren Fassung oder von Hand in die Datei geschrieben soll |
| 100 | die Darstellung nicht auf einen Zustand bringen, den es nicht gibt. */ |
| 101 | textabstand: textabstandBereinigen(kandidat['textabstand']), |
| 102 | sprechtempo: sprechtempoBereinigen(kandidat['sprechtempo']), |
| 103 | /* Wie `tastenkuerzel` nur durchgereicht, nicht aufgefüllt: Ohne |
| 104 | gespeicherte Lage soll das Fenster die Vorgabe bekommen, nicht eine |
| 105 | erfundene Größe. */ |
| 106 | ...(fensterlage === null ? {} : { fenster: fensterlage }), |
| 107 | // `exactOptionalPropertyTypes`: das Feld darf nur gesetzt werden, wenn es |
| 108 | // wirklich einen Wert hat. |
| 109 | ...(gueltigeProfilId ? { profilId } : {}), |
| 110 | /* Wie `profilId` nur durchgereicht, NICHT aufgefüllt: Das Fehlen des |
| 111 | Feldes ist hier selbst eine Aussage – „noch nicht entschieden“ – und |
| 112 | steuert die Voreinstellung nach erkanntem Hilfsmittel. Ein |
| 113 | Rückfallwert machte daraus stillschweigend eine Entscheidung. */ |
| 114 | ...(typeof tastenkuerzel === 'boolean' ? { tastenkuerzel } : {}), |
| 115 | /* Ebenfalls nicht aufgefüllt: Das Fehlen heißt „noch nie gesichert“ und |
| 116 | ist eine Aussage, kein fehlender Wert. */ |
| 117 | ...(gueltigeSicherung ? { letzteSicherung } : {}), |
| 118 | }; |
| 119 | } |
| 120 | |
| 121 | export function einstellungenLesen(): Einstellungen { |
| 122 | const pfad = dateipfad(); |
| 123 | if (!existsSync(pfad)) { |
| 124 | return EINSTELLUNGEN_STANDARD; |
| 125 | } |
| 126 | |
| 127 | try { |
| 128 | return einstellungenBereinigen(JSON.parse(readFileSync(pfad, 'utf8'))); |
| 129 | } catch (fehler) { |
| 130 | console.warn('[einstellungen] Datei nicht lesbar, verwende Standardwerte:', fehler); |
| 131 | return EINSTELLUNGEN_STANDARD; |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Schreibt Einstellungen – als Änderung, nicht als Ersatz. |
| 137 | * |
| 138 | * Der Renderer schickt nur die Felder, die er ändern will; sie werden über den |
| 139 | * gespeicherten Stand gelegt. Ohne dieses Zusammenlegen würde jede |
| 140 | * Themenumschaltung alle übrigen Einstellungen auf die Standardwerte |
| 141 | * zurücksetzen, weil {@link einstellungenBereinigen} fehlende Felder auffüllt. |
| 142 | * Heute folgenlos – sobald aber eine zweite Einstellung wirklich benutzt wird, |
| 143 | * ein stiller Datenverlust. |
| 144 | * |
| 145 | * Wirft, wenn nicht geschrieben werden konnte. Das ist der Unterschied zu |
| 146 | * vorher: Ein voller Datenträger oder ein schreibgeschütztes Verzeichnis blieb |
| 147 | * unbemerkt – die Umstellung griff sofort und war nach dem Neustart wieder |
| 148 | * weg, ohne dass je etwas gemeldet wurde. |
| 149 | * |
| 150 | * **Geschrieben wird über eine Nebendatei.** Ginge es geradewegs in die |
| 151 | * Zieldatei, hinterließe ein Abbruch mitten im Schreiben – Stromausfall, |
| 152 | * voller Datenträger nach dem halben Puffer – eine verstümmelte Datei. |
| 153 | * {@link einstellungenLesen} fiele dann sauber auf die Werksvorgaben zurück, |
| 154 | * aber der Nutzer verlöre wortlos Farbschema, Anzeigegröße, Vorlesen, |
| 155 | * Tastenkürzel und das zuletzt benutzte Profil. `rename` innerhalb desselben |
| 156 | * Verzeichnisses ist unter Windows wie unter POSIX unteilbar: Es liegt |
| 157 | * entweder der alte oder der neue Stand da, nie ein halber. Das war der |
| 158 | * einzige nicht unteilbare Schreibweg der Anwendung – der Lernstand ist |
| 159 | * transaktional, die Sicherung nutzt `VACUUM INTO`. |
| 160 | */ |
| 161 | export function einstellungenSchreiben(roh: unknown): Einstellungen { |
| 162 | const aenderung = typeof roh === 'object' && roh !== null ? roh : {}; |
| 163 | const bereinigt = einstellungenBereinigen({ ...einstellungenLesen(), ...aenderung }); |
| 164 | const pfad = dateipfad(); |
| 165 | const neben = `${pfad}.neu`; |
| 166 | |
| 167 | try { |
| 168 | mkdirSync(dirname(pfad), { recursive: true }); |
| 169 | writeFileSync(neben, `${JSON.stringify(bereinigt, null, 2)}\n`, 'utf8'); |
| 170 | renameSync(neben, pfad); |
| 171 | } catch (fehler: unknown) { |
| 172 | /* Die Nebendatei nicht liegen lassen: Ein halb geschriebener Rest neben |
| 173 | der gültigen Datei verwirrt beim Nachsehen und belegt Platz, den es |
| 174 | gerade nicht gab. Scheitert auch das, bleibt es dabei – der |
| 175 | ursprüngliche Fehler ist der, der gemeldet gehört. */ |
| 176 | try { |
| 177 | rmSync(neben, { force: true }); |
| 178 | } catch { |
| 179 | /* bewusst still */ |
| 180 | } |
| 181 | const grund = fehler instanceof Error ? fehler.message : String(fehler); |
| 182 | /* `cause` hängt den ursprünglichen Fehler an, statt ihn wegzuwerfen. Die |
| 183 | lesbare Meldung geht an den Nutzer, der Systemfehlercode (ENOSPC, |
| 184 | EACCES) und die Aufrufliste bleiben für die Fehlersuche erhalten – |
| 185 | genau die Angaben, die bei „konnte nicht gespeichert werden“ zählen. */ |
| 186 | throw new Error(`Die Einstellungen konnten nicht gespeichert werden: ${grund}`, { |
| 187 | cause: fehler, |
| 188 | }); |
| 189 | } |
| 190 | |
| 191 | return bereinigt; |
| 192 | } |