/** * Preload-Skript – die einzige Brücke zwischen Renderer und Main-Prozess. * * Läuft mit `sandbox: true` und `contextIsolation: true`. Deshalb gilt: * - keine Node-APIs außer `electron`, `events`, `timers`, `url`, * - das Bundle muss vollständig gebündelt sein (keine externen Importe), * - Ausgabeformat CommonJS (`out/preload/index.js`). * * Der Renderer bekommt ausschließlich die Funktionen aus * {@link LernAppBridge} – niemals `ipcRenderer` selbst. */ import { contextBridge, ipcRenderer } from 'electron'; import type { Uebernahmeergebnis } from '../shared/sicherung'; import type { Erklaerungstiefe } from '../shared/druck/fehlerprotokoll'; import type { Schriftgroesse } from '../shared/druck/stil'; import type { Erklaerungen } from '../shared/erklaerungen'; import type { Glossar } from '../shared/glossar'; import type { Normtexte } from '../shared/normtexte'; import type { Verlaufspunkt } from '../shared/reifeverlauf'; import type { Themen } from '../shared/themen'; import type { HartnaeckigeFrage } from '../shared/hartnaeckig'; import type { Katalog } from '../shared/katalog'; import type { Lernplan } from '../shared/lernplan'; import { BRIDGE_NAME, istIpcKanal, istMenuebefehl, KANAL_ANZEIGEGROESSE_GEAENDERT, KANAL_ENERGIESPAREN_ENDE, KANAL_HILFSMITTEL_GEAENDERT, KANAL_MENUE_BEFEHL, type AnwendungsInfo, type Menuebefehl, type Druckergebnis, type Einstellungen, type IpcAnfrage, type IpcAntwort, type IpcKanal, type LernAppBridge, } from '../shared/ipc'; import type { Antwortprotokoll, FrageStand, Lernuebersicht, Profil, SitzungsFilter, SitzungsFrage, } from '../shared/lernstand'; import type { Datenschutzangaben } from '../shared/datenschutz'; import type { Lizenzangaben } from '../shared/lizenzen'; import type { Einspielergebnis, Pruefergebnis, Sicherungsergebnis } from '../shared/sicherung'; import type { OffenerLauf, Pruefungsantwort, Pruefungsauftrag, Pruefungsbogen, Pruefungsergebnis, Pruefungsverlauf, Zwischenstand, } from '../shared/pruefung'; /** * Typsicherer Aufruf. Die Kanalprüfung ist eine zusätzliche Verteidigungslinie: * selbst wenn Renderer-Code manipuliert würde, kommen nur Kanäle aus der * Allowlist durch. */ async function aufrufen( kanal: K, anfrage: IpcAnfrage, ): Promise> { if (!istIpcKanal(kanal)) { throw new Error(`Unbekannter IPC-Kanal: ${String(kanal)}`); } return (await ipcRenderer.invoke(kanal, anfrage)) as IpcAntwort; } const bridge: LernAppBridge = Object.freeze({ anwendungsInfoLesen: (): Promise => aufrufen('anwendung:info', undefined), einstellungenLesen: (): Promise => aufrufen('einstellungen:lesen', undefined), einstellungenSchreiben: (aenderung: Partial): Promise => aufrufen('einstellungen:schreiben', aenderung), // ── Fragenkatalog ──────────────────────────────────────────────────── katalogLaden: (): Promise => aufrufen('katalog:laden', undefined), /** Nur die ID wandert über die Brücke – nie ein Pfad. */ katalogBild: (bildId: string): Promise => aufrufen('katalog:bild', { bildId }), erklaerungenLaden: (): Promise => aufrufen('erklaerungen:laden', undefined), glossarLaden: (): Promise => aufrufen('glossar:laden', undefined), normtexteLaden: (): Promise => aufrufen('gesetz:normtexte', undefined), themenLaden: (): Promise => aufrufen('themen:laden', undefined), // ── Profile ────────────────────────────────────────────────────────── profilListe: (): Promise => aufrufen('profil:liste', undefined), profilAnlegen: (name: string): Promise => aufrufen('profil:anlegen', { name }), profilAktualisieren: ( id: number, aenderung: { name?: string; pruefungstermin?: string | null; kapitelAusschluss?: readonly string[]; }, ): Promise => aufrufen('profil:aktualisieren', { id, ...aenderung }), // ── Lernen ─────────────────────────────────────────────────────────── lernSitzung: (profilId: number, filter: SitzungsFilter): Promise => aufrufen('lernen:sitzung', { profilId, filter }), lernAntworten: (profilId: number, protokoll: Antwortprotokoll): Promise => aufrufen('lernen:antworten', { profilId, protokoll }), lernMerken: (profilId: number, frageId: string, gemerkt: boolean): Promise => aufrufen('lernen:merken', { profilId, frageId, gemerkt }), lernFragestand: (profilId: number, frageId: string): Promise => aufrufen('lernen:fragestand', { profilId, frageId }), lernHartnaeckige: (profilId: number): Promise => aufrufen('lernen:hartnaeckige', { profilId }), lernUebersicht: (profilId: number): Promise => aufrufen('lernen:uebersicht', { profilId }), lernVerlauf: (profilId: number, tage?: number): Promise => aufrufen('lernen:verlauf', tage === undefined ? { profilId } : { profilId, tage }), lernPlan: (profilId: number): Promise => aufrufen('lernen:plan', { profilId }), lernZuruecksetzen: (profilId: number, kapitel?: string | null): Promise => aufrufen('lernen:zuruecksetzen', { profilId, kapitel: kapitel ?? null }), // ── Prüfungssimulation ─────────────────────────────────────────────── pruefungStarten: (profilId: number, auftrag: Pruefungsauftrag): Promise => aufrufen('pruefung:starten', { profilId, auftrag }), /** * Die Richtigkeit der Antworten wird im Main-Prozess nachgerechnet – was * hier hinübergeht, sind ausschließlich die Eingaben des Prüflings. */ pruefungAuswerten: ( profilId: number, auftrag: Pruefungsauftrag, antworten: Pruefungsantwort[], dauerMs: number, zeitAbgelaufen: boolean, ): Promise => aufrufen('pruefung:auswerten', { profilId, auftrag, antworten, dauerMs, zeitAbgelaufen }), pruefungVerlauf: (profilId: number): Promise => aufrufen('pruefung:verlauf', { profilId }), pruefungSichern: (profilId: number, stand: Zwischenstand): Promise => aufrufen('pruefung:sichern', { profilId, stand }), pruefungOffen: (profilId: number): Promise => aufrufen('pruefung:offen', { profilId }), pruefungVerwerfen: (profilId: number): Promise => aufrufen('pruefung:verwerfen', { profilId }), pruefungLaeuft: (laeuft: boolean): Promise => aufrufen('pruefung:laeuft', { laeuft }), // ── Rechtliches ────────────────────────────────────────────────────── anzeigegroesseSetzen: (prozent: number): Promise => aufrufen('ansicht:groesse', { prozent }), anzeigegroesseBeobachten: (melden: (prozent: number) => void): (() => void) => { const zuhoerer = (_ereignis: unknown, prozent: unknown): void => { if (typeof prozent === 'number') { melden(prozent); } }; ipcRenderer.on(KANAL_ANZEIGEGROESSE_GEAENDERT, zuhoerer); return () => { ipcRenderer.off(KANAL_ANZEIGEGROESSE_GEAENDERT, zuhoerer); }; }, sicherungUebernehmen: (vorgang: string, profilIndex: number): Promise => aufrufen('sicherung:uebernehmen', { vorgang, profilIndex }), fehlerprotokollDrucken: ( profilId: number, schriftgroesse: Schriftgroesse, tiefe: Erklaerungstiefe, ): Promise => aufrufen('druck:fehlerprotokoll', { profilId, schriftgroesse, tiefe }), fragenlisteDrucken: ( bereiche: readonly string[], schriftgroesse: Schriftgroesse, mitLoesungen: boolean, ): Promise => aufrufen('druck:fragenliste', { bereiche, schriftgroesse, mitLoesungen }), lernberichtDrucken: (profilId: number, schriftgroesse: Schriftgroesse): Promise => aufrufen('druck:lernbericht', { profilId, schriftgroesse }), profilLoeschen: (id: number): Promise => aufrufen('profil:loeschen', { id }), lizenzenLesen: (): Promise => aufrufen('lizenzen:lesen', undefined), datenschutzLesen: (): Promise => aufrufen('datenschutz:lesen', undefined), hilfsmittelAktiv: (): Promise => aufrufen('system:hilfsmittel', undefined), /* Nur die Zeichenkette wandert hinüber – der Renderer bestimmt den Inhalt, der Hauptprozess führt aus. Genau deshalb gibt es diesen Kanal statt einer gelockerten Berechtigung: Er hat eine feste Nutzlast. */ zwischenablageSchreiben: (text: string): Promise => aufrufen('system:kopieren', { text }), /* Der einzige Weg nach außen – und er führt nicht irgendwohin: Der Hauptprozess vergleicht die Adresse mit der einen eingetragenen und öffnet sonst nichts. Hier steht deshalb bewusst keine Prüfung: Zwei Prüfungen an zwei Stellen laufen auseinander, und maßgeblich ist die im Hauptprozess. */ unterstuetzungOeffnen: (url: string): Promise => aufrufen('system:unterstuetzung', { url }), /* Nur die Kennung wandert hinüber, nie ein Pfad – dieselbe Überlegung wie bei der Sicherung und beim unterbrochenen Prüfungsbogen. */ dateiZeigen: (kennung: string, wunsch: 'oeffnen' | 'ordner'): Promise => aufrufen('system:datei-zeigen', { kennung, wunsch }), /* Ohne Argument – der Hauptprozess weiß, welcher Ordner gemeint ist. */ sicherungsordnerZeigen: (): Promise => aufrufen('sicherung:ordner-zeigen', undefined), sicherungAnlegen: (): Promise => aufrufen('sicherung:anlegen', undefined), sicherungPruefen: (): Promise => aufrufen('sicherung:pruefen', undefined), /* Nur die Kennung wandert hinüber, nie ein Pfad. */ sicherungEinspielen: (vorgang: string): Promise => aufrufen('sicherung:einspielen', { vorgang }), /* Der Renderer bekommt nur eine Rückruffunktion, nie das Ereignisobjekt – sonst reichte man ihm über `event.sender` doch wieder Electron-Interna. */ hilfsmittelBeobachten: (melden: (aktiv: boolean) => void): (() => void) => { const zuhoerer = (_ereignis: unknown, aktiv: unknown): void => { melden(aktiv === true); }; ipcRenderer.on(KANAL_HILFSMITTEL_GEAENDERT, zuhoerer); return () => { ipcRenderer.removeListener(KANAL_HILFSMITTEL_GEAENDERT, zuhoerer); }; }, /* Was nicht auf der Liste steht, kommt nicht durch: Die Prüfung im Preload ist dieselbe Verteidigungslinie wie die Kanal-Allowlist – der Renderer bekommt nur Befehle, die es wirklich gibt. */ menuebefehlBeobachten: (melden: (befehl: Menuebefehl) => void): (() => void) => { const zuhoerer = (_ereignis: unknown, befehl: unknown): void => { if (istMenuebefehl(befehl)) { melden(befehl); } }; ipcRenderer.on(KANAL_MENUE_BEFEHL, zuhoerer); return () => { ipcRenderer.removeListener(KANAL_MENUE_BEFEHL, zuhoerer); }; }, /* Nur eine Zahl wandert hinüber, und sie wird hier auf Brauchbarkeit geprüft: Eine negative oder unendliche Dauer verschöbe den Zeitursprung der Simulation ins Sinnlose. Der Hauptprozess prüft dasselbe – zwei Linien, wie bei den übrigen Kanälen. */ energiesparenBeobachten: (melden: (schlafMs: number) => void): (() => void) => { const zuhoerer = (_ereignis: unknown, schlafMs: unknown): void => { if (typeof schlafMs === 'number' && Number.isFinite(schlafMs) && schlafMs > 0) { melden(schlafMs); } }; ipcRenderer.on(KANAL_ENERGIESPAREN_ENDE, zuhoerer); return () => { ipcRenderer.removeListener(KANAL_ENERGIESPAREN_ENDE, zuhoerer); }; }, }); if (process.contextIsolated) { contextBridge.exposeInMainWorld(BRIDGE_NAME, bridge); } else { // Sollte nie eintreten – contextIsolation ist hart aktiviert. throw new Error('contextIsolation ist deaktiviert. Die Anwendung startet nicht ungesichert.'); }