waffensachkunde

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

/ app src main ipc.ts

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