waffensachkunde

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

/ app src preload index.ts

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