waffensachkunde

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

/ app src shared ipc.ts

39,0 KB Rohdatei
app/src/shared/ipc.ts — 984 Zeilen
1 /**
2 * Typisierter IPC-Vertrag zwischen Main- und Renderer-Prozess.
3 *
4 * Der Renderer erhält NIEMALS direkten Zugriff auf `ipcRenderer`. Stattdessen
5 * legt das Preload-Skript über `contextBridge` genau die hier beschriebene
6 * API frei. Jeder Kanal ist über {@link IpcVertrag} typisiert – Anfrage- und
7 * Antworttyp werden auf beiden Seiten aus derselben Quelle abgeleitet.
8 */
9
10 import { ANZEIGEGROESSE_STANDARD } from './ansicht';
11 import type { HartnaeckigeFrage } from './hartnaeckig';
12 import { SPRECHTEMPO_STANDARD } from './sprechtempo';
13 import { TEXTABSTAND_STANDARD } from './textabstand';
14 import type { Erklaerungstiefe } from './druck/fehlerprotokoll';
15 import type { Schriftgroesse } from './druck/stil';
16 import type { Erklaerungen } from './erklaerungen';
17 import type { Glossar } from './glossar';
18 import type { Normtexte } from './normtexte';
19 import type { Themen } from './themen';
20 import type { Katalog } from './katalog';
21 import type { Lernplan } from './lernplan';
22 import type {
23 Antwortprotokoll,
24 FrageStand,
25 Lernuebersicht,
26 Profil,
27 SitzungsFilter,
28 SitzungsFrage,
29 } from './lernstand';
30 import type { Datenschutzangaben } from './datenschutz';
31 import type { Lizenzangaben } from './lizenzen';
32 import type {
33 Einspielergebnis,
34 Pruefergebnis,
35 Sicherungsergebnis,
36 Uebernahmeergebnis,
37 } from './sicherung';
38 import type {
39 OffenerLauf,
40 Pruefungsantwort,
41 Pruefungsauftrag,
42 Pruefungsbogen,
43 Pruefungsergebnis,
44 Pruefungsverlauf,
45 Zwischenstand,
46 } from './pruefung';
47 import type { ThemeAuswahl } from './theme';
48
49 /**
50 * Ergebnis eines Exports.
51 *
52 * Ein Abbruch im Speicherdialog ist kein Fehler, sondern eine Entscheidung
53 * des Nutzers – deshalb `gespeichert: false` statt einer Ausnahme. Nur was
54 * wirklich schiefgeht (kein Schreibrecht, voller Datenträger), wirft.
55 */
56 export interface Druckergebnis {
57 readonly gespeichert: boolean;
58 /** Wohin geschrieben wurde, oder `null` bei Abbruch. */
59 readonly pfad: string | null;
60 /** Größe der geschriebenen Datei in Byte; 0 bei Abbruch. */
61 readonly bytes: number;
62 /**
63 * Kennung, unter der sich die Datei öffnen oder im Ordner zeigen lässt.
64 *
65 * **Eine Kennung und kein Pfad**: Wer einen Pfad mitbringen darf, darf auch
66 * einen anderen mitbringen. Der Hauptprozess merkt sich, was er selbst
67 * geschrieben hat, und öffnet ausschließlich das (`main/dateizugriff.ts`) –
68 * dieselbe Überlegung wie beim Einspielweg der Sicherung.
69 *
70 * Fehlt bei Abbruch und in älteren Fassungen des Anwendungskerns; dann
71 * entfallen die beiden Schaltflächen.
72 */
73 readonly dateiKennung?: string;
74 }
75
76 /** Ergebnis des better-sqlite3-Rauchtests im Main-Prozess. */
77 export interface DatenbankStatus {
78 /** `true`, wenn das native Modul geladen und eine Abfrage ausgeführt wurde. */
79 readonly verfuegbar: boolean;
80 /** Von SQLite gemeldete Version, z. B. "3.50.2". */
81 readonly sqliteVersion: string | null;
82 /** Für Menschen lesbare Statusmeldung (deutsch). */
83 readonly meldung: string;
84 }
85
86 /**
87 * Befund des Katalogstand-Abgleichs beim Öffnen des Lernstands.
88 *
89 * Die Lernstand-Datenbank vermerkt seit Schemafassung 9, gegen welchen
90 * amtlichen Katalogstand sie geführt wird. Weicht der geladene Katalog davon
91 * ab – etwa nach einer neuen BVA-Fassung mit geänderter Nummerierung –,
92 * entsteht dieser Befund, und zwar genau in der Sitzung, die den Wechsel
93 * zuerst sieht: Danach ist der neue Stand vermerkt, und der nächste Start
94 * findet Gleichstand vor. Gelöscht wird dabei nichts; die beiden Zählungen
95 * beziffern nur, was im geladenen Katalog kein Ziel mehr hat.
96 */
97 export interface Katalogwechsel {
98 /** Katalogstand (ISO-Datum), unter dem der Lernstand bisher geführt wurde. */
99 readonly vorher: string;
100 /** Stand des jetzt geladenen Katalogs. */
101 readonly nachher: string;
102 /** `frage_stand`-Zeilen zu Frage-IDs, die es im geladenen Katalog nicht gibt. */
103 readonly verwaisteStaende: number;
104 /** `antwort_log`-Zeilen zu Frage-IDs, die es im geladenen Katalog nicht gibt. */
105 readonly verwaisteAntworten: number;
106 }
107
108 /** Laufzeit-Informationen für die Info-Anzeige im Startbildschirm. */
109 export interface AnwendungsInfo {
110 readonly anwendungsVersion: string;
111 /**
112 * Kurzes Commit-Kürzel dieses Baus, `+` bei ungesicherten Änderungen.
113 *
114 * Leer, wenn beim Bauen kein Git zur Verfügung stand. Die Versionsnummer
115 * allein benennt einen Stand nicht eindeutig – zwischen zwei
116 * Veröffentlichungen entstehen viele Bauten mit derselben Nummer.
117 */
118 readonly baukennung: string;
119 /** Commit-Datum als ISO-Datum, oder leer. */
120 readonly baustand: string;
121 readonly electronVersion: string;
122 readonly chromeVersion: string;
123 readonly nodeVersion: string;
124 readonly plattform: NodeJS.Platform;
125 readonly datenbank: DatenbankStatus;
126 /**
127 * Befund eines Katalogwechsels, oder `null`, wenn nichts zu melden ist –
128 * auch dann, wenn der Lernstand nicht zu öffnen war: Dieser Fehler hat
129 * seine eigene Meldung an anderer Stelle. Optional geführt wie die übrigen
130 * jüngeren Erweiterungen, damit die Attrappen der Oberflächentests mit dem
131 * bisherigen Zuschnitt gültig bleiben.
132 */
133 readonly katalogwechsel?: Katalogwechsel | null;
134 /**
135 * Ablageort des Fehlerprotokolls, oder `null`.
136 *
137 * Steht im Systemzustand, damit die Datei auffindbar ist, wenn jemand einen
138 * Absturz melden will. Sie geht nie von selbst irgendwohin – der Satz
139 * daneben sagt das ausdrücklich.
140 *
141 * Optional geführt wie die übrigen jüngeren Erweiterungen, damit die
142 * Attrappen der Oberflächentests mit dem bisherigen Zuschnitt gültig
143 * bleiben.
144 */
145 readonly protokollPfad?: string | null;
146 }
147
148 /** Dauerhaft gespeicherte Nutzereinstellungen. */
149 export interface Einstellungen {
150 readonly thema: ThemeAuswahl;
151 /** Zuletzt benutztes Lernprofil. */
152 readonly profilId?: number;
153 /**
154 * Antwortoptionen in zufälliger Reihenfolge zeigen.
155 *
156 * Vorgabe ist **aus**. Ein Lernvorteil des Mischens ist nicht belegt – es
157 * existiert keine kontrollierte Studie, die eine gemischte gegen eine feste
158 * Übungsreihenfolge stellt und danach das Behalten misst. Belegt ist nur,
159 * dass Umsortieren die Itemschwierigkeit kaum verändert; damit trägt auch
160 * die Gegenbegründung „macht schwerer, also lernwirksamer" nicht. Dem
161 * unbelegten Nutzen steht ein messbarer Aufwand gegenüber: 83 % der
162 * Auswahlfragen erscheinen dann in anderer Reihenfolge als im amtlichen
163 * Katalog, in dem der Lernende nachschlägt. Die Begründung im Einzelnen
164 * steht in `docs/entscheidung-antwortreihenfolge.md`.
165 *
166 * Wer die Funktion will, schaltet sie unter „Schwierigkeit" ein.
167 */
168 /**
169 * Die Frage nach dem abwählbaren Kapitel wurde beim Erststart gestellt.
170 *
171 * Eine Einstellung und keine Spalte am Profil: Es ist eine Frage der
172 * Bedienung, kein Lernstand. Sie steht deshalb auch nicht in der
173 * Sicherung – wer auf einem neuen Rechner anfängt, bekommt sie noch
174 * einmal, und das ist richtig so: Dort ist es wieder ein erster Start.
175 */
176 readonly zuschnittGefragt?: boolean;
177 readonly optionenMischen?: boolean;
178 /**
179 * Verschweigen, wie viele Antworten richtig sind.
180 *
181 * Vorgabe ist **aus**, also wie bisher: Die Lernsitzung verrät über Radios
182 * gegenüber Kästchen und über den Hinweistext, ob eine oder mehrere
183 * Antworten richtig sind. Eingeschaltet entfällt dieser Hinweis – so, wie
184 * die Prüfungssimulation es ohnehin hält.
185 *
186 * Das ist der belegbarere der beiden Schwierigkeitsregler: Er entfernt
187 * einen Hinweis, den die Prüfung nicht gibt, statt einen hinzuzufügen,
188 * den sie auch nicht gibt.
189 */
190 readonly antwortzahlVerbergen?: boolean;
191 /**
192 * Bietet in Lernsitzung und Prüfungslauf eine Schaltfläche zum Vorlesen an
193 * (nutzt Systemstimmen).
194 *
195 * Gesprochen wird **nur auf Knopfdruck**. Bis 0.11.1 begann die Ansage der
196 * Rückmeldung von selbst – was weder der Schalter noch das Handbuch zusagte.
197 */
198 readonly vorlesen?: boolean;
199 /**
200 * In der Lernsitzung von selbst vorlesen, ohne Knopfdruck.
201 *
202 * Ab Werk **aus**, und nur wirksam, wenn {@link vorlesen} an ist. Bis 0.11.1
203 * gab es diese Wahl nicht: Die Rückmeldung wurde immer von selbst
204 * angesagt, sobald die Sprachausgabe eingeschaltet war. Gemeldet von einem
205 * Nutzer, und die eigenen Texte gaben ihm recht – Schalter wie Handbuch
206 * sagten nur eine Schaltfläche zu.
207 *
208 * Wer es einschaltet, bekommt beides angesagt: die neue Frage, sobald sie
209 * erscheint, und die Rückmeldung nach dem Bestätigen. Gedacht für Menschen
210 * mit Lese-Rechtschreib-Störung oder ermüdeten Augen, denen ein Knopfdruck
211 * je Frage im Weg steht.
212 *
213 * **Nicht** im Prüfungslauf: Dort bildet die Anwendung eine schriftliche
214 * Prüfung nach, und eine Stimme, die von selbst losredet, gehört nicht dazu.
215 * Die Schaltfläche gibt es dort weiterhin.
216 */
217 readonly vorlesenAutomatisch?: boolean;
218 /**
219 * Bei falscher Antwort die ausführliche Begründung vorlesen.
220 *
221 * Ab Werk **aus**, unabhängig von {@link vorlesenAutomatisch} und nur
222 * wirksam, wenn {@link vorlesen} an ist.
223 *
224 * Der ausführliche Text bleibt sonst überall außen vor – auch die
225 * Schaltfläche liest ihn nicht: Er dauert gesprochen über eine Minute, und
226 * die nächste Frage wartet. Genau deshalb ist er eine eigene Wahl und nicht
227 * Teil der Ansage: Wer eine Frage falsch hatte, hat die Minute gerade übrig;
228 * bei jeder richtigen Antwort wäre sie eine Zumutung.
229 *
230 * Unabhängig, weil die nützlichste Kombination die ist, die man sonst nicht
231 * einstellen könnte: sonst selbst lesen, aber bei einem Fehler die
232 * Begründung hören.
233 */
234 readonly vorlesenErklaerungBeiFehler?: boolean;
235 /**
236 * Sprechgeschwindigkeit als `rate` der Sprachausgabe; 1 ist die
237 * Systemvorgabe.
238 *
239 * Bis 0.22.0 wurde sie nie gesetzt, das Tempo war also nicht einstellbar.
240 * Für Menschen mit Lese-Rechtschreib-Störung – die Zielgruppe, die der
241 * Schalter „Vorlesen“ selbst benennt – ist ein festes Tempo bei
242 * minutenlangen Rechtstexten eine echte Hürde. Stufen in
243 * `renderer/src/lernen/sprachausgabe.ts`.
244 */
245 readonly sprechtempo?: number;
246 /**
247 * Anzeigegröße der ganzen Anwendung in Prozent.
248 *
249 * Gültige Stufen stehen in `shared/ansicht.ts`. Gespeichert wird die
250 * Prozentzahl, nicht Chromiums Zoomfaktor – sie ist die Zahl, die auch
251 * angezeigt und angesagt wird.
252 */
253 readonly anzeigegroesse?: number;
254 /**
255 * Zeilen-, Wort- und Zeichenabstand in Stufen (WCAG 1.4.12).
256 *
257 * Stufen und Werte stehen in `shared/textabstand.ts`. Getrennt von der
258 * Anzeigegröße, weil es etwas anderes ist: Die Größe skaliert alles
259 * gemeinsam, der Abstand gibt dem Text bei gleicher Größe mehr Luft. Wer
260 * Legasthenie hat, braucht oft das Zweite und nicht das Erste.
261 */
262 readonly textabstand?: string;
263 /**
264 * Zeichentasten-Kürzel in der Lernsitzung (WCAG 2.1.4).
265 *
266 * **Dreiwertig, und das mit Absicht.** Fehlt das Feld, hat niemand
267 * entschieden – dann richtet sich die Voreinstellung danach, ob ein
268 * Hilfsmittel läuft: Im Lesemodus von NVDA und JAWS sind die Ziffern für
269 * die Navigation nach Überschriftenebenen belegt. `true` und `false` sind
270 * ausdrückliche Entscheidungen und haben immer Vorrang, auch wenn später
271 * ein Screenreader startet.
272 *
273 * Deshalb steht das Feld **nicht** in {@link EINSTELLUNGEN_STANDARD} und
274 * wird von `einstellungenBereinigen` nicht aufgefüllt: Ein Rückfallwert
275 * wäre hier eine erfundene Entscheidung und löschte den Unterschied
276 * zwischen „aus, weil ein Hilfsmittel läuft“ und „aus, weil ich das so
277 * will“ – genau den Unterschied, den der Hinweistext am Schalter erklärt.
278 *
279 * Bis 0.21.0 lag diese Wahl allein im `localStorage` des Renderers und
280 * überlebte damit weder einen Gerätewechsel noch eine Sicherung.
281 */
282 readonly tastenkuerzel?: boolean;
283 /**
284 * Zeitpunkt der letzten selbst angelegten Sicherung, als ISO-Zeit.
285 *
286 * Fehlt das Feld, ist noch nie eine angelegt worden – und genau das steht
287 * dann auch auf dem Bildschirm. Ein Rückfall auf „heute“ wäre die
288 * gefährlichste Lüge, die hier möglich ist.
289 *
290 * **Eine Einstellung und kein Lernstand**, obwohl es um den Lernstand
291 * geht: Der Vermerk beschreibt, was auf *diesem* Rechner geschehen ist.
292 * Wanderte er in der Sicherung mit, behauptete er auf dem neuen Rechner
293 * eine Sicherung, die dort niemand angelegt hat.
294 */
295 readonly letzteSicherung?: string;
296 /**
297 * Zuletzt benutzte Fenstergröße und -lage.
298 *
299 * Bis 0.22.0 startete das Fenster immer mit 1180 × 820 an der
300 * Systemposition. Wer maximiert arbeitet oder es – wie der Kommentar in
301 * `main/fenster.ts` selbst als Anwendungsfall nennt – schmal neben eine
302 * Bildschirmlupe stellt, richtete das bei jedem Start neu ein. Das trifft
303 * genau die Zielgruppe des Barrierefreiheitsanspruchs.
304 *
305 * Die Lage wird beim Start gegen die vorhandenen Bildschirme geprüft: Ein
306 * Fenster, das auf einem abgesteckten zweiten Monitor lag, wäre sonst
307 * unsichtbar und praktisch unerreichbar.
308 */
309 readonly fenster?: {
310 readonly breite: number;
311 readonly hoehe: number;
312 readonly x?: number;
313 readonly y?: number;
314 readonly maximiert?: boolean;
315 };
316 }
317
318 export const EINSTELLUNGEN_STANDARD: Einstellungen = Object.freeze({
319 thema: 'system',
320 optionenMischen: false,
321 antwortzahlVerbergen: false,
322 vorlesen: false,
323 zuschnittGefragt: false,
324 vorlesenAutomatisch: false,
325 vorlesenErklaerungBeiFehler: false,
326 anzeigegroesse: ANZEIGEGROESSE_STANDARD,
327 textabstand: TEXTABSTAND_STANDARD,
328 sprechtempo: SPRECHTEMPO_STANDARD,
329 });
330
331 /**
332 * Die einzige Stelle, an der IPC-Kanäle definiert werden.
333 * `anfrage` = Nutzlast vom Renderer, `antwort` = Rückgabe des Main-Prozesses.
334 *
335 * Kanäle ohne Nutzlast verwenden `undefined` (nicht `void`): der Wert wird
336 * tatsächlich über die Bridge gereicht, ist also ein Wert und kein
337 * Rückgabetyp.
338 */
339 export interface IpcVertrag {
340 'anwendung:info': { anfrage: undefined; antwort: AnwendungsInfo };
341 'einstellungen:lesen': { anfrage: undefined; antwort: Einstellungen };
342 /**
343 * Ändert einzelne Einstellungen. Die Nutzlast ist eine Teilmenge – sie
344 * wird über den gespeicherten Stand gelegt, nicht an seine Stelle
345 * gesetzt. Antwort ist der vollständige neue Stand.
346 */
347 'einstellungen:schreiben': { anfrage: Partial<Einstellungen>; antwort: Einstellungen };
348
349 /** Liefert den vollständigen Fragenkatalog (einmalig beim Start). */
350 'katalog:laden': { anfrage: undefined; antwort: Katalog };
351 /** Bild eines Prüfzeichens als Data-URL – die CSP verbietet Dateizugriffe. */
352 'katalog:bild': { anfrage: { bildId: string }; antwort: string };
353 /**
354 * Erklärungen zu den Fragen – eigener redaktioneller Inhalt.
355 *
356 * Wird wie der Katalog einmalig beim Start geladen. Der Bestand ist
357 * kleiner als der Katalog und wächst mit ihm; ein Nachladen je Frage
358 * wäre viel Verkehr für wenig Nutzen.
359 */
360 'erklaerungen:laden': { anfrage: undefined; antwort: Erklaerungen };
361 /**
362 * Glossar der Fachbegriffe – erfüllt WCAG 3.1.3 und 3.1.4.
363 *
364 * Wie die Erklärungen einmalig geladen: Die Einträge ändern sich zur
365 * Laufzeit nicht, und die Begriffssuche braucht ohnehin alle auf einmal.
366 */
367 'glossar:laden': { anfrage: undefined; antwort: Glossar };
368
369 /**
370 * Die mitgelieferten Normtexte.
371 *
372 * Wie Erklärungen und Glossar einmalig geladen. Der Umfang ist bekannt und
373 * fest: nur die Normen, die irgendwo zitiert werden – rund 500 KiB. Ein
374 * Kanal je Fundstelle wäre bei 2123 Zitaten mehr Verkehr als die ganze
375 * Datei und brächte dem Lesenden nichts, weil er ohnehin blättert.
376 */
377 'gesetz:normtexte': { anfrage: undefined; antwort: Normtexte };
378
379 /**
380 * Die redaktionelle Feingliederung der Kapitel II bis IV.
381 *
382 * Wie Erklärungen und Glossar einmalig geladen: 29 Gruppen über 230 Fragen,
383 * und sie ändern sich zur Laufzeit nicht.
384 */
385 'themen:laden': { anfrage: undefined; antwort: Themen };
386
387 'profil:liste': { anfrage: undefined; antwort: Profil[] };
388 'profil:anlegen': { anfrage: { name: string }; antwort: Profil };
389 'profil:aktualisieren': {
390 anfrage: {
391 id: number;
392 name?: string;
393 pruefungstermin?: string | null;
394 /** Kapitel, die dieses Profil dauerhaft nicht lernt – Kennungen wie „IV“. */
395 kapitelAusschluss?: readonly string[];
396 };
397 antwort: Profil;
398 };
399 /**
400 * Löscht ein Profil samt Lernstand, Antworten und Prüfungsverlauf.
401 *
402 * Antwort ist die verbleibende Liste – die Oberfläche muss danach ohnehin
403 * ein anderes Profil wählen und braucht die Auswahl sofort.
404 */
405 'profil:loeschen': { anfrage: { id: number }; antwort: Profil[] };
406
407 /** Stellt die Fragen einer Lernsitzung nach Filter zusammen. */
408 'lernen:sitzung': {
409 anfrage: { profilId: number; filter: SitzungsFilter };
410 antwort: SitzungsFrage[];
411 };
412 /** Protokolliert eine Antwort und liefert den neuen Stand der Frage. */
413 'lernen:antworten': {
414 anfrage: { profilId: number; protokoll: Antwortprotokoll };
415 antwort: FrageStand;
416 };
417 /** Merkliste umschalten. */
418 'lernen:merken': {
419 anfrage: { profilId: number; frageId: string; gemerkt: boolean };
420 antwort: FrageStand;
421 };
422 /**
423 * Der gespeicherte Stand einer einzelnen Frage; `null`, wenn sie noch nie
424 * beantwortet wurde.
425 *
426 * **Wozu.** `FrageStand` transportierte `versuche`, `richtige`,
427 * `zuletztBeantwortet` und `faelligAb` schon immer über die Brücke – die
428 * Oberfläche benutzte davon bis 0.22.0 ausschließlich `gemerkt`. Der
429 * Lernende konnte nirgends beantworten, wie oft er diese Frage schon hatte
430 * und wie oft davon richtig; „Warum kommt die schon wieder?“ blieb ohne
431 * Antwort, obwohl die Antwort in der Datenbank stand.
432 *
433 * Ein eigener Kanal und nicht die Rückgabe von `lernen:antworten`: Der
434 * Steckbrief soll die Historie **vor** der heutigen Antwort zeigen, und bei
435 * offenen Fragen wird die Buchung ohnehin bis zum Weiterblättern
436 * zurückgehalten (`useSitzung`, `bewertungAendern`).
437 */
438 'lernen:fragestand': {
439 anfrage: { profilId: number; frageId: string };
440 antwort: FrageStand;
441 };
442 /**
443 * Die hartnäckigen Fragen dieses Profils – wiederholt danebengegangen.
444 *
445 * Rein deskriptiv: gezählte Fehlschläge aus dem Protokoll, keine
446 * Kennzahl. Einzelheiten in `shared/hartnaeckig.ts`.
447 */
448 'lernen:hartnaeckige': {
449 anfrage: { profilId: number };
450 antwort: HartnaeckigeFrage[];
451 };
452 'lernen:uebersicht': { anfrage: { profilId: number }; antwort: Lernuebersicht };
453 /**
454 * Lernplan: Prognose, Tagespensum und Machbarkeit zum Prüfungstermin.
455 *
456 * Eigener Kanal statt einer Erweiterung von `lernen:uebersicht`: Der Plan
457 * rechnet über den gesamten Katalog und wird nur dort gebraucht, wo er
458 * auch angezeigt wird.
459 */
460 'lernen:plan': { anfrage: { profilId: number }; antwort: Lernplan };
461 /** Lernstand des Profils vollständig zurücksetzen (ohne Begrenzung). */
462 'lernen:zuruecksetzen': {
463 anfrage: { profilId: number; kapitel?: string | null };
464 antwort: Lernuebersicht;
465 };
466
467 /**
468 * Stellt einen Prüfungsbogen nach dem gewählten Profil zusammen.
469 *
470 * Die Antwort ist ein {@link Pruefungsbogen} und keine reine Fragenliste:
471 * Weicht das Ziehen von den Vorgaben ab, gehört das an den Bildschirm und
472 * nicht nur ins Protokoll des Hauptprozesses. Die Sätze in `warnungen`
473 * kommen fertig formuliert von dort.
474 */
475 'pruefung:starten': {
476 anfrage: { profilId: number; auftrag: Pruefungsauftrag };
477 antwort: Pruefungsbogen;
478 };
479 /** Wertet einen Simulationslauf aus und speichert ihn im Verlauf. */
480 'pruefung:auswerten': {
481 anfrage: {
482 profilId: number;
483 auftrag: Pruefungsauftrag;
484 antworten: Pruefungsantwort[];
485 dauerMs: number;
486 zeitAbgelaufen: boolean;
487 };
488 antwort: Pruefungsergebnis;
489 };
490 'pruefung:verlauf': { anfrage: { profilId: number }; antwort: Pruefungsverlauf[] };
491
492 /**
493 * Sichert den Zwischenstand eines laufenden Bogens.
494 *
495 * Bewusst OHNE den Bogen selbst: Der Kern hat ihn beim Starten abgelegt.
496 * Wer den Bogen mitbringen dürfte, könnte ihn auch erfinden – dann wäre
497 * die Prüfung der eingereichten Antwortliste wertlos. Gesichert wird nur,
498 * was dem Renderer gehört.
499 *
500 * Die Antwort sagt, ob es noch eine Zeile zu sichern gab. `false` heißt
501 * nicht Fehler, sondern „dieser Lauf ist vorbei" – etwa weil die Abgabe
502 * schneller war als ein entprellter Nachzügler.
503 */
504 'pruefung:sichern': {
505 anfrage: { profilId: number; stand: Zwischenstand };
506 antwort: boolean;
507 };
508
509 /** Der unterbrochene Lauf eines Profils, oder `null`. */
510 'pruefung:offen': { anfrage: { profilId: number }; antwort: OffenerLauf | null };
511
512 /** Verwirft den unterbrochenen Lauf – nur auf ausdrücklichen Wunsch. */
513 'pruefung:verwerfen': { anfrage: { profilId: number }; antwort: undefined };
514
515 /**
516 * Meldet, ob gerade eine Prüfung bearbeitet wird.
517 *
518 * Der Hauptprozess fragt beim Schließen des Fensters nach, wenn eine läuft.
519 * Bewusst ein Abgleich und keine Kantenmeldung: Der Renderer schickt seinen
520 * Zustand auch unverändert, damit ein Merker kein Neuladen überlebt.
521 */
522 'pruefung:laeuft': { anfrage: { laeuft: boolean }; antwort: undefined };
523
524 /**
525 * Erzeugt den Lernbericht als PDF und fragt, wohin er gespeichert werden soll.
526 *
527 * Der Anwendungskern holt die Zahlen selbst – Übersicht, Lernplan und
528 * Prüfungsverlauf liegen dort ohnehin. Der Renderer schickt nur, für wen
529 * und wie groß; so kann keine Oberfläche Zahlen in ein Dokument bringen,
530 * die der Kern nicht bestätigt hat.
531 */
532 'druck:lernbericht': {
533 anfrage: { profilId: number; schriftgroesse: Schriftgroesse };
534 antwort: Druckergebnis;
535 };
536
537 /**
538 * Setzt die Anzeigegröße und merkt sie sich.
539 *
540 * Der Zoom gehört in den Hauptprozess: Er wirkt auf das Fenster, nicht auf
541 * das Dokument, und muss beim nächsten Start wieder anliegen, bevor der
542 * erste Bildpunkt gezeichnet wird.
543 */
544 'ansicht:groesse': { anfrage: { prozent: number }; antwort: number };
545
546 /**
547 * Das Fehlerprotokoll als PDF – die zuletzt falsch beantworteten Fragen.
548 *
549 * `tiefe` steuert, ob nur die Kurzerklärung oder die vollständige
550 * Begründung mitgedruckt wird. Gemessen ist der Unterschied erheblich: 150
551 * Fragen ergeben 51 gegen 161 Seiten.
552 */
553 'druck:fehlerprotokoll': {
554 anfrage: { profilId: number; schriftgroesse: Schriftgroesse; tiefe: Erklaerungstiefe };
555 antwort: Druckergebnis;
556 };
557
558 /**
559 * Die Fragenliste als PDF – ein Bogen zum Bearbeiten auf Papier.
560 *
561 * `bereiche` sind Kapitel- oder Abschnittskennungen; eine leere Liste
562 * bedeutet den ganzen Katalog. **Ohne Lernprofil**: Die Liste hängt am
563 * Katalog und nicht am Lernstand, es gibt also nichts zu personalisieren.
564 */
565 'druck:fragenliste': {
566 anfrage: {
567 bereiche: readonly string[];
568 schriftgroesse: Schriftgroesse;
569 mitLoesungen: boolean;
570 };
571 antwort: Druckergebnis;
572 };
573
574 /**
575 * Ein einzelnes Profil aus der geprüften Datei dazunehmen.
576 *
577 * `profilIndex` zeigt in `Pruefergebnis.ausDatei.jeProfil` – nicht auf eine
578 * Profilnummer. Die Nummern der fremden Datei kennt nur der Anwendungskern,
579 * und er behält sie für sich.
580 */
581 'sicherung:uebernehmen': {
582 anfrage: { vorgang: string; profilIndex: number };
583 antwort: Uebernahmeergebnis;
584 };
585
586 /** Lizenzangaben für den Bereich „Über diese Software“. */
587 'lizenzen:lesen': { anfrage: undefined; antwort: Lizenzangaben };
588
589 /**
590 * Die mitgelieferte Datenschutzerklärung.
591 *
592 * Derselbe Weg wie bei den Lizenztexten: Der Hauptprozess liest die Datei,
593 * der Renderer bekommt fertige Blöcke. Kein Dateizugriff im Renderer.
594 */
595 'datenschutz:lesen': { anfrage: undefined; antwort: Datenschutzangaben };
596
597 /**
598 * Meldet, ob gerade ein Hilfsmittel läuft (Screenreader, Bildschirmlupe …).
599 *
600 * Grundlage ist `app.accessibilitySupportEnabled` von Electron. Der Wert
601 * steuert die Voreinstellung der Zeichenkürzel: Diese kollidieren im
602 * Lesemodus von NVDA und JAWS mit der Schnellnavigation – dort sind die
603 * Ziffern für Überschriftenebenen belegt.
604 */
605 'system:hilfsmittel': { anfrage: undefined; antwort: boolean };
606 /**
607 * Legt Text in die Zwischenablage.
608 *
609 * Es gibt diesen Kanal, weil der Renderer es selbst **nicht darf**:
610 * `sicherheit.ts` lehnt mit `setPermissionCheckHandler(() => false)`
611 * sämtliche Berechtigungen ab, und Blink fragt für
612 * `navigator.clipboard.writeText()` die Berechtigung
613 * `clipboard-sanitized-write` ab. Nachgemessen im gebauten Fenster:
614 * `isSecureContext` ist `true`, `navigator.clipboard.writeText` existiert –
615 * und der Aufruf scheitert mit `NotAllowedError: Write permission denied`.
616 *
617 * Den Berechtigungswächter dafür zu öffnen wäre der schlechtere Handel: Das
618 * gäbe die Zwischenablage jedem Renderer-Code frei statt einem Kanal mit
619 * fester Nutzlast.
620 */
621 'system:kopieren': { anfrage: { text: string }; antwort: boolean };
622
623 /**
624 * Öffnet die Unterstützungsseite im Standardbrowser des Systems.
625 *
626 * Der einzige Kanal dieser Anwendung, der nach außen führt – und bewusst
627 * kein allgemeines „öffne diese Adresse“. Der Hauptprozess vergleicht die
628 * Nutzlast mit `UNTERSTUETZUNG_URL` aus `shared/unterstuetzung.ts` und
629 * öffnet ausschließlich diese eine Adresse; jede andere wird abgewiesen.
630 * Wer eine Adresse mitbringen darf, darf sonst auch eine andere mitbringen
631 * – dieselbe Überlegung wie bei `sicherung:einspielen`.
632 *
633 * Dass die Adresse trotzdem in der Nutzlast steht und nicht weggelassen
634 * wird, ist Absicht: So lässt sich prüfen, dass die Oberfläche genau das
635 * anfordert, was sie anzeigt.
636 *
637 * Antwort ist `false`, wenn keine Adresse eingetragen ist, die Nutzlast
638 * nicht passt oder das Betriebssystem den Browser nicht öffnen konnte. Die
639 * Oberfläche zeigt dann die Adresse zum Abschreiben – sie behauptet nicht,
640 * es sei etwas geschehen.
641 */
642 'system:unterstuetzung': { anfrage: { url: string }; antwort: boolean };
643
644 /**
645 * Öffnet eine soeben geschriebene Datei oder zeigt sie im Dateimanager.
646 *
647 * Die Nutzlast ist eine **Kennung**, nie ein Pfad: Wer einen Pfad
648 * mitbringen darf, darf auch einen anderen mitbringen. Der Hauptprozess
649 * merkt sich, was er in dieser Sitzung selbst geschrieben hat, und öffnet
650 * ausschließlich das (`main/dateizugriff.ts`).
651 *
652 * Antwortet `false`, wenn die Kennung unbekannt ist oder das
653 * Betriebssystem die Datei nicht öffnen konnte – etwa weil sie inzwischen
654 * verschoben wurde. Die Oberfläche sagt dann, dass es nicht geklappt hat,
655 * statt so zu tun, als sei etwas geschehen.
656 */
657 'system:datei-zeigen': {
658 anfrage: { kennung: string; wunsch: 'oeffnen' | 'ordner' };
659 antwort: boolean;
660 };
661
662 /**
663 * Schreibt den ganzen Lernstand in eine Datei.
664 *
665 * Der Nutzer wählt den Ort in einem Systemdialog. Übertragen wird nichts –
666 * die Datei geht dorthin, wohin er sie legt, und sonst nirgendwohin.
667 */
668 'sicherung:anlegen': { anfrage: undefined; antwort: Sicherungsergebnis };
669
670 /**
671 * Prüft eine gewählte Datei und beziffert sie, **ohne etwas zu verändern**.
672 *
673 * Getrennt vom Einspielen, weil zwischen „das steht darin“ und „ja, ersetze
674 * meinen Lernstand“ eine Entscheidung des Nutzers liegt. Bis
675 * {@link IpcVertrag['sicherung:einspielen']} gerufen wird, ist nichts
676 * angefasst.
677 */
678 'sicherung:pruefen': { anfrage: undefined; antwort: Pruefergebnis };
679
680 /**
681 * Ersetzt den Lernstand durch die zuvor geprüfte Datei.
682 *
683 * Die Nutzlast ist ausschliesslich die Vorgangskennung aus
684 * `sicherung:pruefen`; einen Pfad nimmt dieser Kanal nicht entgegen. Wer
685 * einen Pfad mitbringen darf, darf auch einen anderen mitbringen.
686 */
687 'sicherung:einspielen': { anfrage: { vorgang: string }; antwort: Einspielergebnis };
688 }
689
690 /**
691 * Kanal, über den der Main-Prozess von sich aus meldet, dass sich der
692 * Hilfsmittel-Zustand geändert hat. Nötig, weil ein Screenreader auch
693 * mitten in der Sitzung gestartet werden kann – gerade dann, wenn jemand
694 * merkt, dass er ihn braucht.
695 */
696 export const KANAL_HILFSMITTEL_GEAENDERT = 'system:hilfsmittel-geaendert' as const;
697
698 /** Kanal, über den der Hauptprozess eine geänderte Anzeigegröße meldet. */
699 export const KANAL_ANZEIGEGROESSE_GEAENDERT = 'ansicht:groesse-geaendert' as const;
700
701 /**
702 * Befehle, die aus der Menüleiste kommen und in der Oberfläche wirken.
703 *
704 * Die Menüleiste liegt im Hauptprozess, die Ansicht im Renderer – ohne einen
705 * solchen Kanal wäre ein Menüeintrag, der etwas anzeigt, gar nicht baubar.
706 * Absichtlich eine geschlossene Liste und keine freie Zeichenkette: Was hier
707 * nicht steht, kommt am anderen Ende nicht an.
708 */
709 export const MENUEBEFEHLE = [
710 'hilfe',
711 /*
712 Die Ziele des Menüs „Gehe zu“, seit Fassung 0.26.0.
713
714 Bis dahin führte das Anwendungsmenü zu keiner einzigen der zehn
715 Ansichten. Es ist die einzige Fläche des Fensters, die nicht wegrollt —
716 und der Startbildschirm ist bei 1265 Bildpunkten Breite 8530 hoch.
717 Wer daran vorbeigerollt war, hatte keinen Weg mehr in eine andere
718 Ansicht als zurück an den Anfang.
719
720 Die Namen sind die Ansichtsarten aus `renderer/src/lernen/typen.ts`, mit
721 „gehe-zu-“ davor: Ein Befehl ist keine Ansicht, und beide Listen sollen
722 sich unabhängig ändern dürfen.
723 */
724 'gehe-zu-start',
725 'gehe-zu-kapitelwahl',
726 'gehe-zu-pruefungswahl',
727 'gehe-zu-suche',
728 'gehe-zu-glossar',
729 'gehe-zu-ueber',
730 ] as const;
731
732 export type Menuebefehl = (typeof MENUEBEFEHLE)[number];
733
734 export function istMenuebefehl(wert: unknown): wert is Menuebefehl {
735 return typeof wert === 'string' && (MENUEBEFEHLE as readonly string[]).includes(wert);
736 }
737
738 /** Kanal, über den der Hauptprozess einen Menübefehl an die Ansicht meldet. */
739 export const KANAL_MENUE_BEFEHL = 'menue:befehl' as const;
740
741 /**
742 * Kanal, über den der Hauptprozess meldet, wie lange der Rechner geschlafen
743 * hat. Die Nutzlast ist die Schlafdauer in Millisekunden.
744 *
745 * **Warum das nicht der Renderer selbst messen kann.** Naheliegend wäre, im
746 * Sekundentakt zu prüfen, ob zwischen zwei Takten mehr als eine Sekunde
747 * vergangen ist. Chromium drosselt Zeitgeber in verdeckten Fenstern aber auf
748 * einen Takt pro Minute – ein bloß **minimiertes** Fenster sähe damit aus wie
749 * ein schlafender Rechner. Die Prüfungsuhr ließe sich durch Minimieren
750 * anhalten, und das wäre ein Schummelweg statt eines Nachteilsausgleichs.
751 * `powerMonitor` meldet ausschließlich echten Energiesparmodus und ist von
752 * der Drosselung unberührt.
753 */
754 export const KANAL_ENERGIESPAREN_ENDE = 'system:energiesparen-ende' as const;
755
756 export type IpcKanal = keyof IpcVertrag;
757 export type IpcAnfrage<K extends IpcKanal> = IpcVertrag[K]['anfrage'];
758 export type IpcAntwort<K extends IpcKanal> = IpcVertrag[K]['antwort'];
759
760 /**
761 * Allowlist aller erlaubten Kanäle. Main und Preload prüfen dagegen, damit
762 * keine unbeabsichtigten Kanäle über die Bridge erreichbar werden.
763 */
764 export const IPC_KANAELE = [
765 'anwendung:info',
766 'einstellungen:lesen',
767 'einstellungen:schreiben',
768 'katalog:laden',
769 'katalog:bild',
770 'erklaerungen:laden',
771 'glossar:laden',
772 'gesetz:normtexte',
773 'themen:laden',
774 'profil:liste',
775 'profil:anlegen',
776 'profil:aktualisieren',
777 'profil:loeschen',
778 'lernen:sitzung',
779 'lernen:antworten',
780 'lernen:merken',
781 'lernen:fragestand',
782 'lernen:hartnaeckige',
783 'lernen:uebersicht',
784 'lernen:plan',
785 'lernen:zuruecksetzen',
786 'pruefung:starten',
787 'pruefung:auswerten',
788 'pruefung:verlauf',
789 'pruefung:sichern',
790 'pruefung:offen',
791 'pruefung:verwerfen',
792 'pruefung:laeuft',
793 'druck:lernbericht',
794 'druck:fehlerprotokoll',
795 'druck:fragenliste',
796 'ansicht:groesse',
797 'lizenzen:lesen',
798 'datenschutz:lesen',
799 'system:hilfsmittel',
800 'system:kopieren',
801 'system:unterstuetzung',
802 'system:datei-zeigen',
803 'sicherung:anlegen',
804 'sicherung:pruefen',
805 'sicherung:einspielen',
806 'sicherung:uebernehmen',
807 ] as const satisfies readonly IpcKanal[];
808
809 export function istIpcKanal(wert: unknown): wert is IpcKanal {
810 return typeof wert === 'string' && (IPC_KANAELE as readonly string[]).includes(wert);
811 }
812
813 /** Name des Objekts, das im Renderer unter `window` liegt. */
814 export const BRIDGE_NAME = 'lernApp' as const;
815
816 /** Die im Renderer sichtbare, vollständig typisierte API. */
817 export interface LernAppBridge {
818 readonly anwendungsInfoLesen: () => Promise<AnwendungsInfo>;
819 readonly einstellungenLesen: () => Promise<Einstellungen>;
820 readonly einstellungenSchreiben: (aenderung: Partial<Einstellungen>) => Promise<Einstellungen>;
821
822 readonly katalogLaden: () => Promise<Katalog>;
823 readonly katalogBild: (bildId: string) => Promise<string>;
824 readonly erklaerungenLaden: () => Promise<Erklaerungen>;
825 readonly glossarLaden: () => Promise<Glossar>;
826 readonly normtexteLaden: () => Promise<Normtexte>;
827 readonly themenLaden: () => Promise<Themen>;
828
829 readonly profilListe: () => Promise<Profil[]>;
830 readonly profilAnlegen: (name: string) => Promise<Profil>;
831 readonly profilAktualisieren: (
832 id: number,
833 aenderung: {
834 name?: string;
835 pruefungstermin?: string | null;
836 /** Kapitel, die dieses Profil dauerhaft nicht lernt. */
837 kapitelAusschluss?: readonly string[];
838 },
839 ) => Promise<Profil>;
840 /** Löscht ein Profil; liefert die verbleibenden zurück. */
841 readonly profilLoeschen: (id: number) => Promise<Profil[]>;
842
843 readonly lernSitzung: (profilId: number, filter: SitzungsFilter) => Promise<SitzungsFrage[]>;
844 readonly lernAntworten: (profilId: number, protokoll: Antwortprotokoll) => Promise<FrageStand>;
845 readonly lernMerken: (profilId: number, frageId: string, gemerkt: boolean) => Promise<FrageStand>;
846 /**
847 * Der gespeicherte Stand einer einzelnen Frage – für den Steckbrief.
848 *
849 * Optional geführt wie die übrigen jüngeren Kanäle: Fehlt er, entfällt der
850 * Steckbrief, und die Sitzung läuft wie bisher.
851 */
852 readonly lernFragestand?: (profilId: number, frageId: string) => Promise<FrageStand>;
853 /** Die hartnäckigen Fragen – wiederholt danebengegangen. */
854 readonly lernHartnaeckige?: (profilId: number) => Promise<HartnaeckigeFrage[]>;
855 readonly lernUebersicht: (profilId: number) => Promise<Lernuebersicht>;
856 readonly lernPlan: (profilId: number) => Promise<Lernplan>;
857 readonly lernZuruecksetzen: (
858 profilId: number,
859 kapitel?: string | null,
860 ) => Promise<Lernuebersicht>;
861
862 /** Liefert den Bogen samt der Abweichungen, die beim Ziehen nötig waren. */
863 readonly pruefungStarten: (
864 profilId: number,
865 auftrag: Pruefungsauftrag,
866 ) => Promise<Pruefungsbogen>;
867 readonly pruefungAuswerten: (
868 profilId: number,
869 auftrag: Pruefungsauftrag,
870 antworten: Pruefungsantwort[],
871 dauerMs: number,
872 zeitAbgelaufen: boolean,
873 ) => Promise<Pruefungsergebnis>;
874 readonly pruefungVerlauf: (profilId: number) => Promise<Pruefungsverlauf[]>;
875 /** Sichert den Zwischenstand; `false` heißt „dieser Lauf ist vorbei". */
876 readonly pruefungSichern: (profilId: number, stand: Zwischenstand) => Promise<boolean>;
877 /** Der unterbrochene Lauf eines Profils, oder `null`. */
878 readonly pruefungOffen: (profilId: number) => Promise<OffenerLauf | null>;
879 readonly pruefungVerwerfen: (profilId: number) => Promise<void>;
880 /** Meldet dem Hauptprozess, ob gerade geprüft wird. */
881 readonly pruefungLaeuft: (laeuft: boolean) => Promise<void>;
882
883 /**
884 * Setzt die Anzeigegröße in Prozent; liefert die tatsächlich gesetzte
885 * Stufe zurück (der Kern begrenzt auf gültige Werte).
886 */
887 readonly anzeigegroesseSetzen: (prozent: number) => Promise<number>;
888 /**
889 * Meldet, wenn die Anzeigegröße anderswo geändert wurde – über das Menü
890 * oder Strg+Plus. Ohne diese Meldung zeigte die Einstellung eine Zahl an,
891 * die nicht mehr stimmt.
892 */
893 readonly anzeigegroesseBeobachten: (melden: (prozent: number) => void) => () => void;
894
895 /**
896 * Nimmt ein Profil aus der geprüften Datei dazu, ohne etwas zu ersetzen.
897 *
898 * `profilIndex` zeigt in die Liste, die `sicherungPruefen` geliefert hat.
899 */
900 readonly sicherungUebernehmen: (
901 vorgang: string,
902 profilIndex: number,
903 ) => Promise<Uebernahmeergebnis>;
904
905 /** Erzeugt das Fehlerprotokoll als PDF und fragt nach dem Speicherort. */
906 readonly fehlerprotokollDrucken: (
907 profilId: number,
908 schriftgroesse: Schriftgroesse,
909 tiefe: Erklaerungstiefe,
910 ) => Promise<Druckergebnis>;
911
912 /** Erzeugt die Fragenliste als PDF und fragt nach dem Speicherort. */
913 readonly fragenlisteDrucken: (
914 bereiche: readonly string[],
915 schriftgroesse: Schriftgroesse,
916 mitLoesungen: boolean,
917 ) => Promise<Druckergebnis>;
918
919 /** Erzeugt den Lernbericht als PDF und fragt nach dem Speicherort. */
920 readonly lernberichtDrucken: (
921 profilId: number,
922 schriftgroesse: Schriftgroesse,
923 ) => Promise<Druckergebnis>;
924
925 readonly lizenzenLesen: () => Promise<Lizenzangaben>;
926
927 readonly datenschutzLesen: () => Promise<Datenschutzangaben>;
928
929 readonly hilfsmittelAktiv: () => Promise<boolean>;
930 /**
931 * Legt Text in die Zwischenablage. Siehe Kanal `system:kopieren`.
932 *
933 * Optional geführt wie die übrigen jüngeren Kanäle: Die Oberfläche muss
934 * ohne ihn auskommen, und die Attrappen der Oberflächentests tragen das
935 * volle Interface. Fehlt er, entfällt der Kopierknopf – der Meldetext bleibt
936 * sichtbar und markierbar.
937 */
938 readonly zwischenablageSchreiben?: (text: string) => Promise<boolean>;
939
940 /**
941 * Öffnet die Unterstützungsseite im Standardbrowser. Siehe Kanal
942 * `system:unterstuetzung`.
943 *
944 * Optional geführt wie die übrigen jüngeren Kanäle. Fehlt er, lässt die
945 * Ansicht „Über diese Software“ das Angebot **vollständig** weg – ein Knopf,
946 * der nichts öffnen kann, wäre eine Zusage ohne Deckung.
947 */
948 readonly unterstuetzungOeffnen?: (url: string) => Promise<boolean>;
949
950 /**
951 * Öffnet eine soeben geschriebene Datei oder zeigt sie im Ordner.
952 *
953 * Optional geführt wie die übrigen jüngeren Kanäle: Fehlt er, entfallen
954 * die beiden Schaltflächen nach einem Export – gespeichert ist die Datei
955 * dann trotzdem, und ihr Pfad steht auf dem Bildschirm.
956 */
957 readonly dateiZeigen?: (kennung: string, wunsch: 'oeffnen' | 'ordner') => Promise<boolean>;
958
959 /* Optional geführt wie die übrigen jüngeren Kanäle: Die Oberfläche muss
960 ohne sie auskommen, und die Attrappen der Tests tragen das volle
961 Interface. Fehlen sie, entfällt die Karte zur Sicherung. */
962 readonly sicherungAnlegen?: () => Promise<Sicherungsergebnis>;
963 readonly sicherungPruefen?: () => Promise<Pruefergebnis>;
964 readonly sicherungEinspielen?: (vorgang: string) => Promise<Einspielergebnis>;
965 /**
966 * Meldet Änderungen des Hilfsmittel-Zustands. Liefert eine Funktion zum
967 * Abmelden zurück, damit React beim Abbau aufräumen kann.
968 */
969 readonly hilfsmittelBeobachten: (melden: (aktiv: boolean) => void) => () => void;
970
971 /**
972 * Meldet Befehle aus der Menüleiste. Liefert eine Funktion zum Abmelden
973 * zurück, damit React beim Abbau aufräumen kann.
974 */
975 readonly menuebefehlBeobachten: (melden: (befehl: Menuebefehl) => void) => () => void;
976
977 /**
978 * Meldet nach dem Aufwachen, wie lange der Rechner geschlafen hat.
979 *
980 * Optional geführt wie die übrigen jüngeren Kanäle: Fehlt er, läuft die
981 * Simulation wie bisher – nur ohne Gutschrift.
982 */
983 readonly energiesparenBeobachten?: (melden: (schlafMs: number) => void) => () => void;
984 }