/** * Typisierter IPC-Vertrag zwischen Main- und Renderer-Prozess. * * Der Renderer erhält NIEMALS direkten Zugriff auf `ipcRenderer`. Stattdessen * legt das Preload-Skript über `contextBridge` genau die hier beschriebene * API frei. Jeder Kanal ist über {@link IpcVertrag} typisiert – Anfrage- und * Antworttyp werden auf beiden Seiten aus derselben Quelle abgeleitet. */ import { ANZEIGEGROESSE_STANDARD } from './ansicht'; import type { HartnaeckigeFrage } from './hartnaeckig'; import { SPRECHTEMPO_STANDARD } from './sprechtempo'; import { TEXTABSTAND_STANDARD } from './textabstand'; import type { Erklaerungstiefe } from './druck/fehlerprotokoll'; import type { Schriftgroesse } from './druck/stil'; import type { Erklaerungen } from './erklaerungen'; import type { Glossar } from './glossar'; import type { Verlaufspunkt } from './reifeverlauf'; import type { Normtexte } from './normtexte'; import type { Themen } from './themen'; import type { Katalog } from './katalog'; import type { Lernplan } from './lernplan'; import type { Antwortprotokoll, FrageStand, Lernuebersicht, Profil, SitzungsFilter, SitzungsFrage, } from './lernstand'; import type { Datenschutzangaben } from './datenschutz'; import type { Lizenzangaben } from './lizenzen'; import type { Einspielergebnis, Pruefergebnis, Sicherungsergebnis, Uebernahmeergebnis, } from './sicherung'; import type { OffenerLauf, Pruefungsantwort, Pruefungsauftrag, Pruefungsbogen, Pruefungsergebnis, Pruefungsverlauf, Zwischenstand, } from './pruefung'; import type { ThemeAuswahl } from './theme'; /** * Ergebnis eines Exports. * * Ein Abbruch im Speicherdialog ist kein Fehler, sondern eine Entscheidung * des Nutzers – deshalb `gespeichert: false` statt einer Ausnahme. Nur was * wirklich schiefgeht (kein Schreibrecht, voller Datenträger), wirft. */ export interface Druckergebnis { readonly gespeichert: boolean; /** Wohin geschrieben wurde, oder `null` bei Abbruch. */ readonly pfad: string | null; /** Größe der geschriebenen Datei in Byte; 0 bei Abbruch. */ readonly bytes: number; /** * Kennung, unter der sich die Datei öffnen oder im Ordner zeigen lässt. * * **Eine Kennung und kein Pfad**: Wer einen Pfad mitbringen darf, darf auch * einen anderen mitbringen. Der Hauptprozess merkt sich, was er selbst * geschrieben hat, und öffnet ausschließlich das (`main/dateizugriff.ts`) – * dieselbe Überlegung wie beim Einspielweg der Sicherung. * * Fehlt bei Abbruch und in älteren Fassungen des Anwendungskerns; dann * entfallen die beiden Schaltflächen. */ readonly dateiKennung?: string; } /** Ergebnis des better-sqlite3-Rauchtests im Main-Prozess. */ export interface DatenbankStatus { /** `true`, wenn das native Modul geladen und eine Abfrage ausgeführt wurde. */ readonly verfuegbar: boolean; /** Von SQLite gemeldete Version, z. B. "3.50.2". */ readonly sqliteVersion: string | null; /** Für Menschen lesbare Statusmeldung (deutsch). */ readonly meldung: string; } /** * Befund des Katalogstand-Abgleichs beim Öffnen des Lernstands. * * Die Lernstand-Datenbank vermerkt seit Schemafassung 9, gegen welchen * amtlichen Katalogstand sie geführt wird. Weicht der geladene Katalog davon * ab – etwa nach einer neuen BVA-Fassung mit geänderter Nummerierung –, * entsteht dieser Befund, und zwar genau in der Sitzung, die den Wechsel * zuerst sieht: Danach ist der neue Stand vermerkt, und der nächste Start * findet Gleichstand vor. Gelöscht wird dabei nichts; die beiden Zählungen * beziffern nur, was im geladenen Katalog kein Ziel mehr hat. */ export interface Katalogwechsel { /** Katalogstand (ISO-Datum), unter dem der Lernstand bisher geführt wurde. */ readonly vorher: string; /** Stand des jetzt geladenen Katalogs. */ readonly nachher: string; /** `frage_stand`-Zeilen zu Frage-IDs, die es im geladenen Katalog nicht gibt. */ readonly verwaisteStaende: number; /** `antwort_log`-Zeilen zu Frage-IDs, die es im geladenen Katalog nicht gibt. */ readonly verwaisteAntworten: number; } /** Laufzeit-Informationen für die Info-Anzeige im Startbildschirm. */ export interface AnwendungsInfo { readonly anwendungsVersion: string; /** * Kurzes Commit-Kürzel dieses Baus, `+` bei ungesicherten Änderungen. * * Leer, wenn beim Bauen kein Git zur Verfügung stand. Die Versionsnummer * allein benennt einen Stand nicht eindeutig – zwischen zwei * Veröffentlichungen entstehen viele Bauten mit derselben Nummer. */ readonly baukennung: string; /** Commit-Datum als ISO-Datum, oder leer. */ readonly baustand: string; readonly electronVersion: string; readonly chromeVersion: string; readonly nodeVersion: string; readonly plattform: NodeJS.Platform; readonly datenbank: DatenbankStatus; /** * Befund eines Katalogwechsels, oder `null`, wenn nichts zu melden ist – * auch dann, wenn der Lernstand nicht zu öffnen war: Dieser Fehler hat * seine eigene Meldung an anderer Stelle. Optional geführt wie die übrigen * jüngeren Erweiterungen, damit die Attrappen der Oberflächentests mit dem * bisherigen Zuschnitt gültig bleiben. */ readonly katalogwechsel?: Katalogwechsel | null; /** * Ablageort des Fehlerprotokolls, oder `null`. * * Steht im Systemzustand, damit die Datei auffindbar ist, wenn jemand einen * Absturz melden will. Sie geht nie von selbst irgendwohin – der Satz * daneben sagt das ausdrücklich. * * Optional geführt wie die übrigen jüngeren Erweiterungen, damit die * Attrappen der Oberflächentests mit dem bisherigen Zuschnitt gültig * bleiben. */ readonly protokollPfad?: string | null; } /** Dauerhaft gespeicherte Nutzereinstellungen. */ export interface Einstellungen { readonly thema: ThemeAuswahl; /** Zuletzt benutztes Lernprofil. */ readonly profilId?: number; /** * Wie viele Fragen eine Lernsitzung umfasst. * * Fehlt der Wert, gilt {@link SITZUNGSUMFANG}. Bis 0.26.6 war die Zahl * eine Konstante im Quelltext: Wer täglich nur zehn Minuten hat, bekam * dieselben zwanzig Fragen vorgelegt wie jemand mit einer Stunde – und * ließ die Sitzung entweder halb liegen oder lernte sie unter Zeitdruck * zu Ende. Der Anwendungskern nimmt seit jeher jede Zahl von 1 bis 1000 * entgegen; es fehlte allein der Weg dorthin. */ readonly sitzungsumfang?: number; /** * Die Frage nach dem abwählbaren Kapitel wurde beim Erststart gestellt. * * Eine Einstellung und keine Spalte am Profil: Es ist eine Frage der * Bedienung, kein Lernstand. Sie steht deshalb auch nicht in der * Sicherung – wer auf einem neuen Rechner anfängt, bekommt sie noch * einmal, und das ist richtig so: Dort ist es wieder ein erster Start. */ readonly zuschnittGefragt?: boolean; /** * Antwortoptionen in zufälliger Reihenfolge zeigen. * * Vorgabe ist **aus**. Ein Lernvorteil des Mischens ist nicht belegt – es * existiert keine kontrollierte Studie, die eine gemischte gegen eine feste * Übungsreihenfolge stellt und danach das Behalten misst. Belegt ist nur, * dass Umsortieren die Itemschwierigkeit kaum verändert; damit trägt auch * die Gegenbegründung „macht schwerer, also lernwirksamer" nicht. Dem * unbelegten Nutzen steht ein messbarer Aufwand gegenüber: 83 % der * Auswahlfragen erscheinen dann in anderer Reihenfolge als im amtlichen * Katalog, in dem der Lernende nachschlägt. Die Begründung im Einzelnen * steht in `docs/entscheidung-antwortreihenfolge.md`. * * Wer die Funktion will, schaltet sie unter „Schwierigkeit" ein. */ readonly optionenMischen?: boolean; /** * Verschweigen, wie viele Antworten richtig sind. * * Vorgabe ist **aus**, also wie bisher: Die Lernsitzung verrät über Radios * gegenüber Kästchen und über den Hinweistext, ob eine oder mehrere * Antworten richtig sind. Eingeschaltet entfällt dieser Hinweis – so, wie * die Prüfungssimulation es ohnehin hält. * * Das ist der belegbarere der beiden Schwierigkeitsregler: Er entfernt * einen Hinweis, den die Prüfung nicht gibt, statt einen hinzuzufügen, * den sie auch nicht gibt. */ readonly antwortzahlVerbergen?: boolean; /** * Bietet in Lernsitzung und Prüfungslauf eine Schaltfläche zum Vorlesen an * (nutzt Systemstimmen). * * Gesprochen wird **nur auf Knopfdruck**. Bis 0.11.1 begann die Ansage der * Rückmeldung von selbst – was weder der Schalter noch das Handbuch zusagte. */ readonly vorlesen?: boolean; /** * In der Lernsitzung von selbst vorlesen, ohne Knopfdruck. * * Ab Werk **aus**, und nur wirksam, wenn {@link vorlesen} an ist. Bis 0.11.1 * gab es diese Wahl nicht: Die Rückmeldung wurde immer von selbst * angesagt, sobald die Sprachausgabe eingeschaltet war. Gemeldet von einem * Nutzer, und die eigenen Texte gaben ihm recht – Schalter wie Handbuch * sagten nur eine Schaltfläche zu. * * Wer es einschaltet, bekommt beides angesagt: die neue Frage, sobald sie * erscheint, und die Rückmeldung nach dem Bestätigen. Gedacht für Menschen * mit Lese-Rechtschreib-Störung oder ermüdeten Augen, denen ein Knopfdruck * je Frage im Weg steht. * * **Nicht** im Prüfungslauf: Dort bildet die Anwendung eine schriftliche * Prüfung nach, und eine Stimme, die von selbst losredet, gehört nicht dazu. * Die Schaltfläche gibt es dort weiterhin. */ readonly vorlesenAutomatisch?: boolean; /** * Bei falscher Antwort die ausführliche Begründung vorlesen. * * Ab Werk **aus**, unabhängig von {@link vorlesenAutomatisch} und nur * wirksam, wenn {@link vorlesen} an ist. * * Der ausführliche Text bleibt sonst überall außen vor – auch die * Schaltfläche liest ihn nicht: Er dauert gesprochen über eine Minute, und * die nächste Frage wartet. Genau deshalb ist er eine eigene Wahl und nicht * Teil der Ansage: Wer eine Frage falsch hatte, hat die Minute gerade übrig; * bei jeder richtigen Antwort wäre sie eine Zumutung. * * Unabhängig, weil die nützlichste Kombination die ist, die man sonst nicht * einstellen könnte: sonst selbst lesen, aber bei einem Fehler die * Begründung hören. */ readonly vorlesenErklaerungBeiFehler?: boolean; /** * Sprechgeschwindigkeit als `rate` der Sprachausgabe; 1 ist die * Systemvorgabe. * * Bis 0.22.0 wurde sie nie gesetzt, das Tempo war also nicht einstellbar. * Für Menschen mit Lese-Rechtschreib-Störung – die Zielgruppe, die der * Schalter „Vorlesen“ selbst benennt – ist ein festes Tempo bei * minutenlangen Rechtstexten eine echte Hürde. Stufen in * `renderer/src/lernen/sprachausgabe.ts`. */ readonly sprechtempo?: number; /** * Anzeigegröße der ganzen Anwendung in Prozent. * * Gültige Stufen stehen in `shared/ansicht.ts`. Gespeichert wird die * Prozentzahl, nicht Chromiums Zoomfaktor – sie ist die Zahl, die auch * angezeigt und angesagt wird. */ readonly anzeigegroesse?: number; /** * Zeilen-, Wort- und Zeichenabstand in Stufen (WCAG 1.4.12). * * Stufen und Werte stehen in `shared/textabstand.ts`. Getrennt von der * Anzeigegröße, weil es etwas anderes ist: Die Größe skaliert alles * gemeinsam, der Abstand gibt dem Text bei gleicher Größe mehr Luft. Wer * Legasthenie hat, braucht oft das Zweite und nicht das Erste. */ readonly textabstand?: string; /** * Zeichentasten-Kürzel in der Lernsitzung (WCAG 2.1.4). * * **Dreiwertig, und das mit Absicht.** Fehlt das Feld, hat niemand * entschieden – dann richtet sich die Voreinstellung danach, ob ein * Hilfsmittel läuft: Im Lesemodus von NVDA und JAWS sind die Ziffern für * die Navigation nach Überschriftenebenen belegt. `true` und `false` sind * ausdrückliche Entscheidungen und haben immer Vorrang, auch wenn später * ein Screenreader startet. * * Deshalb steht das Feld **nicht** in {@link EINSTELLUNGEN_STANDARD} und * wird von `einstellungenBereinigen` nicht aufgefüllt: Ein Rückfallwert * wäre hier eine erfundene Entscheidung und löschte den Unterschied * zwischen „aus, weil ein Hilfsmittel läuft“ und „aus, weil ich das so * will“ – genau den Unterschied, den der Hinweistext am Schalter erklärt. * * Bis 0.21.0 lag diese Wahl allein im `localStorage` des Renderers und * überlebte damit weder einen Gerätewechsel noch eine Sicherung. */ readonly tastenkuerzel?: boolean; /** * Zeitpunkt der letzten selbst angelegten Sicherung, als ISO-Zeit. * * Fehlt das Feld, ist noch nie eine angelegt worden – und genau das steht * dann auch auf dem Bildschirm. Ein Rückfall auf „heute“ wäre die * gefährlichste Lüge, die hier möglich ist. * * **Eine Einstellung und kein Lernstand**, obwohl es um den Lernstand * geht: Der Vermerk beschreibt, was auf *diesem* Rechner geschehen ist. * Wanderte er in der Sicherung mit, behauptete er auf dem neuen Rechner * eine Sicherung, die dort niemand angelegt hat. */ readonly letzteSicherung?: string; /** * Zuletzt benutzte Fenstergröße und -lage. * * Bis 0.22.0 startete das Fenster immer mit 1180 × 820 an der * Systemposition. Wer maximiert arbeitet oder es – wie der Kommentar in * `main/fenster.ts` selbst als Anwendungsfall nennt – schmal neben eine * Bildschirmlupe stellt, richtete das bei jedem Start neu ein. Das trifft * genau die Zielgruppe des Barrierefreiheitsanspruchs. * * Die Lage wird beim Start gegen die vorhandenen Bildschirme geprüft: Ein * Fenster, das auf einem abgesteckten zweiten Monitor lag, wäre sonst * unsichtbar und praktisch unerreichbar. */ readonly fenster?: { readonly breite: number; readonly hoehe: number; readonly x?: number; readonly y?: number; readonly maximiert?: boolean; }; } export const EINSTELLUNGEN_STANDARD: Einstellungen = Object.freeze({ thema: 'system', optionenMischen: false, antwortzahlVerbergen: false, vorlesen: false, zuschnittGefragt: false, vorlesenAutomatisch: false, vorlesenErklaerungBeiFehler: false, anzeigegroesse: ANZEIGEGROESSE_STANDARD, textabstand: TEXTABSTAND_STANDARD, sprechtempo: SPRECHTEMPO_STANDARD, }); /** * Die Einstellungen, die mit einer Sicherung mitreisen. * * **Warum überhaupt.** Bis 0.27.0 enthielt die Sicherung Profile, Antworten, * Merklisten, Termine und Verlauf – aber keine Einstellung. Wer 400 Prozent * Anzeigegröße oder hohen Kontrast braucht, musste auf dem zweiten Rechner * ohne sie anfangen, um sie einzustellen. Gerade das, was Barrierefreiheit * herstellt, blieb zurück. * * **Warum eine Auswahl und nicht alles.** Vier Felder dürfen nicht mitreisen, * und das ist keine Geschmacksfrage: * * - `profilId` zeigt auf eine Profilnummer des **alten** Rechners. Auf dem * neuen gehört sie einem anderen Profil oder gar keinem. * - `letzteSicherung` nennt einen Dateipfad, den es dort nicht gibt. * - `fenster` ist die Fenstergröße einer fremden Bildschirmauflösung. * - `zuschnittGefragt` merkt sich, dass die Erststart-Frage **auf diesem * Rechner** gestellt wurde. Mitgereist übersprünge sie jemand, der sie nie * gesehen hat. * * Der Rest reist mit. Er beschreibt, wie jemand lesen, hören und lernen * will – und das ändert sich nicht mit dem Gerät. */ export const EINSTELLUNGEN_REISEN = Object.freeze([ 'thema', 'anzeigegroesse', 'textabstand', 'sprechtempo', 'vorlesen', 'vorlesenAutomatisch', 'vorlesenErklaerungBeiFehler', 'tastenkuerzel', 'optionenMischen', 'antwortzahlVerbergen', 'sitzungsumfang', ] as const satisfies readonly (keyof Einstellungen)[]); /** Nur die mitreisenden Felder, als eigenes Stück. */ export type ReisendeEinstellungen = Partial< Pick >; /** * Die einzige Stelle, an der IPC-Kanäle definiert werden. * `anfrage` = Nutzlast vom Renderer, `antwort` = Rückgabe des Main-Prozesses. * * Kanäle ohne Nutzlast verwenden `undefined` (nicht `void`): der Wert wird * tatsächlich über die Bridge gereicht, ist also ein Wert und kein * Rückgabetyp. */ export interface IpcVertrag { 'anwendung:info': { anfrage: undefined; antwort: AnwendungsInfo }; 'einstellungen:lesen': { anfrage: undefined; antwort: Einstellungen }; /** * Ändert einzelne Einstellungen. Die Nutzlast ist eine Teilmenge – sie * wird über den gespeicherten Stand gelegt, nicht an seine Stelle * gesetzt. Antwort ist der vollständige neue Stand. */ 'einstellungen:schreiben': { anfrage: Partial; antwort: Einstellungen }; /** Liefert den vollständigen Fragenkatalog (einmalig beim Start). */ 'katalog:laden': { anfrage: undefined; antwort: Katalog }; /** Bild eines Prüfzeichens als Data-URL – die CSP verbietet Dateizugriffe. */ 'katalog:bild': { anfrage: { bildId: string }; antwort: string }; /** * Erklärungen zu den Fragen – eigener redaktioneller Inhalt. * * Wird wie der Katalog einmalig beim Start geladen. Der Bestand ist * kleiner als der Katalog und wächst mit ihm; ein Nachladen je Frage * wäre viel Verkehr für wenig Nutzen. */ 'erklaerungen:laden': { anfrage: undefined; antwort: Erklaerungen }; /** * Glossar der Fachbegriffe – erfüllt WCAG 3.1.3 und 3.1.4. * * Wie die Erklärungen einmalig geladen: Die Einträge ändern sich zur * Laufzeit nicht, und die Begriffssuche braucht ohnehin alle auf einmal. */ 'glossar:laden': { anfrage: undefined; antwort: Glossar }; /** * Die mitgelieferten Normtexte. * * Wie Erklärungen und Glossar einmalig geladen. Der Umfang ist bekannt und * fest: nur die Normen, die irgendwo zitiert werden – rund 500 KiB. Ein * Kanal je Fundstelle wäre bei 2123 Zitaten mehr Verkehr als die ganze * Datei und brächte dem Lesenden nichts, weil er ohnehin blättert. */ 'gesetz:normtexte': { anfrage: undefined; antwort: Normtexte }; /** * Die redaktionelle Feingliederung der Kapitel II bis IV. * * Wie Erklärungen und Glossar einmalig geladen: 29 Gruppen über 230 Fragen, * und sie ändern sich zur Laufzeit nicht. */ 'themen:laden': { anfrage: undefined; antwort: Themen }; 'profil:liste': { anfrage: undefined; antwort: Profil[] }; 'profil:anlegen': { anfrage: { name: string }; antwort: Profil }; 'profil:aktualisieren': { anfrage: { id: number; name?: string; pruefungstermin?: string | null; /** Kapitel, die dieses Profil dauerhaft nicht lernt – Kennungen wie „IV“. */ kapitelAusschluss?: readonly string[]; }; antwort: Profil; }; /** * Löscht ein Profil samt Lernstand, Antworten und Prüfungsverlauf. * * Antwort ist die verbleibende Liste – die Oberfläche muss danach ohnehin * ein anderes Profil wählen und braucht die Auswahl sofort. */ 'profil:loeschen': { anfrage: { id: number }; antwort: Profil[] }; /** Stellt die Fragen einer Lernsitzung nach Filter zusammen. */ 'lernen:sitzung': { anfrage: { profilId: number; filter: SitzungsFilter }; antwort: SitzungsFrage[]; }; /** Protokolliert eine Antwort und liefert den neuen Stand der Frage. */ 'lernen:antworten': { anfrage: { profilId: number; protokoll: Antwortprotokoll }; antwort: FrageStand; }; /** Merkliste umschalten. */ 'lernen:merken': { anfrage: { profilId: number; frageId: string; gemerkt: boolean }; antwort: FrageStand; }; /** * Der gespeicherte Stand einer einzelnen Frage; `null`, wenn sie noch nie * beantwortet wurde. * * **Wozu.** `FrageStand` transportierte `versuche`, `richtige`, * `zuletztBeantwortet` und `faelligAb` schon immer über die Brücke – die * Oberfläche benutzte davon bis 0.22.0 ausschließlich `gemerkt`. Der * Lernende konnte nirgends beantworten, wie oft er diese Frage schon hatte * und wie oft davon richtig; „Warum kommt die schon wieder?“ blieb ohne * Antwort, obwohl die Antwort in der Datenbank stand. * * Ein eigener Kanal und nicht die Rückgabe von `lernen:antworten`: Der * Steckbrief soll die Historie **vor** der heutigen Antwort zeigen, und bei * offenen Fragen wird die Buchung ohnehin bis zum Weiterblättern * zurückgehalten (`useSitzung`, `bewertungAendern`). */ 'lernen:fragestand': { anfrage: { profilId: number; frageId: string }; antwort: FrageStand; }; /** * Die hartnäckigen Fragen dieses Profils – wiederholt danebengegangen. * * Rein deskriptiv: gezählte Fehlschläge aus dem Protokoll, keine * Kennzahl. Einzelheiten in `shared/hartnaeckig.ts`. */ 'lernen:hartnaeckige': { anfrage: { profilId: number }; antwort: HartnaeckigeFrage[]; }; 'lernen:uebersicht': { anfrage: { profilId: number }; antwort: Lernuebersicht }; /** * Der Reifegrad der letzten Tage, aus dem Antwortprotokoll nachgerechnet. * * Eigener Kanal statt eines Feldes an `lernen:uebersicht`: Die Rechnung * läuft über das ganze Protokoll und wird nur dort gebraucht, wo der * Verlauf auch angezeigt wird. Die Übersicht holt die Oberfläche nach * **jeder** Antwort. */ 'lernen:verlauf': { anfrage: { profilId: number; tage?: number }; antwort: Verlaufspunkt[]; }; /** * Lernplan: Prognose, Tagespensum und Machbarkeit zum Prüfungstermin. * * Eigener Kanal statt einer Erweiterung von `lernen:uebersicht`: Der Plan * rechnet über den gesamten Katalog und wird nur dort gebraucht, wo er * auch angezeigt wird. */ 'lernen:plan': { anfrage: { profilId: number }; antwort: Lernplan }; /** Lernstand des Profils vollständig zurücksetzen (ohne Begrenzung). */ 'lernen:zuruecksetzen': { anfrage: { profilId: number; kapitel?: string | null }; antwort: Lernuebersicht; }; /** * Stellt einen Prüfungsbogen nach dem gewählten Profil zusammen. * * Die Antwort ist ein {@link Pruefungsbogen} und keine reine Fragenliste: * Weicht das Ziehen von den Vorgaben ab, gehört das an den Bildschirm und * nicht nur ins Protokoll des Hauptprozesses. Die Sätze in `warnungen` * kommen fertig formuliert von dort. */ 'pruefung:starten': { anfrage: { profilId: number; auftrag: Pruefungsauftrag }; antwort: Pruefungsbogen; }; /** Wertet einen Simulationslauf aus und speichert ihn im Verlauf. */ 'pruefung:auswerten': { anfrage: { profilId: number; auftrag: Pruefungsauftrag; antworten: Pruefungsantwort[]; dauerMs: number; zeitAbgelaufen: boolean; }; antwort: Pruefungsergebnis; }; 'pruefung:verlauf': { anfrage: { profilId: number }; antwort: Pruefungsverlauf[] }; /** * Sichert den Zwischenstand eines laufenden Bogens. * * Bewusst OHNE den Bogen selbst: Der Kern hat ihn beim Starten abgelegt. * Wer den Bogen mitbringen dürfte, könnte ihn auch erfinden – dann wäre * die Prüfung der eingereichten Antwortliste wertlos. Gesichert wird nur, * was dem Renderer gehört. * * Die Antwort sagt, ob es noch eine Zeile zu sichern gab. `false` heißt * nicht Fehler, sondern „dieser Lauf ist vorbei" – etwa weil die Abgabe * schneller war als ein entprellter Nachzügler. */ 'pruefung:sichern': { anfrage: { profilId: number; stand: Zwischenstand }; antwort: boolean; }; /** Der unterbrochene Lauf eines Profils, oder `null`. */ 'pruefung:offen': { anfrage: { profilId: number }; antwort: OffenerLauf | null }; /** Verwirft den unterbrochenen Lauf – nur auf ausdrücklichen Wunsch. */ 'pruefung:verwerfen': { anfrage: { profilId: number }; antwort: undefined }; /** * Meldet, ob gerade eine Prüfung bearbeitet wird. * * Der Hauptprozess fragt beim Schließen des Fensters nach, wenn eine läuft. * Bewusst ein Abgleich und keine Kantenmeldung: Der Renderer schickt seinen * Zustand auch unverändert, damit ein Merker kein Neuladen überlebt. */ 'pruefung:laeuft': { anfrage: { laeuft: boolean }; antwort: undefined }; /** * Erzeugt den Lernbericht als PDF und fragt, wohin er gespeichert werden soll. * * Der Anwendungskern holt die Zahlen selbst – Übersicht, Lernplan und * Prüfungsverlauf liegen dort ohnehin. Der Renderer schickt nur, für wen * und wie groß; so kann keine Oberfläche Zahlen in ein Dokument bringen, * die der Kern nicht bestätigt hat. */ 'druck:lernbericht': { anfrage: { profilId: number; schriftgroesse: Schriftgroesse }; antwort: Druckergebnis; }; /** * Setzt die Anzeigegröße und merkt sie sich. * * Der Zoom gehört in den Hauptprozess: Er wirkt auf das Fenster, nicht auf * das Dokument, und muss beim nächsten Start wieder anliegen, bevor der * erste Bildpunkt gezeichnet wird. */ 'ansicht:groesse': { anfrage: { prozent: number }; antwort: number }; /** * Das Fehlerprotokoll als PDF – die zuletzt falsch beantworteten Fragen. * * `tiefe` steuert, ob nur die Kurzerklärung oder die vollständige * Begründung mitgedruckt wird. Der Unterschied im Umfang ist erheblich — * die Zahlen dazu stehen in `shared/druck/umfang.ts` und sonst nirgends. */ 'druck:fehlerprotokoll': { anfrage: { profilId: number; schriftgroesse: Schriftgroesse; tiefe: Erklaerungstiefe }; antwort: Druckergebnis; }; /** * Die Fragenliste als PDF – ein Bogen zum Bearbeiten auf Papier. * * `bereiche` sind Kapitel- oder Abschnittskennungen; eine leere Liste * bedeutet den ganzen Katalog. **Ohne Lernprofil**: Die Liste hängt am * Katalog und nicht am Lernstand, es gibt also nichts zu personalisieren. */ 'druck:fragenliste': { anfrage: { bereiche: readonly string[]; schriftgroesse: Schriftgroesse; mitLoesungen: boolean; }; antwort: Druckergebnis; }; /** * Ein einzelnes Profil aus der geprüften Datei dazunehmen. * * `profilIndex` zeigt in `Pruefergebnis.ausDatei.jeProfil` – nicht auf eine * Profilnummer. Die Nummern der fremden Datei kennt nur der Anwendungskern, * und er behält sie für sich. */ 'sicherung:uebernehmen': { anfrage: { vorgang: string; profilIndex: number }; antwort: Uebernahmeergebnis; }; /** Lizenzangaben für den Bereich „Über diese Software“. */ 'lizenzen:lesen': { anfrage: undefined; antwort: Lizenzangaben }; /** * Die mitgelieferte Datenschutzerklärung. * * Derselbe Weg wie bei den Lizenztexten: Der Hauptprozess liest die Datei, * der Renderer bekommt fertige Blöcke. Kein Dateizugriff im Renderer. */ 'datenschutz:lesen': { anfrage: undefined; antwort: Datenschutzangaben }; /** * Meldet, ob gerade ein Hilfsmittel läuft (Screenreader, Bildschirmlupe …). * * Grundlage ist `app.accessibilitySupportEnabled` von Electron. Der Wert * steuert die Voreinstellung der Zeichenkürzel: Diese kollidieren im * Lesemodus von NVDA und JAWS mit der Schnellnavigation – dort sind die * Ziffern für Überschriftenebenen belegt. */ 'system:hilfsmittel': { anfrage: undefined; antwort: boolean }; /** * Legt Text in die Zwischenablage. * * Es gibt diesen Kanal, weil der Renderer es selbst **nicht darf**: * `sicherheit.ts` lehnt mit `setPermissionCheckHandler(() => false)` * sämtliche Berechtigungen ab, und Blink fragt für * `navigator.clipboard.writeText()` die Berechtigung * `clipboard-sanitized-write` ab. Nachgemessen im gebauten Fenster: * `isSecureContext` ist `true`, `navigator.clipboard.writeText` existiert – * und der Aufruf scheitert mit `NotAllowedError: Write permission denied`. * * Den Berechtigungswächter dafür zu öffnen wäre der schlechtere Handel: Das * gäbe die Zwischenablage jedem Renderer-Code frei statt einem Kanal mit * fester Nutzlast. */ 'system:kopieren': { anfrage: { text: string }; antwort: boolean }; /** * Öffnet die Unterstützungsseite im Standardbrowser des Systems. * * Der einzige Kanal dieser Anwendung, der nach außen führt – und bewusst * kein allgemeines „öffne diese Adresse“. Der Hauptprozess vergleicht die * Nutzlast mit `UNTERSTUETZUNG_URL` aus `shared/unterstuetzung.ts` und * öffnet ausschließlich diese eine Adresse; jede andere wird abgewiesen. * Wer eine Adresse mitbringen darf, darf sonst auch eine andere mitbringen * – dieselbe Überlegung wie bei `sicherung:einspielen`. * * Dass die Adresse trotzdem in der Nutzlast steht und nicht weggelassen * wird, ist Absicht: So lässt sich prüfen, dass die Oberfläche genau das * anfordert, was sie anzeigt. * * Antwort ist `false`, wenn keine Adresse eingetragen ist, die Nutzlast * nicht passt oder das Betriebssystem den Browser nicht öffnen konnte. Die * Oberfläche zeigt dann die Adresse zum Abschreiben – sie behauptet nicht, * es sei etwas geschehen. */ 'system:unterstuetzung': { anfrage: { url: string }; antwort: boolean }; /** * Öffnet eine soeben geschriebene Datei oder zeigt sie im Dateimanager. * * Die Nutzlast ist eine **Kennung**, nie ein Pfad: Wer einen Pfad * mitbringen darf, darf auch einen anderen mitbringen. Der Hauptprozess * merkt sich, was er in dieser Sitzung selbst geschrieben hat, und öffnet * ausschließlich das (`main/dateizugriff.ts`). * * Antwortet `false`, wenn die Kennung unbekannt ist oder das * Betriebssystem die Datei nicht öffnen konnte – etwa weil sie inzwischen * verschoben wurde. Die Oberfläche sagt dann, dass es nicht geklappt hat, * statt so zu tun, als sei etwas geschehen. */ 'system:datei-zeigen': { anfrage: { kennung: string; wunsch: 'oeffnen' | 'ordner' }; antwort: boolean; }; /** * Schreibt den ganzen Lernstand in eine Datei. * * Der Nutzer wählt den Ort in einem Systemdialog. Übertragen wird nichts – * die Datei geht dorthin, wohin er sie legt, und sonst nirgendwohin. */ 'sicherung:anlegen': { anfrage: undefined; antwort: Sicherungsergebnis }; /** * Öffnet den Ordner der selbsttätigen Sicherheitskopien. * * **Ohne Argument, mit Absicht.** Welcher Ordner das ist, entscheidet der * Hauptprozess; aus der Oberfläche kommt kein Pfad und keine Kennung. Ein * Kanal, der einen Pfad entgegennähme, wäre ein Weg, beliebige Ordner des * Rechners zu öffnen – und der Renderer hat in diesem Projekt keinen * Node-Zugriff, gerade damit es solche Wege nicht gibt. * * Antwortet `false`, wenn es den Ordner noch nicht gibt. Er entsteht mit * der ersten Kopie; bis dahin gibt es nichts zu zeigen, und die Oberfläche * sagt das, statt so zu tun, als sei etwas geschehen. */ 'sicherung:ordner-zeigen': { anfrage: undefined; antwort: boolean }; /** * Prüft eine gewählte Datei und beziffert sie, **ohne etwas zu verändern**. * * Getrennt vom Einspielen, weil zwischen „das steht darin“ und „ja, ersetze * meinen Lernstand“ eine Entscheidung des Nutzers liegt. Bis * {@link IpcVertrag['sicherung:einspielen']} gerufen wird, ist nichts * angefasst. */ 'sicherung:pruefen': { anfrage: undefined; antwort: Pruefergebnis }; /** * Ersetzt den Lernstand durch die zuvor geprüfte Datei. * * Die Nutzlast ist ausschliesslich die Vorgangskennung aus * `sicherung:pruefen`; einen Pfad nimmt dieser Kanal nicht entgegen. Wer * einen Pfad mitbringen darf, darf auch einen anderen mitbringen. */ 'sicherung:einspielen': { anfrage: { vorgang: string }; antwort: Einspielergebnis }; } /** * Kanal, über den der Main-Prozess von sich aus meldet, dass sich der * Hilfsmittel-Zustand geändert hat. Nötig, weil ein Screenreader auch * mitten in der Sitzung gestartet werden kann – gerade dann, wenn jemand * merkt, dass er ihn braucht. */ export const KANAL_HILFSMITTEL_GEAENDERT = 'system:hilfsmittel-geaendert' as const; /** Kanal, über den der Hauptprozess eine geänderte Anzeigegröße meldet. */ export const KANAL_ANZEIGEGROESSE_GEAENDERT = 'ansicht:groesse-geaendert' as const; /** * Befehle, die aus der Menüleiste kommen und in der Oberfläche wirken. * * Die Menüleiste liegt im Hauptprozess, die Ansicht im Renderer – ohne einen * solchen Kanal wäre ein Menüeintrag, der etwas anzeigt, gar nicht baubar. * Absichtlich eine geschlossene Liste und keine freie Zeichenkette: Was hier * nicht steht, kommt am anderen Ende nicht an. */ export const MENUEBEFEHLE = [ 'hilfe', /* Die Ziele des Menüs „Gehe zu“, seit Fassung 0.26.0. Bis dahin führte das Anwendungsmenü zu keiner einzigen der zehn Ansichten. Es ist die einzige Fläche des Fensters, die nicht wegrollt — und der Startbildschirm ist bei 1265 Bildpunkten Breite 8530 hoch. Wer daran vorbeigerollt war, hatte keinen Weg mehr in eine andere Ansicht als zurück an den Anfang. Die Namen sind die Ansichtsarten aus `renderer/src/lernen/typen.ts`, mit „gehe-zu-“ davor: Ein Befehl ist keine Ansicht, und beide Listen sollen sich unabhängig ändern dürfen. */ 'gehe-zu-start', 'gehe-zu-kapitelwahl', 'gehe-zu-pruefungswahl', 'gehe-zu-suche', 'gehe-zu-glossar', 'gehe-zu-gesetze', 'gehe-zu-ueber', ] as const; export type Menuebefehl = (typeof MENUEBEFEHLE)[number]; export function istMenuebefehl(wert: unknown): wert is Menuebefehl { return typeof wert === 'string' && (MENUEBEFEHLE as readonly string[]).includes(wert); } /** Kanal, über den der Hauptprozess einen Menübefehl an die Ansicht meldet. */ export const KANAL_MENUE_BEFEHL = 'menue:befehl' as const; /** * Kanal, über den der Hauptprozess meldet, wie lange der Rechner geschlafen * hat. Die Nutzlast ist die Schlafdauer in Millisekunden. * * **Warum das nicht der Renderer selbst messen kann.** Naheliegend wäre, im * Sekundentakt zu prüfen, ob zwischen zwei Takten mehr als eine Sekunde * vergangen ist. Chromium drosselt Zeitgeber in verdeckten Fenstern aber auf * einen Takt pro Minute – ein bloß **minimiertes** Fenster sähe damit aus wie * ein schlafender Rechner. Die Prüfungsuhr ließe sich durch Minimieren * anhalten, und das wäre ein Schummelweg statt eines Nachteilsausgleichs. * `powerMonitor` meldet ausschließlich echten Energiesparmodus und ist von * der Drosselung unberührt. */ export const KANAL_ENERGIESPAREN_ENDE = 'system:energiesparen-ende' as const; export type IpcKanal = keyof IpcVertrag; export type IpcAnfrage = IpcVertrag[K]['anfrage']; export type IpcAntwort = IpcVertrag[K]['antwort']; /** * Allowlist aller erlaubten Kanäle. Main und Preload prüfen dagegen, damit * keine unbeabsichtigten Kanäle über die Bridge erreichbar werden. */ export const IPC_KANAELE = [ 'anwendung:info', 'einstellungen:lesen', 'einstellungen:schreiben', 'katalog:laden', 'katalog:bild', 'erklaerungen:laden', 'glossar:laden', 'gesetz:normtexte', 'themen:laden', 'profil:liste', 'profil:anlegen', 'profil:aktualisieren', 'profil:loeschen', 'lernen:sitzung', 'lernen:antworten', 'lernen:merken', 'lernen:fragestand', 'lernen:hartnaeckige', 'lernen:uebersicht', 'lernen:verlauf', 'lernen:plan', 'lernen:zuruecksetzen', 'pruefung:starten', 'pruefung:auswerten', 'pruefung:verlauf', 'pruefung:sichern', 'pruefung:offen', 'pruefung:verwerfen', 'pruefung:laeuft', 'druck:lernbericht', 'druck:fehlerprotokoll', 'druck:fragenliste', 'ansicht:groesse', 'lizenzen:lesen', 'datenschutz:lesen', 'system:hilfsmittel', 'system:kopieren', 'system:unterstuetzung', 'system:datei-zeigen', 'sicherung:ordner-zeigen', 'sicherung:anlegen', 'sicherung:pruefen', 'sicherung:einspielen', 'sicherung:uebernehmen', ] as const satisfies readonly IpcKanal[]; export function istIpcKanal(wert: unknown): wert is IpcKanal { return typeof wert === 'string' && (IPC_KANAELE as readonly string[]).includes(wert); } /** Name des Objekts, das im Renderer unter `window` liegt. */ export const BRIDGE_NAME = 'lernApp' as const; /** Die im Renderer sichtbare, vollständig typisierte API. */ export interface LernAppBridge { readonly anwendungsInfoLesen: () => Promise; readonly einstellungenLesen: () => Promise; readonly einstellungenSchreiben: (aenderung: Partial) => Promise; readonly katalogLaden: () => Promise; readonly katalogBild: (bildId: string) => Promise; readonly erklaerungenLaden: () => Promise; readonly glossarLaden: () => Promise; readonly normtexteLaden: () => Promise; readonly themenLaden: () => Promise; readonly profilListe: () => Promise; readonly profilAnlegen: (name: string) => Promise; readonly profilAktualisieren: ( id: number, aenderung: { name?: string; pruefungstermin?: string | null; /** Kapitel, die dieses Profil dauerhaft nicht lernt. */ kapitelAusschluss?: readonly string[]; }, ) => Promise; /** Löscht ein Profil; liefert die verbleibenden zurück. */ readonly profilLoeschen: (id: number) => Promise; readonly lernSitzung: (profilId: number, filter: SitzungsFilter) => Promise; readonly lernAntworten: (profilId: number, protokoll: Antwortprotokoll) => Promise; readonly lernMerken: (profilId: number, frageId: string, gemerkt: boolean) => Promise; /** * Der gespeicherte Stand einer einzelnen Frage – für den Steckbrief. * * Optional geführt wie die übrigen jüngeren Kanäle: Fehlt er, entfällt der * Steckbrief, und die Sitzung läuft wie bisher. */ readonly lernFragestand?: (profilId: number, frageId: string) => Promise; /** Die hartnäckigen Fragen – wiederholt danebengegangen. */ readonly lernHartnaeckige?: (profilId: number) => Promise; readonly lernUebersicht: (profilId: number) => Promise; /** Der nachgerechnete Reifegrad-Verlauf; wahlfrei, weil erst ab 0.27.0. */ readonly lernVerlauf?: (profilId: number, tage?: number) => Promise; readonly lernPlan: (profilId: number) => Promise; readonly lernZuruecksetzen: ( profilId: number, kapitel?: string | null, ) => Promise; /** Liefert den Bogen samt der Abweichungen, die beim Ziehen nötig waren. */ readonly pruefungStarten: ( profilId: number, auftrag: Pruefungsauftrag, ) => Promise; readonly pruefungAuswerten: ( profilId: number, auftrag: Pruefungsauftrag, antworten: Pruefungsantwort[], dauerMs: number, zeitAbgelaufen: boolean, ) => Promise; readonly pruefungVerlauf: (profilId: number) => Promise; /** Sichert den Zwischenstand; `false` heißt „dieser Lauf ist vorbei". */ readonly pruefungSichern: (profilId: number, stand: Zwischenstand) => Promise; /** Der unterbrochene Lauf eines Profils, oder `null`. */ readonly pruefungOffen: (profilId: number) => Promise; readonly pruefungVerwerfen: (profilId: number) => Promise; /** Meldet dem Hauptprozess, ob gerade geprüft wird. */ readonly pruefungLaeuft: (laeuft: boolean) => Promise; /** * Setzt die Anzeigegröße in Prozent; liefert die tatsächlich gesetzte * Stufe zurück (der Kern begrenzt auf gültige Werte). */ readonly anzeigegroesseSetzen: (prozent: number) => Promise; /** * Meldet, wenn die Anzeigegröße anderswo geändert wurde – über das Menü * oder Strg+Plus. Ohne diese Meldung zeigte die Einstellung eine Zahl an, * die nicht mehr stimmt. */ readonly anzeigegroesseBeobachten: (melden: (prozent: number) => void) => () => void; /** * Nimmt ein Profil aus der geprüften Datei dazu, ohne etwas zu ersetzen. * * `profilIndex` zeigt in die Liste, die `sicherungPruefen` geliefert hat. */ readonly sicherungUebernehmen: ( vorgang: string, profilIndex: number, ) => Promise; /** Erzeugt das Fehlerprotokoll als PDF und fragt nach dem Speicherort. */ readonly fehlerprotokollDrucken: ( profilId: number, schriftgroesse: Schriftgroesse, tiefe: Erklaerungstiefe, ) => Promise; /** Erzeugt die Fragenliste als PDF und fragt nach dem Speicherort. */ readonly fragenlisteDrucken: ( bereiche: readonly string[], schriftgroesse: Schriftgroesse, mitLoesungen: boolean, ) => Promise; /** Erzeugt den Lernbericht als PDF und fragt nach dem Speicherort. */ readonly lernberichtDrucken: ( profilId: number, schriftgroesse: Schriftgroesse, ) => Promise; readonly lizenzenLesen: () => Promise; readonly datenschutzLesen: () => Promise; readonly hilfsmittelAktiv: () => Promise; /** * Legt Text in die Zwischenablage. Siehe Kanal `system:kopieren`. * * Optional geführt wie die übrigen jüngeren Kanäle: Die Oberfläche muss * ohne ihn auskommen, und die Attrappen der Oberflächentests tragen das * volle Interface. Fehlt er, entfällt der Kopierknopf – der Meldetext bleibt * sichtbar und markierbar. */ readonly zwischenablageSchreiben?: (text: string) => Promise; /** * Öffnet die Unterstützungsseite im Standardbrowser. Siehe Kanal * `system:unterstuetzung`. * * Optional geführt wie die übrigen jüngeren Kanäle. Fehlt er, lässt die * Ansicht „Über diese Software“ das Angebot **vollständig** weg – ein Knopf, * der nichts öffnen kann, wäre eine Zusage ohne Deckung. */ readonly unterstuetzungOeffnen?: (url: string) => Promise; /** * Öffnet eine soeben geschriebene Datei oder zeigt sie im Ordner. * * Optional geführt wie die übrigen jüngeren Kanäle: Fehlt er, entfallen * die beiden Schaltflächen nach einem Export – gespeichert ist die Datei * dann trotzdem, und ihr Pfad steht auf dem Bildschirm. */ readonly dateiZeigen?: (kennung: string, wunsch: 'oeffnen' | 'ordner') => Promise; /** * Öffnet den Ordner der selbsttätigen Sicherheitskopien. * * Ohne Argument: Welcher Ordner das ist, weiß allein der Hauptprozess. */ readonly sicherungsordnerZeigen?: () => Promise; /* Optional geführt wie die übrigen jüngeren Kanäle: Die Oberfläche muss ohne sie auskommen, und die Attrappen der Tests tragen das volle Interface. Fehlen sie, entfällt die Karte zur Sicherung. */ readonly sicherungAnlegen?: () => Promise; readonly sicherungPruefen?: () => Promise; readonly sicherungEinspielen?: (vorgang: string) => Promise; /** * Meldet Änderungen des Hilfsmittel-Zustands. Liefert eine Funktion zum * Abmelden zurück, damit React beim Abbau aufräumen kann. */ readonly hilfsmittelBeobachten: (melden: (aktiv: boolean) => void) => () => void; /** * Meldet Befehle aus der Menüleiste. Liefert eine Funktion zum Abmelden * zurück, damit React beim Abbau aufräumen kann. */ readonly menuebefehlBeobachten: (melden: (befehl: Menuebefehl) => void) => () => void; /** * Meldet nach dem Aufwachen, wie lange der Rechner geschlafen hat. * * Optional geführt wie die übrigen jüngeren Kanäle: Fehlt er, läuft die * Simulation wie bisher – nur ohne Gutschrift. */ readonly energiesparenBeobachten?: (melden: (schlafMs: number) => void) => () => void; }