waffensachkunde

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

/ app src main einstellungen.ts

8,3 KB Rohdatei
app/src/main/einstellungen.ts — 182 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 gueltigeProfilId =
56 typeof profilId === 'number' && Number.isInteger(profilId) && profilId > 0;
57
58 return {
59 thema: istThemeAuswahl(thema) ? thema : EINSTELLUNGEN_STANDARD.thema,
60 /* Beide Schwierigkeitsregler fallen auf „aus" zurück – dieselbe Aussage
61 wie in EINSTELLUNGEN_STANDARD. Stünde hier ein anderer Rückfallwert,
62 hebelte er den Standard aus, sobald das Feld dort einmal fehlt. */
63 optionenMischen:
64 typeof optionenMischen === 'boolean'
65 ? optionenMischen
66 : (EINSTELLUNGEN_STANDARD.optionenMischen ?? false),
67 antwortzahlVerbergen:
68 typeof antwortzahlVerbergen === 'boolean'
69 ? antwortzahlVerbergen
70 : (EINSTELLUNGEN_STANDARD.antwortzahlVerbergen ?? false),
71 vorlesen: typeof vorlesen === 'boolean' ? vorlesen : (EINSTELLUNGEN_STANDARD.vorlesen ?? false),
72 vorlesenAutomatisch:
73 typeof vorlesenAutomatisch === 'boolean'
74 ? vorlesenAutomatisch
75 : (EINSTELLUNGEN_STANDARD.vorlesenAutomatisch ?? false),
76 vorlesenErklaerungBeiFehler:
77 typeof vorlesenErklaerung === 'boolean'
78 ? vorlesenErklaerung
79 : (EINSTELLUNGEN_STANDARD.vorlesenErklaerungBeiFehler ?? false),
80 zuschnittGefragt:
81 typeof zuschnittGefragt === 'boolean'
82 ? zuschnittGefragt
83 : (EINSTELLUNGEN_STANDARD.zuschnittGefragt ?? false),
84 /* Bereinigt auf eine gültige Stufe: Ein Wert aus einer früheren Fassung
85 oder von Hand in die Datei geschrieben soll die Anwendung nicht in
86 eine unbedienbare Größe zwingen. */
87 anzeigegroesse: anzeigegroesseBereinigen(anzeigegroesse),
88 /* Wie die Anzeigegröße auf eine gültige Stufe gebracht: Ein Wert aus
89 einer früheren Fassung oder von Hand in die Datei geschrieben soll
90 die Darstellung nicht auf einen Zustand bringen, den es nicht gibt. */
91 textabstand: textabstandBereinigen(kandidat['textabstand']),
92 sprechtempo: sprechtempoBereinigen(kandidat['sprechtempo']),
93 /* Wie `tastenkuerzel` nur durchgereicht, nicht aufgefüllt: Ohne
94 gespeicherte Lage soll das Fenster die Vorgabe bekommen, nicht eine
95 erfundene Größe. */
96 ...(fensterlage === null ? {} : { fenster: fensterlage }),
97 // `exactOptionalPropertyTypes`: das Feld darf nur gesetzt werden, wenn es
98 // wirklich einen Wert hat.
99 ...(gueltigeProfilId ? { profilId } : {}),
100 /* Wie `profilId` nur durchgereicht, NICHT aufgefüllt: Das Fehlen des
101 Feldes ist hier selbst eine Aussage – „noch nicht entschieden“ – und
102 steuert die Voreinstellung nach erkanntem Hilfsmittel. Ein
103 Rückfallwert machte daraus stillschweigend eine Entscheidung. */
104 ...(typeof tastenkuerzel === 'boolean' ? { tastenkuerzel } : {}),
105 /* Ebenfalls nicht aufgefüllt: Das Fehlen heißt „noch nie gesichert“ und
106 ist eine Aussage, kein fehlender Wert. */
107 ...(gueltigeSicherung ? { letzteSicherung } : {}),
108 };
109 }
110
111 export function einstellungenLesen(): Einstellungen {
112 const pfad = dateipfad();
113 if (!existsSync(pfad)) {
114 return EINSTELLUNGEN_STANDARD;
115 }
116
117 try {
118 return einstellungenBereinigen(JSON.parse(readFileSync(pfad, 'utf8')));
119 } catch (fehler) {
120 console.warn('[einstellungen] Datei nicht lesbar, verwende Standardwerte:', fehler);
121 return EINSTELLUNGEN_STANDARD;
122 }
123 }
124
125 /**
126 * Schreibt Einstellungen – als Änderung, nicht als Ersatz.
127 *
128 * Der Renderer schickt nur die Felder, die er ändern will; sie werden über den
129 * gespeicherten Stand gelegt. Ohne dieses Zusammenlegen würde jede
130 * Themenumschaltung alle übrigen Einstellungen auf die Standardwerte
131 * zurücksetzen, weil {@link einstellungenBereinigen} fehlende Felder auffüllt.
132 * Heute folgenlos – sobald aber eine zweite Einstellung wirklich benutzt wird,
133 * ein stiller Datenverlust.
134 *
135 * Wirft, wenn nicht geschrieben werden konnte. Das ist der Unterschied zu
136 * vorher: Ein voller Datenträger oder ein schreibgeschütztes Verzeichnis blieb
137 * unbemerkt – die Umstellung griff sofort und war nach dem Neustart wieder
138 * weg, ohne dass je etwas gemeldet wurde.
139 *
140 * **Geschrieben wird über eine Nebendatei.** Ginge es geradewegs in die
141 * Zieldatei, hinterließe ein Abbruch mitten im Schreiben – Stromausfall,
142 * voller Datenträger nach dem halben Puffer – eine verstümmelte Datei.
143 * {@link einstellungenLesen} fiele dann sauber auf die Werksvorgaben zurück,
144 * aber der Nutzer verlöre wortlos Farbschema, Anzeigegröße, Vorlesen,
145 * Tastenkürzel und das zuletzt benutzte Profil. `rename` innerhalb desselben
146 * Verzeichnisses ist unter Windows wie unter POSIX unteilbar: Es liegt
147 * entweder der alte oder der neue Stand da, nie ein halber. Das war der
148 * einzige nicht unteilbare Schreibweg der Anwendung – der Lernstand ist
149 * transaktional, die Sicherung nutzt `VACUUM INTO`.
150 */
151 export function einstellungenSchreiben(roh: unknown): Einstellungen {
152 const aenderung = typeof roh === 'object' && roh !== null ? roh : {};
153 const bereinigt = einstellungenBereinigen({ ...einstellungenLesen(), ...aenderung });
154 const pfad = dateipfad();
155 const neben = `${pfad}.neu`;
156
157 try {
158 mkdirSync(dirname(pfad), { recursive: true });
159 writeFileSync(neben, `${JSON.stringify(bereinigt, null, 2)}\n`, 'utf8');
160 renameSync(neben, pfad);
161 } catch (fehler: unknown) {
162 /* Die Nebendatei nicht liegen lassen: Ein halb geschriebener Rest neben
163 der gültigen Datei verwirrt beim Nachsehen und belegt Platz, den es
164 gerade nicht gab. Scheitert auch das, bleibt es dabei – der
165 ursprüngliche Fehler ist der, der gemeldet gehört. */
166 try {
167 rmSync(neben, { force: true });
168 } catch {
169 /* bewusst still */
170 }
171 const grund = fehler instanceof Error ? fehler.message : String(fehler);
172 /* `cause` hängt den ursprünglichen Fehler an, statt ihn wegzuwerfen. Die
173 lesbare Meldung geht an den Nutzer, der Systemfehlercode (ENOSPC,
174 EACCES) und die Aufrufliste bleiben für die Fehlersuche erhalten –
175 genau die Angaben, die bei „konnte nicht gespeichert werden“ zählen. */
176 throw new Error(`Die Einstellungen konnten nicht gespeichert werden: ${grund}`, {
177 cause: fehler,
178 });
179 }
180
181 return bereinigt;
182 }