/** * Registrierung der IPC-Handler – ausschließlich über den typisierten Vertrag * aus `src/shared/ipc.ts`. * * Die Typisierung beschreibt, was der Renderer schicken SOLL. Was er * tatsächlich schickt, ist an dieser Grenze unbekannt: die Nutzlasten werden * deshalb in den Fachmodulen (`katalog.ts`, `lernstand.ts`) zur Laufzeit * geprüft, bevor irgendetwas damit geschieht. */ import { join } from 'node:path'; import type BetterSqlite3 from 'better-sqlite3'; import { app, BrowserWindow, clipboard, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron'; import { IPC_KANAELE, KANAL_HILFSMITTEL_GEAENDERT, type AnwendungsInfo, type IpcAnfrage, type IpcAntwort, type IpcKanal, type Katalogwechsel, } from '../shared/ipc'; import { anzeigegroesseSetzen } from './anzeige'; import { dateiZeigen, ordnerZeigen } from './dateizugriff'; import { datenbankStatus } from './datenbank'; import { einstellungenLesen, einstellungenSchreiben } from './einstellungen'; import { erklaerungenLaden } from './erklaerungen'; import { glossarLaden } from './glossar'; import { normtexteLaden } from './normtexte'; import { themenLaden } from './themen'; import { katalogBild, katalogLaden } from './katalog'; import { fehlerprotokollExportieren, fragenlisteExportieren } from './fragendruck-export'; import { lernberichtExportieren } from './lernbericht-export'; import { datenbankKonstruktor, istBeschaedigt, lernstandBeiseitelegen, lernstandInstanz, lernstandOffen, lernstandSchliessen, type Lernstand, } from './lernstand'; import { protokollPfad } from './protokoll'; import { kopieVorDemVerwerfen, selbstsicherungAnlegen, selbstsicherungsordner, } from './selbstsicherung'; import { datenschutzLesen } from './datenschutz'; import { lizenzenLesen } from './lizenzen'; import { pruefungInstanz, pruefungZuruecksetzen, type Pruefung } from './pruefung'; import { sicherungAnlegen, sicherungEinspielen, sicherungUebernehmen, sicherungPruefen, type Umgebung as Sicherungsumgebung, } from './sicherung-dialoge'; import { laufMelden } from './laufwaechter'; import { istErlaubteNavigation, unterstuetzungOeffnen } from './sicherheit'; /** Dateiname des Lernstands im `userData`-Verzeichnis. */ const LERNSTAND_DATEI = 'lernstand.db'; /** * Anwendungsversion, zur Bauzeit aus der package.json eingesetzt * (siehe `electron.vite.config.ts`). * * `app.getVersion()` taugt dafür nicht durchgängig: Wird der gebaute * Einstiegspunkt direkt gestartet – in der Entwicklung und in den * Oberflächentests – findet Electron keine package.json und liefert * ersatzweise seine eigene Version zurück. */ declare const __ANWENDUNGS_VERSION__: string | undefined; function anwendungsVersion(): string { return typeof __ANWENDUNGS_VERSION__ === 'string' ? __ANWENDUNGS_VERSION__ : app.getVersion(); } /** * Baukennung und Commit-Datum, ebenfalls zur Bauzeit eingesetzt. * * Ohne Git beim Bauen bleiben beide leer; die Oberfläche lässt die Zeile * dann weg, statt „unbekannt“ anzuzeigen. */ declare const __BAUKENNUNG__: string | undefined; declare const __BAUSTAND__: string | undefined; function zeichenkette(wert: string | undefined): string { return typeof wert === 'string' ? wert : ''; } /** * Stammt die Anfrage aus unserer eigenen Oberfläche? * * Zweite Verteidigungslinie hinter der Navigationssperre in `sicherheit.ts`. * Käme eine fremde Seite doch einmal in den Renderer, hätte sie über das * Preload-Skript die volle Bridge in der Hand – hier endet das trotzdem. * * Ein Rahmen ohne URL wird durchgelassen: Das kommt vor, wenn der Rahmen * gerade abgebaut wird, und wäre kein Angriff, sondern ein Wettlauf. */ function vonEigenerOberflaeche(ereignis: IpcMainInvokeEvent): boolean { const herkunft = ereignis.senderFrame?.url; return herkunft === undefined || herkunft === '' || istErlaubteNavigation(herkunft); } /** * Typsichere Hülle um `ipcMain.handle`: Kanal, Anfrage- und Antworttyp werden * aus {@link IpcVertrag} abgeleitet, sodass Tippfehler zu Compilerfehlern * werden statt zu stillen `undefined`-Antworten. */ function behandeln( kanal: K, /* Auch ein Versprechen ist erlaubt: `ipcMain.handle` wartet es ab. Gebraucht wird das vom Export, der einen Dialog zeigt und danach schreibt. */ behandler: ( ereignis: IpcMainInvokeEvent, anfrage: IpcAnfrage, ) => IpcAntwort | Promise>, ): void { ipcMain.handle(kanal, (ereignis, anfrage: unknown) => { if (!vonEigenerOberflaeche(ereignis)) { console.warn(`[sicherheit] IPC-Anfrage fremder Herkunft abgewiesen: ${kanal}`); throw new Error('Diese Anfrage stammt nicht aus der Oberfläche dieser Anwendung.'); } return behandler(ereignis, anfrage as IpcAnfrage); }); } /** * Holt ein Feld aus einer IPC-Nutzlast. Trotz Typisierung wird hier nichts * vorausgesetzt – die eigentliche Prüfung der Werte macht das Fachmodul. */ function feld(anfrage: unknown, name: string): unknown { if (typeof anfrage !== 'object' || anfrage === null || Array.isArray(anfrage)) { throw new Error(`Ungültige Anfrage: Es wurde ein Objekt mit dem Feld „${name}“ erwartet.`); } return (anfrage as Record)[name]; } /** * Befund des Katalogstand-Abgleichs. Der Abgleich selbst läuft beim Öffnen * des Lernstands; hier wird er nur abgeholt. Scheitert das Öffnen – etwa bei * einem Lernstand aus einer neueren Programmversion oder ohne Katalog –, * antwortet die Systemauskunft trotzdem: Diese Fehler haben ihre eigenen * Meldungen an anderer Stelle. */ function katalogwechselLesen(): Katalogwechsel | null { try { return lernstand().katalogwechsel; } catch { return null; } } function anwendungsInfo(): AnwendungsInfo { return { anwendungsVersion: anwendungsVersion(), baukennung: zeichenkette(__BAUKENNUNG__), baustand: zeichenkette(__BAUSTAND__), electronVersion: process.versions.electron, chromeVersion: process.versions.chrome, nodeVersion: process.versions.node, plattform: process.platform, datenbank: datenbankStatus(), katalogwechsel: katalogwechselLesen(), protokollPfad: protokollPfad(), }; } /** * Lernstand beim ersten Bedarf öffnen. Der Katalog wird dabei mitgeladen – * ohne ihn lassen sich weder Fragen zusammenstellen noch Statistiken bilden. * * **Der eine Fall, in dem es hier etwas anzubieten gibt.** Ist die Datei * beschädigt, endete das bis 0.22.0 in einer Sackgasse: Der Renderer meldete * „Ihr Lernprofil konnte nicht geladen werden … Antworten werden dabei nicht * gespeichert“, und dabei blieb es bei jedem Start aufs Neue. Wer nicht von * sich aus auf die Idee kam, im `userData`-Verzeichnis nachzusehen, konnte * die Anwendung nie wieder benutzen. * * Deshalb fragt der Hauptprozess hier nach. Der Dialog ist bewusst * synchron und nativ: Er muss vorliegen, bevor dieser Aufruf zurückkehrt, * und er wird auch dann noch vorgelesen, wenn die Oberfläche nichts anzeigen * kann. Nichts wird von selbst getan – die beschädigte Datei bleibt liegen, * bis jemand ausdrücklich zustimmt, und auch dann wird sie nur umbenannt. */ /** * Merker: Die Kopie beim Öffnen wird je Programmlauf einmal erwogen. * * Nicht einmal je Aufruf – `lernstand()` läuft bei jedem IPC-Kanal. */ let kopieBeimOeffnenErwogen = false; /** * Selbsttätige Kopie auch beim **Öffnen**, nicht nur beim Beenden. * * **Die Lücke.** Die Wochenkopie entstand ausschließlich beim geordneten * Beenden. Stürzt die Anwendung ab, fällt der Strom aus oder beendet jemand * den Prozess über den Taskmanager, entsteht sie nie – und genau in diesen * Fällen ist eine Kopie am ehesten etwas wert. Wer eine Woche lang jeden Tag * abstürzt, hatte am Ende keine einzige. * * **Warum das nichts kostet.** Gerufen wird dieselbe, unverändert über * `istFaellig` gedrosselte Funktion: Ist die letzte Kopie jünger als eine * Woche, kehrt sie ohne Schreiben um. Es entsteht also keine zweite Kopie je * Lauf, sondern dieselbe eine – nur früher, und auch dann, wenn der Lauf * nicht ordentlich endet. * * Wie beim Beenden ist sie eine Zugabe: Misslingt sie, läuft die Anwendung * weiter. Anders als die Kopie vor dem Verwerfen steht hier kein Schritt * dahinter, der Daten vernichtet. */ function selbstsicherungBeimOeffnen(datenbank: BetterSqlite3.Database): void { if (kopieBeimOeffnenErwogen) { return; } kopieBeimOeffnenErwogen = true; try { const pfad = selbstsicherungAnlegen(datenbank, app.getPath('userData'), datenbankKonstruktor()); if (pfad !== null) { console.info(`[sicherung] Selbsttätige Sicherheitskopie beim Öffnen: ${pfad}`); } } catch (fehler: unknown) { console.warn('[sicherung] Selbsttätige Sicherheitskopie beim Öffnen misslang:', fehler); } } function lernstand(): Lernstand { const pfad = join(app.getPath('userData'), LERNSTAND_DATEI); try { const stand = lernstandInstanz(pfad, katalogLaden(), themenLaden()); selbstsicherungBeimOeffnen(stand.datenbank); return stand; } catch (fehler: unknown) { if (!istBeschaedigt(fehler)) { throw fehler; } return beschaedigtenLernstandKlaeren(pfad, fehler); } } /** * Bietet nach einer beschädigten Datei den Neuanfang an – einmal je Start. * * Der Merker verhindert eine Dialogschleife: Jeder IPC-Kanal, der einen * Lernstand braucht, liefe sonst in dieselbe Rückfrage, und der Startbildschirm * fragt mehrere davon gleich nacheinander ab. */ let beschaedigungGeklaert = false; function beschaedigtenLernstandKlaeren(pfad: string, fehler: unknown): Lernstand { if (beschaedigungGeklaert) { throw fehler; } beschaedigungGeklaert = true; const wahl = dialog.showMessageBoxSync({ type: 'error', buttons: ['Beiseitelegen und neu anfangen', 'Nichts tun'], defaultId: 1, cancelId: 1, noLink: true, title: 'Waffensachkunde – Lernsoftware', message: 'Die Datei mit Ihrem Lernstand ist beschädigt.', detail: [ '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.', 'Haben Sie eine Sicherung, können Sie sie danach unter „Lernstand sichern und übertragen“ einspielen.', `Die beschädigte Datei bleibt in jedem Fall erhalten unter:\n${pfad}`, ].join('\n\n'), }); if (wahl !== 0) { throw fehler; } const beiseite = lernstandBeiseitelegen(pfad); console.warn(`[lernstand] Beschädigte Datei beiseitegelegt: ${beiseite}`); return lernstandInstanz(pfad, katalogLaden(), themenLaden()); } /** * Die Prüfungssimulation arbeitet auf demselben Lernstand: ein Lauf soll in * Statistik und Wiedervorlage einfließen, nicht daneben stehen. */ function pruefung(): Pruefung { return pruefungInstanz(lernstand(), katalogLaden()); } export function ipcRegistrieren(): void { behandeln('anwendung:info', () => anwendungsInfo()); behandeln('einstellungen:lesen', () => einstellungenLesen()); behandeln('einstellungen:schreiben', (_ereignis, einstellungen) => einstellungenSchreiben(einstellungen), ); /* Electron meldet über `accessibilitySupportEnabled`, ob eine assistive Technologie erkannt wurde. Die Oberfläche richtet danach die Voreinstellung der Zeichenkürzel aus – sie kollidieren im Lesemodus von NVDA und JAWS mit deren Schnellnavigation. */ behandeln('system:hilfsmittel', () => app.accessibilitySupportEnabled); /* Die Zwischenablage liegt im Hauptprozess, weil der Renderer sie nicht bekommt: `sicherheit.ts` lehnt sämtliche Berechtigungen ab, und Blink fragt für `navigator.clipboard.writeText()` eine ab. Nachgemessen im gebauten Fenster – `isSecureContext` true, Aufruf trotzdem `NotAllowedError: Write permission denied`. Die Obergrenze ist kein Misstrauen gegen die eigene Oberfläche, sondern dieselbe Sorgfalt wie bei jeder anderen Nutzlast: Was über die Brücke kommt, wird geprüft, bevor es wirkt. Der Meldeblock ist rund 500 Zeichen lang; 20 000 lassen jede vorstellbare Erweiterung zu und schließen aus, dass ein Fehler anderswo die Zwischenablage des Nutzers mit Unrat füllt. */ behandeln('system:kopieren', (_ereignis, anfrage) => { const text = feld(anfrage, 'text'); if (typeof text !== 'string' || text.length === 0 || text.length > 20_000) { return false; } clipboard.writeText(text); return true; }); /* Der einzige Kanal, der nach außen führt. Er nimmt eine Adresse entgegen, damit prüfbar bleibt, dass die Oberfläche genau die anfordert, die sie anzeigt – geöffnet wird aber ausschließlich die eine eingetragene, siehe `sicherheit.ts`. Ohne Eintrag öffnet er nichts, und die Oberfläche zeigt das Angebot dann gar nicht erst an. */ behandeln('system:unterstuetzung', (_ereignis, anfrage) => unterstuetzungOeffnen(feld(anfrage, 'url')), ); /* Öffnet ausschließlich, was dieser Prozess in dieser Sitzung selbst geschrieben hat. Der Renderer schickt eine Kennung, keinen Pfad – wer einen Pfad mitbringen darf, darf auch einen anderen mitbringen. */ behandeln('system:datei-zeigen', async (_ereignis, anfrage) => { const wunsch = feld(anfrage, 'wunsch'); return dateiZeigen(feld(anfrage, 'kennung'), wunsch === 'ordner' ? 'ordner' : 'oeffnen'); }); /* Kein Argument: Der Pfad wird hier gebildet und nicht entgegengenommen. Ein Kanal, der einen Ordner entgegennähme, wäre ein Weg, beliebige Ordner des Rechners zu öffnen. */ behandeln('sicherung:ordner-zeigen', () => ordnerZeigen(selbstsicherungsordner(app.getPath('userData'))), ); behandeln('ansicht:groesse', (_ereignis, anfrage) => anzeigegroesseSetzen(feld(anfrage, 'prozent')), ); behandeln('katalog:laden', () => katalogLaden()); behandeln('katalog:bild', (_ereignis, anfrage) => katalogBild(feld(anfrage, 'bildId'))); behandeln('erklaerungen:laden', () => erklaerungenLaden()); behandeln('glossar:laden', () => glossarLaden()); behandeln('gesetz:normtexte', () => normtexteLaden()); behandeln('themen:laden', () => themenLaden()); behandeln('profil:liste', () => lernstand().profile()); behandeln('profil:anlegen', (_ereignis, anfrage) => lernstand().profilAnlegen(feld(anfrage, 'name')), ); behandeln('profil:aktualisieren', (_ereignis, anfrage) => lernstand().profilAktualisieren(anfrage), ); behandeln('profil:loeschen', (_ereignis, anfrage) => { /* Erst die Kopie, dann das Löschen: Misslingt sie, wird nichts gelöscht – der Fehler geht an die Oberfläche. */ kopieVorVerwerfen(); return lernstand().profilLoeschen(feld(anfrage, 'id')); }); behandeln('lernen:sitzung', (_ereignis, anfrage) => lernstand().sitzung(feld(anfrage, 'profilId'), feld(anfrage, 'filter')), ); behandeln('lernen:antworten', (_ereignis, anfrage) => lernstand().antworten(feld(anfrage, 'profilId'), feld(anfrage, 'protokoll')), ); behandeln('lernen:merken', (_ereignis, anfrage) => lernstand().merken( feld(anfrage, 'profilId'), feld(anfrage, 'frageId'), feld(anfrage, 'gemerkt'), ), ); behandeln('lernen:fragestand', (_ereignis, anfrage) => lernstand().frageStand(feld(anfrage, 'profilId'), feld(anfrage, 'frageId')), ); behandeln('lernen:hartnaeckige', (_ereignis, anfrage) => lernstand().hartnaeckige(feld(anfrage, 'profilId')), ); behandeln('lernen:uebersicht', (_ereignis, anfrage) => lernstand().uebersicht(feld(anfrage, 'profilId')), ); behandeln('lernen:verlauf', (_ereignis, anfrage) => lernstand().reifeverlauf(feld(anfrage, 'profilId'), feld(anfrage, 'tage')), ); behandeln('lernen:plan', (_ereignis, anfrage) => lernstand().lernplan(feld(anfrage, 'profilId'))); behandeln('lernen:zuruecksetzen', (_ereignis, anfrage) => { kopieVorVerwerfen(); return lernstand().zuruecksetzen(feld(anfrage, 'profilId'), feld(anfrage, 'kapitel')); }); /* Sicherung des Lernstands. Drei Kanäle, weil zwischen „das steht in der Datei" und „ja, ersetze meinen Lernstand" eine Entscheidung des Nutzers liegt: Der Prüfschritt ist folgenlos, erst `einspielen` fasst etwas an. Die Umgebung wird bei jedem Aufruf frisch gebaut – `lernstand()` muss beim Einspielen NACH dem Ersetzen eine neue Instanz liefern, und ein festgehaltener Verweis zeigte auf die geschlossene alte. */ const sicherungsumgebung = (): Sicherungsumgebung => ({ lernstand, katalog: katalogLaden, Datenbank: datenbankKonstruktor(), jetzt: () => new Date(), }); behandeln('sicherung:anlegen', async (ereignis) => sicherungAnlegen(BrowserWindow.fromWebContents(ereignis.sender), sicherungsumgebung()), ); behandeln('sicherung:pruefen', async (ereignis) => sicherungPruefen(BrowserWindow.fromWebContents(ereignis.sender), sicherungsumgebung()), ); behandeln('sicherung:einspielen', (ereignis, anfrage) => sicherungEinspielen( feld(anfrage, 'vorgang'), BrowserWindow.fromWebContents(ereignis.sender), sicherungsumgebung(), ), ); /* Ohne `async`, wie das Einspielen: Der Hauptprozess ist einfädig, und solange nichts abgewartet wird, kann sich kein zweiter Kanal dazwischenschieben und in derselben Datenbank schreiben. */ behandeln('sicherung:uebernehmen', (ereignis, anfrage) => sicherungUebernehmen( feld(anfrage, 'vorgang'), feld(anfrage, 'profilIndex'), BrowserWindow.fromWebContents(ereignis.sender), sicherungsumgebung(), ), ); /* Der Speicherdialog hängt am Fenster, aus dem der Auftrag kam – modal ist für Screenreader die klarere Lage als ein freischwebender Dialog. */ behandeln('druck:lernbericht', async (ereignis, anfrage) => lernberichtExportieren( { lernstand: lernstand(), pruefung: pruefung(), katalog: katalogLaden(), elternfenster: BrowserWindow.fromWebContents(ereignis.sender), jetzt: () => new Date(), }, feld(anfrage, 'profilId'), feld(anfrage, 'schriftgroesse'), ), ); behandeln('druck:fehlerprotokoll', async (ereignis, anfrage) => fehlerprotokollExportieren( { lernstand: lernstand(), katalog: katalogLaden(), elternfenster: BrowserWindow.fromWebContents(ereignis.sender), jetzt: () => new Date(), }, feld(anfrage, 'profilId'), feld(anfrage, 'schriftgroesse'), feld(anfrage, 'tiefe'), ), ); behandeln('druck:fragenliste', async (ereignis, anfrage) => fragenlisteExportieren( { lernstand: lernstand(), katalog: katalogLaden(), elternfenster: BrowserWindow.fromWebContents(ereignis.sender), jetzt: () => new Date(), }, feld(anfrage, 'bereiche'), feld(anfrage, 'schriftgroesse'), feld(anfrage, 'mitLoesungen'), ), ); behandeln('pruefung:starten', (_ereignis, anfrage) => pruefung().starten(feld(anfrage, 'profilId'), feld(anfrage, 'auftrag')), ); behandeln('pruefung:auswerten', (_ereignis, anfrage) => pruefung().auswerten( feld(anfrage, 'profilId'), feld(anfrage, 'auftrag'), feld(anfrage, 'antworten'), feld(anfrage, 'dauerMs'), feld(anfrage, 'zeitAbgelaufen'), ), ); behandeln('pruefung:sichern', (_ereignis, anfrage) => pruefung().sichern(feld(anfrage, 'profilId'), feld(anfrage, 'stand')), ); behandeln('pruefung:offen', (_ereignis, anfrage) => pruefung().offenerBogen(feld(anfrage, 'profilId')), ); behandeln('pruefung:verwerfen', (_ereignis, anfrage) => { pruefung().verwerfen(feld(anfrage, 'profilId')); return undefined; }); behandeln('pruefung:laeuft', (ereignis, anfrage) => { /* An den sendenden `webContents` gebunden, nicht an ein Modul-Merkmal: Sonst spräche ein zweites Fenster für das erste mit. */ const laeuft = feld(anfrage, 'laeuft'); laufMelden(ereignis.sender, laeuft === true); return undefined; }); behandeln('pruefung:verlauf', (_ereignis, anfrage) => pruefung().verlauf(feld(anfrage, 'profilId')), ); behandeln('lizenzen:lesen', () => lizenzenLesen()); behandeln('datenschutz:lesen', () => datenschutzLesen()); } /** * Meldet Änderungen des Hilfsmittel-Zustands an alle Fenster. * * Ein Screenreader kann auch mitten in der Sitzung gestartet werden – gerade * dann, wenn jemand merkt, dass er ihn braucht. Ohne diese Meldung bliebe die * Oberfläche auf dem Stand des Programmstarts. */ function hilfsmittelMelden(_ereignis: unknown, aktiv: boolean): void { for (const fenster of BrowserWindow.getAllWindows()) { if (!fenster.isDestroyed()) { fenster.webContents.send(KANAL_HILFSMITTEL_GEAENDERT, aktiv); } } } export function hilfsmittelBeobachten(): void { /* Benannt und nicht anonym, damit `ipcAbmelden` ihn wieder lösen kann. Anonym ließ er sich nicht entfernen: `ipcAbmelden` heißt „Gegenstück für Tests und sauberes Herunterfahren“ und hielt das bis Fassung 0.24.1 für diesen einen Zuhörer nicht ein. In der Auslieferung folgenlos – der Aufruf geschieht genau einmal –, aber ein Gegenstück, das nicht alles zurücknimmt, ist beim nächsten Mal die Falle. */ app.on('accessibility-support-changed', hilfsmittelMelden); } /** * Legt beim Beenden eine Sicherheitskopie an, wenn eine fällig ist. * * **Vor** dem Schließen: `VACUUM INTO` braucht die offene Verbindung. Und * nur, wenn der Lernstand in dieser Sitzung überhaupt geöffnet wurde – wer * das Programm startet und gleich wieder beendet, hat nichts zu sichern. * * Ein Fehlschlag hält das Beenden nicht auf. Eine Anwendung, die sich wegen * einer misslungenen Nebensache nicht schließen lässt, wäre der schlechtere * Handel; die Kopie ist eine Zugabe, kein Versprechen. */ /** * Sicherheitskopie vor einem Schritt, der Daten vernichtet. * * Misslingt sie, wirft sie – und `behandeln` reicht den Fehler an die * Oberfläche durch, ohne dass der Schritt ausgeführt wird. Das ist der * Unterschied zur Kopie beim Beenden: Dort ist sie eine Zugabe, hier ist sie * die Bedingung. */ function kopieVorVerwerfen(): void { const offen = lernstandOffen(); if (offen === null) { return; } const pfad = kopieVorDemVerwerfen( offen.datenbank, app.getPath('userData'), datenbankKonstruktor(), ); console.info(`[sicherung] Sicherheitskopie vor dem Verwerfen: ${pfad}`); } function selbstsicherungBeimBeenden(): void { const offen = lernstandOffen(); if (offen === null) { return; } try { const pfad = selbstsicherungAnlegen( offen.datenbank, app.getPath('userData'), datenbankKonstruktor(), ); if (pfad !== null) { console.info(`[sicherung] Selbsttätige Sicherheitskopie: ${pfad}`); } } catch (fehler: unknown) { console.warn('[sicherung] Selbsttätige Sicherheitskopie misslang:', fehler); } } /** Gegenstück für Tests und sauberes Herunterfahren. */ export function ipcAbmelden(): void { for (const kanal of IPC_KANAELE) { ipcMain.removeHandler(kanal); } app.off('accessibility-support-changed', hilfsmittelMelden); pruefungZuruecksetzen(); selbstsicherungBeimBeenden(); lernstandSchliessen(); }