waffensachkunde

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

/ app src main einstellungen.ts

8,7 KB Rohdatei
app/src/main/einstellungen.ts — 192 Zeilen
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 }