waffensachkunde

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

/ app src preload index.ts

12,1 KB Rohdatei
app/src/preload/index.ts — 262 Zeilen
1 /**
2 * Preload-Skript – die einzige Brücke zwischen Renderer und Main-Prozess.
3 *
4 * Läuft mit `sandbox: true` und `contextIsolation: true`. Deshalb gilt:
5 * - keine Node-APIs außer `electron`, `events`, `timers`, `url`,
6 * - das Bundle muss vollständig gebündelt sein (keine externen Importe),
7 * - Ausgabeformat CommonJS (`out/preload/index.js`).
8 *
9 * Der Renderer bekommt ausschließlich die Funktionen aus
10 * {@link LernAppBridge} – niemals `ipcRenderer` selbst.
11 */
12
13 import { contextBridge, ipcRenderer } from 'electron';
14
15 import type { Uebernahmeergebnis } from '../shared/sicherung';
16 import type { Erklaerungstiefe } from '../shared/druck/fehlerprotokoll';
17 import type { Schriftgroesse } from '../shared/druck/stil';
18 import type { Erklaerungen } from '../shared/erklaerungen';
19 import type { Glossar } from '../shared/glossar';
20 import type { Normtexte } from '../shared/normtexte';
21 import type { Themen } from '../shared/themen';
22 import type { HartnaeckigeFrage } from '../shared/hartnaeckig';
23 import type { Katalog } from '../shared/katalog';
24 import type { Lernplan } from '../shared/lernplan';
25 import {
26 BRIDGE_NAME,
27 istIpcKanal,
28 istMenuebefehl,
29 KANAL_ANZEIGEGROESSE_GEAENDERT,
30 KANAL_ENERGIESPAREN_ENDE,
31 KANAL_HILFSMITTEL_GEAENDERT,
32 KANAL_MENUE_BEFEHL,
33 type AnwendungsInfo,
34 type Menuebefehl,
35 type Druckergebnis,
36 type Einstellungen,
37 type IpcAnfrage,
38 type IpcAntwort,
39 type IpcKanal,
40 type LernAppBridge,
41 } from '../shared/ipc';
42 import type {
43 Antwortprotokoll,
44 FrageStand,
45 Lernuebersicht,
46 Profil,
47 SitzungsFilter,
48 SitzungsFrage,
49 } from '../shared/lernstand';
50 import type { Datenschutzangaben } from '../shared/datenschutz';
51 import type { Lizenzangaben } from '../shared/lizenzen';
52 import type { Einspielergebnis, Pruefergebnis, Sicherungsergebnis } from '../shared/sicherung';
53 import type {
54 OffenerLauf,
55 Pruefungsantwort,
56 Pruefungsauftrag,
57 Pruefungsbogen,
58 Pruefungsergebnis,
59 Pruefungsverlauf,
60 Zwischenstand,
61 } from '../shared/pruefung';
62
63 /**
64 * Typsicherer Aufruf. Die Kanalprüfung ist eine zusätzliche Verteidigungslinie:
65 * selbst wenn Renderer-Code manipuliert würde, kommen nur Kanäle aus der
66 * Allowlist durch.
67 */
68 async function aufrufen<K extends IpcKanal>(
69 kanal: K,
70 anfrage: IpcAnfrage<K>,
71 ): Promise<IpcAntwort<K>> {
72 if (!istIpcKanal(kanal)) {
73 throw new Error(`Unbekannter IPC-Kanal: ${String(kanal)}`);
74 }
75 return (await ipcRenderer.invoke(kanal, anfrage)) as IpcAntwort<K>;
76 }
77
78 const bridge: LernAppBridge = Object.freeze({
79 anwendungsInfoLesen: (): Promise<AnwendungsInfo> => aufrufen('anwendung:info', undefined),
80 einstellungenLesen: (): Promise<Einstellungen> => aufrufen('einstellungen:lesen', undefined),
81 einstellungenSchreiben: (aenderung: Partial<Einstellungen>): Promise<Einstellungen> =>
82 aufrufen('einstellungen:schreiben', aenderung),
83
84 // ── Fragenkatalog ────────────────────────────────────────────────────
85 katalogLaden: (): Promise<Katalog> => aufrufen('katalog:laden', undefined),
86 /** Nur die ID wandert über die Brücke – nie ein Pfad. */
87 katalogBild: (bildId: string): Promise<string> => aufrufen('katalog:bild', { bildId }),
88 erklaerungenLaden: (): Promise<Erklaerungen> => aufrufen('erklaerungen:laden', undefined),
89 glossarLaden: (): Promise<Glossar> => aufrufen('glossar:laden', undefined),
90 normtexteLaden: (): Promise<Normtexte> => aufrufen('gesetz:normtexte', undefined),
91 themenLaden: (): Promise<Themen> => aufrufen('themen:laden', undefined),
92
93 // ── Profile ──────────────────────────────────────────────────────────
94 profilListe: (): Promise<Profil[]> => aufrufen('profil:liste', undefined),
95 profilAnlegen: (name: string): Promise<Profil> => aufrufen('profil:anlegen', { name }),
96 profilAktualisieren: (
97 id: number,
98 aenderung: {
99 name?: string;
100 pruefungstermin?: string | null;
101 kapitelAusschluss?: readonly string[];
102 },
103 ): Promise<Profil> => aufrufen('profil:aktualisieren', { id, ...aenderung }),
104
105 // ── Lernen ───────────────────────────────────────────────────────────
106 lernSitzung: (profilId: number, filter: SitzungsFilter): Promise<SitzungsFrage[]> =>
107 aufrufen('lernen:sitzung', { profilId, filter }),
108 lernAntworten: (profilId: number, protokoll: Antwortprotokoll): Promise<FrageStand> =>
109 aufrufen('lernen:antworten', { profilId, protokoll }),
110 lernMerken: (profilId: number, frageId: string, gemerkt: boolean): Promise<FrageStand> =>
111 aufrufen('lernen:merken', { profilId, frageId, gemerkt }),
112 lernFragestand: (profilId: number, frageId: string): Promise<FrageStand> =>
113 aufrufen('lernen:fragestand', { profilId, frageId }),
114 lernHartnaeckige: (profilId: number): Promise<HartnaeckigeFrage[]> =>
115 aufrufen('lernen:hartnaeckige', { profilId }),
116 lernUebersicht: (profilId: number): Promise<Lernuebersicht> =>
117 aufrufen('lernen:uebersicht', { profilId }),
118 lernPlan: (profilId: number): Promise<Lernplan> => aufrufen('lernen:plan', { profilId }),
119 lernZuruecksetzen: (profilId: number, kapitel?: string | null): Promise<Lernuebersicht> =>
120 aufrufen('lernen:zuruecksetzen', { profilId, kapitel: kapitel ?? null }),
121
122 // ── Prüfungssimulation ───────────────────────────────────────────────
123 pruefungStarten: (profilId: number, auftrag: Pruefungsauftrag): Promise<Pruefungsbogen> =>
124 aufrufen('pruefung:starten', { profilId, auftrag }),
125 /**
126 * Die Richtigkeit der Antworten wird im Main-Prozess nachgerechnet – was
127 * hier hinübergeht, sind ausschließlich die Eingaben des Prüflings.
128 */
129 pruefungAuswerten: (
130 profilId: number,
131 auftrag: Pruefungsauftrag,
132 antworten: Pruefungsantwort[],
133 dauerMs: number,
134 zeitAbgelaufen: boolean,
135 ): Promise<Pruefungsergebnis> =>
136 aufrufen('pruefung:auswerten', { profilId, auftrag, antworten, dauerMs, zeitAbgelaufen }),
137 pruefungVerlauf: (profilId: number): Promise<Pruefungsverlauf[]> =>
138 aufrufen('pruefung:verlauf', { profilId }),
139 pruefungSichern: (profilId: number, stand: Zwischenstand): Promise<boolean> =>
140 aufrufen('pruefung:sichern', { profilId, stand }),
141 pruefungOffen: (profilId: number): Promise<OffenerLauf | null> =>
142 aufrufen('pruefung:offen', { profilId }),
143 pruefungVerwerfen: (profilId: number): Promise<void> =>
144 aufrufen('pruefung:verwerfen', { profilId }),
145 pruefungLaeuft: (laeuft: boolean): Promise<void> => aufrufen('pruefung:laeuft', { laeuft }),
146
147 // ── Rechtliches ──────────────────────────────────────────────────────
148 anzeigegroesseSetzen: (prozent: number): Promise<number> =>
149 aufrufen('ansicht:groesse', { prozent }),
150 anzeigegroesseBeobachten: (melden: (prozent: number) => void): (() => void) => {
151 const zuhoerer = (_ereignis: unknown, prozent: unknown): void => {
152 if (typeof prozent === 'number') {
153 melden(prozent);
154 }
155 };
156 ipcRenderer.on(KANAL_ANZEIGEGROESSE_GEAENDERT, zuhoerer);
157 return () => {
158 ipcRenderer.off(KANAL_ANZEIGEGROESSE_GEAENDERT, zuhoerer);
159 };
160 },
161
162 sicherungUebernehmen: (vorgang: string, profilIndex: number): Promise<Uebernahmeergebnis> =>
163 aufrufen('sicherung:uebernehmen', { vorgang, profilIndex }),
164 fehlerprotokollDrucken: (
165 profilId: number,
166 schriftgroesse: Schriftgroesse,
167 tiefe: Erklaerungstiefe,
168 ): Promise<Druckergebnis> =>
169 aufrufen('druck:fehlerprotokoll', { profilId, schriftgroesse, tiefe }),
170 fragenlisteDrucken: (
171 bereiche: readonly string[],
172 schriftgroesse: Schriftgroesse,
173 mitLoesungen: boolean,
174 ): Promise<Druckergebnis> =>
175 aufrufen('druck:fragenliste', { bereiche, schriftgroesse, mitLoesungen }),
176 lernberichtDrucken: (profilId: number, schriftgroesse: Schriftgroesse): Promise<Druckergebnis> =>
177 aufrufen('druck:lernbericht', { profilId, schriftgroesse }),
178
179 profilLoeschen: (id: number): Promise<Profil[]> => aufrufen('profil:loeschen', { id }),
180
181 lizenzenLesen: (): Promise<Lizenzangaben> => aufrufen('lizenzen:lesen', undefined),
182 datenschutzLesen: (): Promise<Datenschutzangaben> => aufrufen('datenschutz:lesen', undefined),
183
184 hilfsmittelAktiv: (): Promise<boolean> => aufrufen('system:hilfsmittel', undefined),
185
186 /* Nur die Zeichenkette wandert hinüber – der Renderer bestimmt den Inhalt,
187 der Hauptprozess führt aus. Genau deshalb gibt es diesen Kanal statt
188 einer gelockerten Berechtigung: Er hat eine feste Nutzlast. */
189 zwischenablageSchreiben: (text: string): Promise<boolean> =>
190 aufrufen('system:kopieren', { text }),
191
192 /* Der einzige Weg nach außen – und er führt nicht irgendwohin: Der
193 Hauptprozess vergleicht die Adresse mit der einen eingetragenen und
194 öffnet sonst nichts. Hier steht deshalb bewusst keine Prüfung: Zwei
195 Prüfungen an zwei Stellen laufen auseinander, und maßgeblich ist die
196 im Hauptprozess. */
197 unterstuetzungOeffnen: (url: string): Promise<boolean> =>
198 aufrufen('system:unterstuetzung', { url }),
199
200 /* Nur die Kennung wandert hinüber, nie ein Pfad – dieselbe Überlegung wie
201 bei der Sicherung und beim unterbrochenen Prüfungsbogen. */
202 dateiZeigen: (kennung: string, wunsch: 'oeffnen' | 'ordner'): Promise<boolean> =>
203 aufrufen('system:datei-zeigen', { kennung, wunsch }),
204
205 /* Ohne Argument – der Hauptprozess weiß, welcher Ordner gemeint ist. */
206 sicherungsordnerZeigen: (): Promise<boolean> => aufrufen('sicherung:ordner-zeigen', undefined),
207
208 sicherungAnlegen: (): Promise<Sicherungsergebnis> => aufrufen('sicherung:anlegen', undefined),
209 sicherungPruefen: (): Promise<Pruefergebnis> => aufrufen('sicherung:pruefen', undefined),
210 /* Nur die Kennung wandert hinüber, nie ein Pfad. */
211 sicherungEinspielen: (vorgang: string): Promise<Einspielergebnis> =>
212 aufrufen('sicherung:einspielen', { vorgang }),
213 /* Der Renderer bekommt nur eine Rückruffunktion, nie das Ereignisobjekt –
214 sonst reichte man ihm über `event.sender` doch wieder Electron-Interna. */
215 hilfsmittelBeobachten: (melden: (aktiv: boolean) => void): (() => void) => {
216 const zuhoerer = (_ereignis: unknown, aktiv: unknown): void => {
217 melden(aktiv === true);
218 };
219 ipcRenderer.on(KANAL_HILFSMITTEL_GEAENDERT, zuhoerer);
220 return () => {
221 ipcRenderer.removeListener(KANAL_HILFSMITTEL_GEAENDERT, zuhoerer);
222 };
223 },
224
225 /* Was nicht auf der Liste steht, kommt nicht durch: Die Prüfung im
226 Preload ist dieselbe Verteidigungslinie wie die Kanal-Allowlist – der
227 Renderer bekommt nur Befehle, die es wirklich gibt. */
228 menuebefehlBeobachten: (melden: (befehl: Menuebefehl) => void): (() => void) => {
229 const zuhoerer = (_ereignis: unknown, befehl: unknown): void => {
230 if (istMenuebefehl(befehl)) {
231 melden(befehl);
232 }
233 };
234 ipcRenderer.on(KANAL_MENUE_BEFEHL, zuhoerer);
235 return () => {
236 ipcRenderer.removeListener(KANAL_MENUE_BEFEHL, zuhoerer);
237 };
238 },
239
240 /* Nur eine Zahl wandert hinüber, und sie wird hier auf Brauchbarkeit
241 geprüft: Eine negative oder unendliche Dauer verschöbe den Zeitursprung
242 der Simulation ins Sinnlose. Der Hauptprozess prüft dasselbe – zwei
243 Linien, wie bei den übrigen Kanälen. */
244 energiesparenBeobachten: (melden: (schlafMs: number) => void): (() => void) => {
245 const zuhoerer = (_ereignis: unknown, schlafMs: unknown): void => {
246 if (typeof schlafMs === 'number' && Number.isFinite(schlafMs) && schlafMs > 0) {
247 melden(schlafMs);
248 }
249 };
250 ipcRenderer.on(KANAL_ENERGIESPAREN_ENDE, zuhoerer);
251 return () => {
252 ipcRenderer.removeListener(KANAL_ENERGIESPAREN_ENDE, zuhoerer);
253 };
254 },
255 });
256
257 if (process.contextIsolated) {
258 contextBridge.exposeInMainWorld(BRIDGE_NAME, bridge);
259 } else {
260 // Sollte nie eintreten – contextIsolation ist hart aktiviert.
261 throw new Error('contextIsolation ist deaktiviert. Die Anwendung startet nicht ungesichert.');
262 }