waffensachkunde

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

/ app src main ipc.ts

22,9 KB Rohdatei
app/src/main/ipc.ts — 625 Zeilen
1 /**
2 * Registrierung der IPC-Handler – ausschließlich über den typisierten Vertrag
3 * aus `src/shared/ipc.ts`.
4 *
5 * Die Typisierung beschreibt, was der Renderer schicken SOLL. Was er
6 * tatsächlich schickt, ist an dieser Grenze unbekannt: die Nutzlasten werden
7 * deshalb in den Fachmodulen (`katalog.ts`, `lernstand.ts`) zur Laufzeit
8 * geprüft, bevor irgendetwas damit geschieht.
9 */
10
11 import { join } from 'node:path';
12
13 import type BetterSqlite3 from 'better-sqlite3';
14
15 import { app, BrowserWindow, clipboard, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron';
16
17 import {
18 IPC_KANAELE,
19 KANAL_HILFSMITTEL_GEAENDERT,
20 type AnwendungsInfo,
21 type IpcAnfrage,
22 type IpcAntwort,
23 type IpcKanal,
24 type Katalogwechsel,
25 } from '../shared/ipc';
26 import { anzeigegroesseSetzen } from './anzeige';
27 import { dateiZeigen, ordnerZeigen } from './dateizugriff';
28 import { datenbankStatus } from './datenbank';
29 import { einstellungenLesen, einstellungenSchreiben } from './einstellungen';
30 import { erklaerungenLaden } from './erklaerungen';
31 import { glossarLaden } from './glossar';
32 import { normtexteLaden } from './normtexte';
33 import { themenLaden } from './themen';
34 import { katalogBild, katalogLaden } from './katalog';
35 import { fehlerprotokollExportieren, fragenlisteExportieren } from './fragendruck-export';
36 import { lernberichtExportieren } from './lernbericht-export';
37 import {
38 datenbankKonstruktor,
39 istBeschaedigt,
40 lernstandBeiseitelegen,
41 lernstandInstanz,
42 lernstandOffen,
43 lernstandSchliessen,
44 type Lernstand,
45 } from './lernstand';
46 import { protokollPfad } from './protokoll';
47 import {
48 kopieVorDemVerwerfen,
49 selbstsicherungAnlegen,
50 selbstsicherungsordner,
51 } from './selbstsicherung';
52 import { datenschutzLesen } from './datenschutz';
53 import { lizenzenLesen } from './lizenzen';
54 import { pruefungInstanz, pruefungZuruecksetzen, type Pruefung } from './pruefung';
55 import {
56 sicherungAnlegen,
57 sicherungEinspielen,
58 sicherungUebernehmen,
59 sicherungPruefen,
60 type Umgebung as Sicherungsumgebung,
61 } from './sicherung-dialoge';
62 import { laufMelden } from './laufwaechter';
63 import { istErlaubteNavigation, unterstuetzungOeffnen } from './sicherheit';
64
65 /** Dateiname des Lernstands im `userData`-Verzeichnis. */
66 const LERNSTAND_DATEI = 'lernstand.db';
67
68 /**
69 * Anwendungsversion, zur Bauzeit aus der package.json eingesetzt
70 * (siehe `electron.vite.config.ts`).
71 *
72 * `app.getVersion()` taugt dafür nicht durchgängig: Wird der gebaute
73 * Einstiegspunkt direkt gestartet – in der Entwicklung und in den
74 * Oberflächentests – findet Electron keine package.json und liefert
75 * ersatzweise seine eigene Version zurück.
76 */
77 declare const __ANWENDUNGS_VERSION__: string | undefined;
78
79 function anwendungsVersion(): string {
80 return typeof __ANWENDUNGS_VERSION__ === 'string' ? __ANWENDUNGS_VERSION__ : app.getVersion();
81 }
82
83 /**
84 * Baukennung und Commit-Datum, ebenfalls zur Bauzeit eingesetzt.
85 *
86 * Ohne Git beim Bauen bleiben beide leer; die Oberfläche lässt die Zeile
87 * dann weg, statt „unbekannt“ anzuzeigen.
88 */
89 declare const __BAUKENNUNG__: string | undefined;
90 declare const __BAUSTAND__: string | undefined;
91
92 function zeichenkette(wert: string | undefined): string {
93 return typeof wert === 'string' ? wert : '';
94 }
95
96 /**
97 * Stammt die Anfrage aus unserer eigenen Oberfläche?
98 *
99 * Zweite Verteidigungslinie hinter der Navigationssperre in `sicherheit.ts`.
100 * Käme eine fremde Seite doch einmal in den Renderer, hätte sie über das
101 * Preload-Skript die volle Bridge in der Hand – hier endet das trotzdem.
102 *
103 * Ein Rahmen ohne URL wird durchgelassen: Das kommt vor, wenn der Rahmen
104 * gerade abgebaut wird, und wäre kein Angriff, sondern ein Wettlauf.
105 */
106 function vonEigenerOberflaeche(ereignis: IpcMainInvokeEvent): boolean {
107 const herkunft = ereignis.senderFrame?.url;
108 return herkunft === undefined || herkunft === '' || istErlaubteNavigation(herkunft);
109 }
110
111 /**
112 * Typsichere Hülle um `ipcMain.handle`: Kanal, Anfrage- und Antworttyp werden
113 * aus {@link IpcVertrag} abgeleitet, sodass Tippfehler zu Compilerfehlern
114 * werden statt zu stillen `undefined`-Antworten.
115 */
116 function behandeln<K extends IpcKanal>(
117 kanal: K,
118 /* Auch ein Versprechen ist erlaubt: `ipcMain.handle` wartet es ab. Gebraucht
119 wird das vom Export, der einen Dialog zeigt und danach schreibt. */
120 behandler: (
121 ereignis: IpcMainInvokeEvent,
122 anfrage: IpcAnfrage<K>,
123 ) => IpcAntwort<K> | Promise<IpcAntwort<K>>,
124 ): void {
125 ipcMain.handle(kanal, (ereignis, anfrage: unknown) => {
126 if (!vonEigenerOberflaeche(ereignis)) {
127 console.warn(`[sicherheit] IPC-Anfrage fremder Herkunft abgewiesen: ${kanal}`);
128 throw new Error('Diese Anfrage stammt nicht aus der Oberfläche dieser Anwendung.');
129 }
130 return behandler(ereignis, anfrage as IpcAnfrage<K>);
131 });
132 }
133
134 /**
135 * Holt ein Feld aus einer IPC-Nutzlast. Trotz Typisierung wird hier nichts
136 * vorausgesetzt – die eigentliche Prüfung der Werte macht das Fachmodul.
137 */
138 function feld(anfrage: unknown, name: string): unknown {
139 if (typeof anfrage !== 'object' || anfrage === null || Array.isArray(anfrage)) {
140 throw new Error(`Ungültige Anfrage: Es wurde ein Objekt mit dem Feld „${name}“ erwartet.`);
141 }
142 return (anfrage as Record<string, unknown>)[name];
143 }
144
145 /**
146 * Befund des Katalogstand-Abgleichs. Der Abgleich selbst läuft beim Öffnen
147 * des Lernstands; hier wird er nur abgeholt. Scheitert das Öffnen – etwa bei
148 * einem Lernstand aus einer neueren Programmversion oder ohne Katalog –,
149 * antwortet die Systemauskunft trotzdem: Diese Fehler haben ihre eigenen
150 * Meldungen an anderer Stelle.
151 */
152 function katalogwechselLesen(): Katalogwechsel | null {
153 try {
154 return lernstand().katalogwechsel;
155 } catch {
156 return null;
157 }
158 }
159
160 function anwendungsInfo(): AnwendungsInfo {
161 return {
162 anwendungsVersion: anwendungsVersion(),
163 baukennung: zeichenkette(__BAUKENNUNG__),
164 baustand: zeichenkette(__BAUSTAND__),
165 electronVersion: process.versions.electron,
166 chromeVersion: process.versions.chrome,
167 nodeVersion: process.versions.node,
168 plattform: process.platform,
169 datenbank: datenbankStatus(),
170 katalogwechsel: katalogwechselLesen(),
171 protokollPfad: protokollPfad(),
172 };
173 }
174
175 /**
176 * Lernstand beim ersten Bedarf öffnen. Der Katalog wird dabei mitgeladen –
177 * ohne ihn lassen sich weder Fragen zusammenstellen noch Statistiken bilden.
178 *
179 * **Der eine Fall, in dem es hier etwas anzubieten gibt.** Ist die Datei
180 * beschädigt, endete das bis 0.22.0 in einer Sackgasse: Der Renderer meldete
181 * „Ihr Lernprofil konnte nicht geladen werden … Antworten werden dabei nicht
182 * gespeichert“, und dabei blieb es bei jedem Start aufs Neue. Wer nicht von
183 * sich aus auf die Idee kam, im `userData`-Verzeichnis nachzusehen, konnte
184 * die Anwendung nie wieder benutzen.
185 *
186 * Deshalb fragt der Hauptprozess hier nach. Der Dialog ist bewusst
187 * synchron und nativ: Er muss vorliegen, bevor dieser Aufruf zurückkehrt,
188 * und er wird auch dann noch vorgelesen, wenn die Oberfläche nichts anzeigen
189 * kann. Nichts wird von selbst getan – die beschädigte Datei bleibt liegen,
190 * bis jemand ausdrücklich zustimmt, und auch dann wird sie nur umbenannt.
191 */
192 /**
193 * Merker: Die Kopie beim Öffnen wird je Programmlauf einmal erwogen.
194 *
195 * Nicht einmal je Aufruf – `lernstand()` läuft bei jedem IPC-Kanal.
196 */
197 let kopieBeimOeffnenErwogen = false;
198
199 /**
200 * Selbsttätige Kopie auch beim **Öffnen**, nicht nur beim Beenden.
201 *
202 * **Die Lücke.** Die Wochenkopie entstand ausschließlich beim geordneten
203 * Beenden. Stürzt die Anwendung ab, fällt der Strom aus oder beendet jemand
204 * den Prozess über den Taskmanager, entsteht sie nie – und genau in diesen
205 * Fällen ist eine Kopie am ehesten etwas wert. Wer eine Woche lang jeden Tag
206 * abstürzt, hatte am Ende keine einzige.
207 *
208 * **Warum das nichts kostet.** Gerufen wird dieselbe, unverändert über
209 * `istFaellig` gedrosselte Funktion: Ist die letzte Kopie jünger als eine
210 * Woche, kehrt sie ohne Schreiben um. Es entsteht also keine zweite Kopie je
211 * Lauf, sondern dieselbe eine – nur früher, und auch dann, wenn der Lauf
212 * nicht ordentlich endet.
213 *
214 * Wie beim Beenden ist sie eine Zugabe: Misslingt sie, läuft die Anwendung
215 * weiter. Anders als die Kopie vor dem Verwerfen steht hier kein Schritt
216 * dahinter, der Daten vernichtet.
217 */
218 function selbstsicherungBeimOeffnen(datenbank: BetterSqlite3.Database): void {
219 if (kopieBeimOeffnenErwogen) {
220 return;
221 }
222 kopieBeimOeffnenErwogen = true;
223
224 try {
225 const pfad = selbstsicherungAnlegen(datenbank, app.getPath('userData'), datenbankKonstruktor());
226 if (pfad !== null) {
227 console.info(`[sicherung] Selbsttätige Sicherheitskopie beim Öffnen: ${pfad}`);
228 }
229 } catch (fehler: unknown) {
230 console.warn('[sicherung] Selbsttätige Sicherheitskopie beim Öffnen misslang:', fehler);
231 }
232 }
233
234 function lernstand(): Lernstand {
235 const pfad = join(app.getPath('userData'), LERNSTAND_DATEI);
236 try {
237 const stand = lernstandInstanz(pfad, katalogLaden(), themenLaden());
238 selbstsicherungBeimOeffnen(stand.datenbank);
239 return stand;
240 } catch (fehler: unknown) {
241 if (!istBeschaedigt(fehler)) {
242 throw fehler;
243 }
244 return beschaedigtenLernstandKlaeren(pfad, fehler);
245 }
246 }
247
248 /**
249 * Bietet nach einer beschädigten Datei den Neuanfang an – einmal je Start.
250 *
251 * Der Merker verhindert eine Dialogschleife: Jeder IPC-Kanal, der einen
252 * Lernstand braucht, liefe sonst in dieselbe Rückfrage, und der Startbildschirm
253 * fragt mehrere davon gleich nacheinander ab.
254 */
255 let beschaedigungGeklaert = false;
256
257 function beschaedigtenLernstandKlaeren(pfad: string, fehler: unknown): Lernstand {
258 if (beschaedigungGeklaert) {
259 throw fehler;
260 }
261 beschaedigungGeklaert = true;
262
263 const wahl = dialog.showMessageBoxSync({
264 type: 'error',
265 buttons: ['Beiseitelegen und neu anfangen', 'Nichts tun'],
266 defaultId: 1,
267 cancelId: 1,
268 noLink: true,
269 title: 'Waffensachkunde – Lernsoftware',
270 message: 'Die Datei mit Ihrem Lernstand ist beschädigt.',
271 detail: [
272 'Sie lässt sich nicht mehr öffnen. Gelöscht wird nichts: Auf Wunsch wird die beschädigte Datei umbenannt und beiseitegelegt, und die Anwendung beginnt mit einem leeren Lernstand.',
273 'Haben Sie eine Sicherung, können Sie sie danach unter „Lernstand sichern und übertragen“ einspielen.',
274 `Die beschädigte Datei bleibt in jedem Fall erhalten unter:\n${pfad}`,
275 ].join('\n\n'),
276 });
277
278 if (wahl !== 0) {
279 throw fehler;
280 }
281
282 const beiseite = lernstandBeiseitelegen(pfad);
283 console.warn(`[lernstand] Beschädigte Datei beiseitegelegt: ${beiseite}`);
284 return lernstandInstanz(pfad, katalogLaden(), themenLaden());
285 }
286
287 /**
288 * Die Prüfungssimulation arbeitet auf demselben Lernstand: ein Lauf soll in
289 * Statistik und Wiedervorlage einfließen, nicht daneben stehen.
290 */
291 function pruefung(): Pruefung {
292 return pruefungInstanz(lernstand(), katalogLaden());
293 }
294
295 export function ipcRegistrieren(): void {
296 behandeln('anwendung:info', () => anwendungsInfo());
297 behandeln('einstellungen:lesen', () => einstellungenLesen());
298 behandeln('einstellungen:schreiben', (_ereignis, einstellungen) =>
299 einstellungenSchreiben(einstellungen),
300 );
301
302 /* Electron meldet über `accessibilitySupportEnabled`, ob eine assistive
303 Technologie erkannt wurde. Die Oberfläche richtet danach die Voreinstellung
304 der Zeichenkürzel aus – sie kollidieren im Lesemodus von NVDA und JAWS mit
305 deren Schnellnavigation. */
306 behandeln('system:hilfsmittel', () => app.accessibilitySupportEnabled);
307
308 /*
309 Die Zwischenablage liegt im Hauptprozess, weil der Renderer sie nicht
310 bekommt: `sicherheit.ts` lehnt sämtliche Berechtigungen ab, und Blink
311 fragt für `navigator.clipboard.writeText()` eine ab. Nachgemessen im
312 gebauten Fenster – `isSecureContext` true, Aufruf trotzdem
313 `NotAllowedError: Write permission denied`.
314
315 Die Obergrenze ist kein Misstrauen gegen die eigene Oberfläche, sondern
316 dieselbe Sorgfalt wie bei jeder anderen Nutzlast: Was über die Brücke
317 kommt, wird geprüft, bevor es wirkt. Der Meldeblock ist rund 500 Zeichen
318 lang; 20 000 lassen jede vorstellbare Erweiterung zu und schließen aus,
319 dass ein Fehler anderswo die Zwischenablage des Nutzers mit Unrat füllt.
320 */
321 behandeln('system:kopieren', (_ereignis, anfrage) => {
322 const text = feld(anfrage, 'text');
323 if (typeof text !== 'string' || text.length === 0 || text.length > 20_000) {
324 return false;
325 }
326 clipboard.writeText(text);
327 return true;
328 });
329
330 /*
331 Der einzige Kanal, der nach außen führt. Er nimmt eine Adresse entgegen,
332 damit prüfbar bleibt, dass die Oberfläche genau die anfordert, die sie
333 anzeigt – geöffnet wird aber ausschließlich die eine eingetragene, siehe
334 `sicherheit.ts`. Ohne Eintrag öffnet er nichts, und die Oberfläche zeigt
335 das Angebot dann gar nicht erst an.
336 */
337 behandeln('system:unterstuetzung', (_ereignis, anfrage) =>
338 unterstuetzungOeffnen(feld(anfrage, 'url')),
339 );
340
341 /*
342 Öffnet ausschließlich, was dieser Prozess in dieser Sitzung selbst
343 geschrieben hat. Der Renderer schickt eine Kennung, keinen Pfad – wer
344 einen Pfad mitbringen darf, darf auch einen anderen mitbringen.
345 */
346 behandeln('system:datei-zeigen', async (_ereignis, anfrage) => {
347 const wunsch = feld(anfrage, 'wunsch');
348 return dateiZeigen(feld(anfrage, 'kennung'), wunsch === 'ordner' ? 'ordner' : 'oeffnen');
349 });
350
351 /* Kein Argument: Der Pfad wird hier gebildet und nicht entgegengenommen.
352 Ein Kanal, der einen Ordner entgegennähme, wäre ein Weg, beliebige
353 Ordner des Rechners zu öffnen. */
354 behandeln('sicherung:ordner-zeigen', () =>
355 ordnerZeigen(selbstsicherungsordner(app.getPath('userData'))),
356 );
357
358 behandeln('ansicht:groesse', (_ereignis, anfrage) =>
359 anzeigegroesseSetzen(feld(anfrage, 'prozent')),
360 );
361
362 behandeln('katalog:laden', () => katalogLaden());
363 behandeln('katalog:bild', (_ereignis, anfrage) => katalogBild(feld(anfrage, 'bildId')));
364 behandeln('erklaerungen:laden', () => erklaerungenLaden());
365 behandeln('glossar:laden', () => glossarLaden());
366 behandeln('gesetz:normtexte', () => normtexteLaden());
367 behandeln('themen:laden', () => themenLaden());
368
369 behandeln('profil:liste', () => lernstand().profile());
370 behandeln('profil:anlegen', (_ereignis, anfrage) =>
371 lernstand().profilAnlegen(feld(anfrage, 'name')),
372 );
373 behandeln('profil:aktualisieren', (_ereignis, anfrage) =>
374 lernstand().profilAktualisieren(anfrage),
375 );
376 behandeln('profil:loeschen', (_ereignis, anfrage) => {
377 /* Erst die Kopie, dann das Löschen: Misslingt sie, wird nichts
378 gelöscht – der Fehler geht an die Oberfläche. */
379 kopieVorVerwerfen();
380 return lernstand().profilLoeschen(feld(anfrage, 'id'));
381 });
382
383 behandeln('lernen:sitzung', (_ereignis, anfrage) =>
384 lernstand().sitzung(feld(anfrage, 'profilId'), feld(anfrage, 'filter')),
385 );
386 behandeln('lernen:antworten', (_ereignis, anfrage) =>
387 lernstand().antworten(feld(anfrage, 'profilId'), feld(anfrage, 'protokoll')),
388 );
389 behandeln('lernen:merken', (_ereignis, anfrage) =>
390 lernstand().merken(
391 feld(anfrage, 'profilId'),
392 feld(anfrage, 'frageId'),
393 feld(anfrage, 'gemerkt'),
394 ),
395 );
396 behandeln('lernen:fragestand', (_ereignis, anfrage) =>
397 lernstand().frageStand(feld(anfrage, 'profilId'), feld(anfrage, 'frageId')),
398 );
399 behandeln('lernen:hartnaeckige', (_ereignis, anfrage) =>
400 lernstand().hartnaeckige(feld(anfrage, 'profilId')),
401 );
402 behandeln('lernen:uebersicht', (_ereignis, anfrage) =>
403 lernstand().uebersicht(feld(anfrage, 'profilId')),
404 );
405 behandeln('lernen:verlauf', (_ereignis, anfrage) =>
406 lernstand().reifeverlauf(feld(anfrage, 'profilId'), feld(anfrage, 'tage')),
407 );
408 behandeln('lernen:plan', (_ereignis, anfrage) => lernstand().lernplan(feld(anfrage, 'profilId')));
409 behandeln('lernen:zuruecksetzen', (_ereignis, anfrage) => {
410 kopieVorVerwerfen();
411 return lernstand().zuruecksetzen(feld(anfrage, 'profilId'), feld(anfrage, 'kapitel'));
412 });
413
414 /*
415 Sicherung des Lernstands. Drei Kanäle, weil zwischen „das steht in der
416 Datei" und „ja, ersetze meinen Lernstand" eine Entscheidung des Nutzers
417 liegt: Der Prüfschritt ist folgenlos, erst `einspielen` fasst etwas an.
418
419 Die Umgebung wird bei jedem Aufruf frisch gebaut – `lernstand()` muss beim
420 Einspielen NACH dem Ersetzen eine neue Instanz liefern, und ein
421 festgehaltener Verweis zeigte auf die geschlossene alte.
422 */
423 const sicherungsumgebung = (): Sicherungsumgebung => ({
424 lernstand,
425 katalog: katalogLaden,
426 Datenbank: datenbankKonstruktor(),
427 jetzt: () => new Date(),
428 });
429
430 behandeln('sicherung:anlegen', async (ereignis) =>
431 sicherungAnlegen(BrowserWindow.fromWebContents(ereignis.sender), sicherungsumgebung()),
432 );
433
434 behandeln('sicherung:pruefen', async (ereignis) =>
435 sicherungPruefen(BrowserWindow.fromWebContents(ereignis.sender), sicherungsumgebung()),
436 );
437
438 behandeln('sicherung:einspielen', (ereignis, anfrage) =>
439 sicherungEinspielen(
440 feld(anfrage, 'vorgang'),
441 BrowserWindow.fromWebContents(ereignis.sender),
442 sicherungsumgebung(),
443 ),
444 );
445
446 /* Ohne `async`, wie das Einspielen: Der Hauptprozess ist einfädig, und
447 solange nichts abgewartet wird, kann sich kein zweiter Kanal
448 dazwischenschieben und in derselben Datenbank schreiben. */
449 behandeln('sicherung:uebernehmen', (ereignis, anfrage) =>
450 sicherungUebernehmen(
451 feld(anfrage, 'vorgang'),
452 feld(anfrage, 'profilIndex'),
453 BrowserWindow.fromWebContents(ereignis.sender),
454 sicherungsumgebung(),
455 ),
456 );
457
458 /* Der Speicherdialog hängt am Fenster, aus dem der Auftrag kam – modal ist
459 für Screenreader die klarere Lage als ein freischwebender Dialog. */
460 behandeln('druck:lernbericht', async (ereignis, anfrage) =>
461 lernberichtExportieren(
462 {
463 lernstand: lernstand(),
464 pruefung: pruefung(),
465 katalog: katalogLaden(),
466 elternfenster: BrowserWindow.fromWebContents(ereignis.sender),
467 jetzt: () => new Date(),
468 },
469 feld(anfrage, 'profilId'),
470 feld(anfrage, 'schriftgroesse'),
471 ),
472 );
473
474 behandeln('druck:fehlerprotokoll', async (ereignis, anfrage) =>
475 fehlerprotokollExportieren(
476 {
477 lernstand: lernstand(),
478 katalog: katalogLaden(),
479 elternfenster: BrowserWindow.fromWebContents(ereignis.sender),
480 jetzt: () => new Date(),
481 },
482 feld(anfrage, 'profilId'),
483 feld(anfrage, 'schriftgroesse'),
484 feld(anfrage, 'tiefe'),
485 ),
486 );
487
488 behandeln('druck:fragenliste', async (ereignis, anfrage) =>
489 fragenlisteExportieren(
490 {
491 lernstand: lernstand(),
492 katalog: katalogLaden(),
493 elternfenster: BrowserWindow.fromWebContents(ereignis.sender),
494 jetzt: () => new Date(),
495 },
496 feld(anfrage, 'bereiche'),
497 feld(anfrage, 'schriftgroesse'),
498 feld(anfrage, 'mitLoesungen'),
499 ),
500 );
501
502 behandeln('pruefung:starten', (_ereignis, anfrage) =>
503 pruefung().starten(feld(anfrage, 'profilId'), feld(anfrage, 'auftrag')),
504 );
505 behandeln('pruefung:auswerten', (_ereignis, anfrage) =>
506 pruefung().auswerten(
507 feld(anfrage, 'profilId'),
508 feld(anfrage, 'auftrag'),
509 feld(anfrage, 'antworten'),
510 feld(anfrage, 'dauerMs'),
511 feld(anfrage, 'zeitAbgelaufen'),
512 ),
513 );
514 behandeln('pruefung:sichern', (_ereignis, anfrage) =>
515 pruefung().sichern(feld(anfrage, 'profilId'), feld(anfrage, 'stand')),
516 );
517 behandeln('pruefung:offen', (_ereignis, anfrage) =>
518 pruefung().offenerBogen(feld(anfrage, 'profilId')),
519 );
520 behandeln('pruefung:verwerfen', (_ereignis, anfrage) => {
521 pruefung().verwerfen(feld(anfrage, 'profilId'));
522 return undefined;
523 });
524 behandeln('pruefung:laeuft', (ereignis, anfrage) => {
525 /* An den sendenden `webContents` gebunden, nicht an ein Modul-Merkmal:
526 Sonst spräche ein zweites Fenster für das erste mit. */
527 const laeuft = feld(anfrage, 'laeuft');
528 laufMelden(ereignis.sender, laeuft === true);
529 return undefined;
530 });
531 behandeln('pruefung:verlauf', (_ereignis, anfrage) =>
532 pruefung().verlauf(feld(anfrage, 'profilId')),
533 );
534
535 behandeln('lizenzen:lesen', () => lizenzenLesen());
536 behandeln('datenschutz:lesen', () => datenschutzLesen());
537 }
538
539 /**
540 * Meldet Änderungen des Hilfsmittel-Zustands an alle Fenster.
541 *
542 * Ein Screenreader kann auch mitten in der Sitzung gestartet werden – gerade
543 * dann, wenn jemand merkt, dass er ihn braucht. Ohne diese Meldung bliebe die
544 * Oberfläche auf dem Stand des Programmstarts.
545 */
546 function hilfsmittelMelden(_ereignis: unknown, aktiv: boolean): void {
547 for (const fenster of BrowserWindow.getAllWindows()) {
548 if (!fenster.isDestroyed()) {
549 fenster.webContents.send(KANAL_HILFSMITTEL_GEAENDERT, aktiv);
550 }
551 }
552 }
553
554 export function hilfsmittelBeobachten(): void {
555 /* Benannt und nicht anonym, damit `ipcAbmelden` ihn wieder lösen kann.
556 Anonym ließ er sich nicht entfernen: `ipcAbmelden` heißt „Gegenstück für
557 Tests und sauberes Herunterfahren“ und hielt das bis Fassung 0.24.1 für
558 diesen einen Zuhörer nicht ein. In der Auslieferung folgenlos – der
559 Aufruf geschieht genau einmal –, aber ein Gegenstück, das nicht alles
560 zurücknimmt, ist beim nächsten Mal die Falle. */
561 app.on('accessibility-support-changed', hilfsmittelMelden);
562 }
563
564 /**
565 * Legt beim Beenden eine Sicherheitskopie an, wenn eine fällig ist.
566 *
567 * **Vor** dem Schließen: `VACUUM INTO` braucht die offene Verbindung. Und
568 * nur, wenn der Lernstand in dieser Sitzung überhaupt geöffnet wurde – wer
569 * das Programm startet und gleich wieder beendet, hat nichts zu sichern.
570 *
571 * Ein Fehlschlag hält das Beenden nicht auf. Eine Anwendung, die sich wegen
572 * einer misslungenen Nebensache nicht schließen lässt, wäre der schlechtere
573 * Handel; die Kopie ist eine Zugabe, kein Versprechen.
574 */
575 /**
576 * Sicherheitskopie vor einem Schritt, der Daten vernichtet.
577 *
578 * Misslingt sie, wirft sie – und `behandeln` reicht den Fehler an die
579 * Oberfläche durch, ohne dass der Schritt ausgeführt wird. Das ist der
580 * Unterschied zur Kopie beim Beenden: Dort ist sie eine Zugabe, hier ist sie
581 * die Bedingung.
582 */
583 function kopieVorVerwerfen(): void {
584 const offen = lernstandOffen();
585 if (offen === null) {
586 return;
587 }
588 const pfad = kopieVorDemVerwerfen(
589 offen.datenbank,
590 app.getPath('userData'),
591 datenbankKonstruktor(),
592 );
593 console.info(`[sicherung] Sicherheitskopie vor dem Verwerfen: ${pfad}`);
594 }
595
596 function selbstsicherungBeimBeenden(): void {
597 const offen = lernstandOffen();
598 if (offen === null) {
599 return;
600 }
601
602 try {
603 const pfad = selbstsicherungAnlegen(
604 offen.datenbank,
605 app.getPath('userData'),
606 datenbankKonstruktor(),
607 );
608 if (pfad !== null) {
609 console.info(`[sicherung] Selbsttätige Sicherheitskopie: ${pfad}`);
610 }
611 } catch (fehler: unknown) {
612 console.warn('[sicherung] Selbsttätige Sicherheitskopie misslang:', fehler);
613 }
614 }
615
616 /** Gegenstück für Tests und sauberes Herunterfahren. */
617 export function ipcAbmelden(): void {
618 for (const kanal of IPC_KANAELE) {
619 ipcMain.removeHandler(kanal);
620 }
621 app.off('accessibility-support-changed', hilfsmittelMelden);
622 pruefungZuruecksetzen();
623 selbstsicherungBeimBeenden();
624 lernstandSchliessen();
625 }