waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Erzeugung des Hauptfensters mit gehärteten `webPreferences`. |
| 3 | */ |
| 4 | |
| 5 | import { join } from 'node:path'; |
| 6 | |
| 7 | import { BrowserWindow, dialog, nativeTheme, screen } from 'electron'; |
| 8 | |
| 9 | import { |
| 10 | FENSTER_MINDESTBREITE, |
| 11 | FENSTER_MINDESTHOEHE, |
| 12 | startlage, |
| 13 | type Fensterlage, |
| 14 | } from '../shared/fensterlage'; |
| 15 | import { startfarbeFuer } from '../shared/theme'; |
| 16 | import { anwendenAuf } from './anzeige'; |
| 17 | import { einstellungenLesen, einstellungenSchreiben } from './einstellungen'; |
| 18 | import { laeuftPruefung, laufVergessen } from './laufwaechter'; |
| 19 | import { oberflaechenDatei } from './oberflaeche'; |
| 20 | import { protokollieren } from './protokoll'; |
| 21 | |
| 22 | /** |
| 23 | * Wie lange der erste Frame auf sich warten lassen darf. |
| 24 | * |
| 25 | * Danach wird das Fenster auch ohne ihn gezeigt. Vier Sekunden sind reichlich |
| 26 | * über dem, was ein gesunder Start braucht — gemessen liegt `ready-to-show` |
| 27 | * hier deutlich unter einer Sekunde —, und kurz genug, dass niemand vor einem |
| 28 | * leeren Bildschirm sitzt und sich fragt, ob er zweimal geklickt hat. |
| 29 | */ |
| 30 | const FRIST_MS = 4000; |
| 31 | |
| 32 | /** |
| 33 | * Hintergrundfarbe des Fensters – verhindert ein Aufblitzen beim Start. |
| 34 | * |
| 35 | * Maßgeblich ist die **gespeicherte** Wahl aus `einstellungen.json`, nicht |
| 36 | * das Systemdesign: Bis 0.21.0 fragte diese Stelle nur `nativeTheme`, und wer |
| 37 | * „Dunkel“ eingestellt hatte, während Windows hell läuft, sah bei jedem Start |
| 38 | * kurz eine helle Fläche. Der Hauptprozess liest die Datei synchron und weiß |
| 39 | * die Antwort damit, bevor der erste Bildpunkt fällt – derselbe Weg, den die |
| 40 | * Anzeigegröße geht. |
| 41 | * |
| 42 | * `system` bleibt `system`: Dann entscheidet weiterhin `nativeTheme`, und |
| 43 | * `themeAufloesen` rechnet das mit denselben Regeln aus wie der Renderer. |
| 44 | */ |
| 45 | function startHintergrund(): string { |
| 46 | return startfarbeFuer(einstellungenLesen().thema, { |
| 47 | bevorzugtDunkel: nativeTheme.shouldUseDarkColors, |
| 48 | erzwungeneFarben: nativeTheme.shouldUseHighContrastColors, |
| 49 | /* `prefers-contrast: more` meldet Electron nicht getrennt; unter |
| 50 | erzwungenen Farben ist die Frage ohnehin beantwortet. */ |
| 51 | bevorzugtMehrKontrast: false, |
| 52 | }); |
| 53 | } |
| 54 | |
| 55 | /** Wie oft ein gestorbener Renderer selbsttätig neu geladen wird. */ |
| 56 | export const NEULADEVERSUCHE = 2; |
| 57 | |
| 58 | /** |
| 59 | * Ab dieser Ruhe gilt ein Absturz als neuer Vorfall statt als Schleife. |
| 60 | * |
| 61 | * Ohne diese Frist zählte ein Absturz aus der vorigen Woche noch mit, und |
| 62 | * das dritte Vorkommnis in einem Jahr bekäme kein Neuladen mehr. |
| 63 | */ |
| 64 | export const SCHLEIFENFENSTER_MS = 60_000; |
| 65 | |
| 66 | /** Was nach dem Tod eines Renderer-Prozesses zu tun ist. */ |
| 67 | export type Absturzantwort = 'ruhen' | 'neu-laden' | 'aufgeben'; |
| 68 | |
| 69 | /** |
| 70 | * Entscheidet über die Antwort auf einen gestorbenen Renderer. |
| 71 | * |
| 72 | * Als reine Funktion herausgezogen, weil an ihr das Verhalten hängt und ein |
| 73 | * echter Absturz sich nicht auf Bestellung herstellen lässt. |
| 74 | * |
| 75 | * `clean-exit` und `killed` sind kein Absturz: Das erste ist das gewöhnliche |
| 76 | * Beenden, das zweite ein Abschuss von außen. Beide neu zu laden hieße, ein |
| 77 | * geschlossenes Fenster wieder aufzumachen. |
| 78 | */ |
| 79 | export function absturzAntwort( |
| 80 | grund: string, |
| 81 | versucheBisher: number, |
| 82 | seitLetztemAbsturzMs: number, |
| 83 | ): Absturzantwort { |
| 84 | if (grund === 'clean-exit' || grund === 'killed') { |
| 85 | return 'ruhen'; |
| 86 | } |
| 87 | const versuche = seitLetztemAbsturzMs > SCHLEIFENFENSTER_MS ? 0 : versucheBisher; |
| 88 | return versuche < NEULADEVERSUCHE ? 'neu-laden' : 'aufgeben'; |
| 89 | } |
| 90 | |
| 91 | export function hauptfensterErstellen(): BrowserWindow { |
| 92 | /* |
| 93 | Mindestbreite: 360 statt der früheren 720. |
| 94 | |
| 95 | 720 Pixel waren die zweite Hälfte des Reflow-Problems (WCAG 1.4.10). Das |
| 96 | Kriterium fragt, ob sich der Inhalt bei 320 CSS-Pixeln ohne seitliches |
| 97 | Rollen benutzen lässt. Mit 720 Pixeln Mindestbreite und einer Zoomleiter |
| 98 | bis 200 Prozent war der schmalste erreichbare Bereich 360 CSS-Pixel – der |
| 99 | Prüffall ließ sich nicht einmal herstellen. |
| 100 | |
| 101 | Die Zoomleiter reicht jetzt bis 400 Prozent und stellt den Fall auf einem |
| 102 | gewöhnlichen Fenster her (1280 / 4 = 320). Die Mindestbreite fällt |
| 103 | trotzdem: Wer das Fenster neben ein anderes stellt oder mit einer |
| 104 | Bildschirmlupe arbeitet, will es schmal ziehen können. 360 und nicht 320, |
| 105 | weil `minWidth` die Fenster- und nicht die Inhaltsbreite ist – der Rahmen |
| 106 | kostet unter Windows ein gutes Dutzend Pixel, und bei 100 Prozent Anzeige |
| 107 | sollen 320 CSS-Pixel Inhalt übrig bleiben. |
| 108 | |
| 109 | Dass das Layout bei 320 Pixeln trägt, ist gemessen: einspaltig, kein |
| 110 | seitliches Rollen, nichts abgeschnitten (`e2e/anzeigegroesse.spec.ts`). |
| 111 | */ |
| 112 | /* |
| 113 | Größe und Lage der letzten Sitzung. |
| 114 | |
| 115 | Gegen die vorhandenen Bildschirme geprüft (`shared/fensterlage.ts`): Eine |
| 116 | Lage auf einem abgesteckten zweiten Monitor ergäbe ein Fenster, das |
| 117 | aufgeht und unsichtbar bleibt – und ohne Fenster gibt es keinen Weg, die |
| 118 | Einstellung zurückzusetzen. |
| 119 | */ |
| 120 | const lage = startlage( |
| 121 | einstellungenLesen().fenster, |
| 122 | screen.getAllDisplays().map((anzeige) => anzeige.workArea), |
| 123 | ); |
| 124 | |
| 125 | const fenster = new BrowserWindow({ |
| 126 | width: lage.breite, |
| 127 | height: lage.hoehe, |
| 128 | ...(lage.x === undefined || lage.y === undefined ? {} : { x: lage.x, y: lage.y }), |
| 129 | minWidth: FENSTER_MINDESTBREITE, |
| 130 | minHeight: FENSTER_MINDESTHOEHE, |
| 131 | show: false, |
| 132 | backgroundColor: startHintergrund(), |
| 133 | title: 'Waffensachkunde – Lernsoftware', |
| 134 | autoHideMenuBar: false, |
| 135 | webPreferences: { |
| 136 | preload: join(__dirname, '../preload/index.js'), |
| 137 | |
| 138 | // ── Sicherheitsgrundlagen (nicht verhandelbar) ────────────────── |
| 139 | contextIsolation: true, |
| 140 | nodeIntegration: false, |
| 141 | sandbox: true, |
| 142 | |
| 143 | // ── Ergänzende Härtung ────────────────────────────────────────── |
| 144 | nodeIntegrationInWorker: false, |
| 145 | nodeIntegrationInSubFrames: false, |
| 146 | webSecurity: true, |
| 147 | allowRunningInsecureContent: false, |
| 148 | experimentalFeatures: false, |
| 149 | webviewTag: false, |
| 150 | |
| 151 | // Die Rechtschreibprüfung lädt Wörterbücher aus dem Netz nach – die |
| 152 | // Anwendung soll vollständig offline funktionieren. |
| 153 | spellcheck: false, |
| 154 | }, |
| 155 | }); |
| 156 | |
| 157 | /* Die gespeicherte Anzeigegröße muss anliegen, BEVOR gezeichnet wird – |
| 158 | sonst blitzt die Anwendung beim Start in der falschen Größe auf. Der |
| 159 | Zoomfaktor hängt an den webContents und wird nicht vererbt; jedes neue |
| 160 | Fenster braucht ihn erneut. */ |
| 161 | fenster.webContents.on('did-finish-load', () => { |
| 162 | anwendenAuf(fenster.webContents); |
| 163 | /* Ein frisch geladenes Dokument prüft nicht. Der Renderer meldet seinen |
| 164 | Zustand danach ohnehin selbst; das hier ist die Absicherung dagegen, |
| 165 | dass ein Merker ein Neuladen überlebt. */ |
| 166 | laufVergessen(fenster.webContents); |
| 167 | }); |
| 168 | |
| 169 | /* |
| 170 | Zeigen, sobald der erste Frame steht — und notfalls auch ohne ihn. |
| 171 | |
| 172 | `ready-to-show` ist der richtige Zeitpunkt: Vorher zeigte das Fenster |
| 173 | einen weißen Blitz. Aber es ist keine Zusage, die immer eingelöst wird. |
| 174 | |
| 175 | **Was am 29.08.2026 passiert ist.** Seit 0.23.0 merkt sich die Anwendung |
| 176 | Größe und Lage des Fensters. Sobald eine Position gespeichert war und |
| 177 | beim Start wieder angelegt wurde, starb auf einem Windows-11-Rechner der |
| 178 | GPU-Prozess (`GPU process exited unexpectedly: exit_code=-1`), danach der |
| 179 | Renderer. Ohne ersten Frame feuerte `ready-to-show` nie — und das Fenster |
| 180 | blieb für immer unsichtbar. Nachgemessen: Das Fenster existierte, saß an |
| 181 | der gespeicherten Stelle und war vollständig geladen; nur zeigte es |
| 182 | niemand. Vier von vier Versuchen, mit jeder Position, auch mit 0/0. Ohne |
| 183 | gespeicherte Position trat es nie auf. |
| 184 | |
| 185 | Für den Anwender heißt das: Die Anwendung startet einmal, er verschiebt |
| 186 | das Fenster, und danach lässt sie sich nie wieder öffnen — es erscheint |
| 187 | nichts, es meldet nichts, und an die Einstellung kommt er nicht heran, |
| 188 | weil dafür ein Fenster nötig wäre. Genau der Fall, den der Kommentar über |
| 189 | `startlage()` befürchtet, nur aus einer anderen Richtung. |
| 190 | |
| 191 | Die Rückfallebene ist deshalb nicht Kosmetik: Ein Fenster ohne ersten |
| 192 | Frame ist unschön, ein Programm ohne Fenster ist unbenutzbar. Nach |
| 193 | `did-finish-load` — der Punkt, an dem die Oberfläche geladen ist — |
| 194 | bekommt der erste Frame noch eine Frist; verstreicht sie, wird trotzdem |
| 195 | gezeigt und der Vorfall ins Fehlerprotokoll geschrieben. |
| 196 | */ |
| 197 | let gezeigt = false; |
| 198 | const zeigen = (grund: 'ready-to-show' | 'notbremse'): void => { |
| 199 | if (gezeigt || fenster.isDestroyed()) { |
| 200 | return; |
| 201 | } |
| 202 | gezeigt = true; |
| 203 | anwendenAuf(fenster.webContents); |
| 204 | /* Maximieren vor dem Zeigen: Andersherum blitzte das Fenster einen |
| 205 | Augenblick in seiner nicht maximierten Größe auf. */ |
| 206 | if (lage.maximiert === true) { |
| 207 | fenster.maximize(); |
| 208 | } |
| 209 | fenster.show(); |
| 210 | if (grund === 'notbremse') { |
| 211 | const spur = `Fenster ohne ersten Frame gezeigt — ready-to-show blieb ${String(FRIST_MS)} ms aus.`; |
| 212 | console.warn(`[fenster] ${spur}`); |
| 213 | protokollieren('fenster', spur); |
| 214 | } |
| 215 | }; |
| 216 | |
| 217 | fenster.once('ready-to-show', () => { |
| 218 | zeigen('ready-to-show'); |
| 219 | }); |
| 220 | |
| 221 | fenster.webContents.once('did-finish-load', () => { |
| 222 | setTimeout(() => { |
| 223 | zeigen('notbremse'); |
| 224 | }, FRIST_MS); |
| 225 | }); |
| 226 | |
| 227 | // Der Renderer darf den Fenstertitel nicht überschreiben. |
| 228 | fenster.on('page-title-updated', (ereignis) => { |
| 229 | ereignis.preventDefault(); |
| 230 | }); |
| 231 | |
| 232 | /* |
| 233 | Größe und Lage merken. |
| 234 | |
| 235 | Gemessen wird `getNormalBounds()` und nicht `getBounds()`: Ein maximiertes |
| 236 | Fenster meldet sonst die Bildschirmgröße, und wer die Maximierung einmal |
| 237 | aufhebt, säße vor einem Fenster, das den ganzen Schirm füllt, ohne |
| 238 | maximiert zu sein — die vorige, bewusst gewählte Größe wäre weg. |
| 239 | |
| 240 | Entprellt, weil `resize` und `move` beim Ziehen im Dutzend feuern: Ohne |
| 241 | Verzögerung schriebe jeder Pixel eine Datei. Und beim Schließen noch |
| 242 | einmal ohne Verzögerung, damit die letzte Änderung nicht verfällt. |
| 243 | */ |
| 244 | let schreibuhr: NodeJS.Timeout | null = null; |
| 245 | |
| 246 | const lageMerken = (): void => { |
| 247 | if (fenster.isDestroyed()) { |
| 248 | return; |
| 249 | } |
| 250 | const bounds = fenster.getNormalBounds(); |
| 251 | const neu: Fensterlage = { |
| 252 | breite: bounds.width, |
| 253 | hoehe: bounds.height, |
| 254 | x: bounds.x, |
| 255 | y: bounds.y, |
| 256 | ...(fenster.isMaximized() ? { maximiert: true } : {}), |
| 257 | }; |
| 258 | try { |
| 259 | einstellungenSchreiben({ fenster: neu }); |
| 260 | } catch (fehler: unknown) { |
| 261 | /* Eine nicht gemerkte Fenstergröße ist ein Schönheitsfehler; das |
| 262 | Beenden daran scheitern zu lassen wäre der schlechtere Handel. */ |
| 263 | console.warn('[fenster] Lage konnte nicht gemerkt werden:', fehler); |
| 264 | } |
| 265 | }; |
| 266 | |
| 267 | const lageSpaeterMerken = (): void => { |
| 268 | if (schreibuhr !== null) { |
| 269 | clearTimeout(schreibuhr); |
| 270 | } |
| 271 | schreibuhr = setTimeout(lageMerken, 400); |
| 272 | }; |
| 273 | |
| 274 | fenster.on('resize', lageSpaeterMerken); |
| 275 | fenster.on('move', lageSpaeterMerken); |
| 276 | fenster.on('maximize', lageSpaeterMerken); |
| 277 | fenster.on('unmaximize', lageSpaeterMerken); |
| 278 | |
| 279 | fenster.on('close', () => { |
| 280 | if (schreibuhr !== null) { |
| 281 | clearTimeout(schreibuhr); |
| 282 | schreibuhr = null; |
| 283 | } |
| 284 | lageMerken(); |
| 285 | }); |
| 286 | |
| 287 | /* |
| 288 | Mitten in der Prüfung wird nachgefragt. |
| 289 | |
| 290 | Ein Bogen läuft bis zu zwei Stunden. Er ist zwar gesichert und lässt sich |
| 291 | fortsetzen – aber ein Fenster, das auf Alt+F4 wortlos zugeht, während |
| 292 | jemand bei Frage 63 sitzt, ist trotzdem ein Schrecken. Die Frage kostet |
| 293 | einen Tastendruck und nimmt ihn weg. |
| 294 | |
| 295 | Bewusst `showMessageBoxSync`: Der Beschluss muss vorliegen, bevor dieser |
| 296 | Behandler zurückkehrt. Ein asynchroner Dialog käme zu spät – das Fenster |
| 297 | wäre längst zu. Native Dialoge liest der Screenreader; die Tastatur |
| 298 | bedient sie ohnehin. |
| 299 | |
| 300 | `cancelId` zeigt ausdrücklich auf „Weiter prüfen", nicht über die |
| 301 | Reihenfolge: Escape soll nichts tun, unabhängig davon, wie die Knöpfe |
| 302 | später einmal angeordnet sind. |
| 303 | */ |
| 304 | fenster.on('close', (ereignis) => { |
| 305 | if (!laeuftPruefung(fenster)) { |
| 306 | return; |
| 307 | } |
| 308 | |
| 309 | const wahl = dialog.showMessageBoxSync(fenster, { |
| 310 | type: 'question', |
| 311 | buttons: ['Weiter prüfen', 'Beenden'], |
| 312 | defaultId: 0, |
| 313 | cancelId: 0, |
| 314 | noLink: true, |
| 315 | title: 'Prüfungssimulation läuft', |
| 316 | message: 'Es läuft eine Prüfungssimulation. Wirklich beenden?', |
| 317 | detail: |
| 318 | 'Ihr Bogen bleibt erhalten. Beim nächsten Start können Sie ihn fortsetzen; ' + |
| 319 | 'die Uhr steht so lange still.', |
| 320 | }); |
| 321 | |
| 322 | if (wahl === 0) { |
| 323 | ereignis.preventDefault(); |
| 324 | } else { |
| 325 | laufVergessen(fenster.webContents); |
| 326 | } |
| 327 | }); |
| 328 | |
| 329 | /* |
| 330 | Ein gestorbener Renderer hinterließ ein totes, leeres Fenster. |
| 331 | |
| 332 | Der Fehlerauffang der Oberfläche (`Fehlerauffang.tsx`) fängt Ausnahmen im |
| 333 | Renderbaum und zeigt eine Fehlerseite. Stirbt aber der Renderer-**Prozess** |
| 334 | selbst – Speichermangel, Grafiktreiber –, kommt er nicht mehr zum Zuge: |
| 335 | Das Fenster bleibt weiß, ohne Text, ohne Fokus, ohne Ansage. Für jemanden |
| 336 | am Screenreader ist das nichts, worüber sich etwas sagen ließe. Ein Ausweg |
| 337 | bestand zwar (das Anwendungsmenü bleibt bedienbar, „Ansicht → Neu laden“), |
| 338 | aber niemand wusste davon. |
| 339 | |
| 340 | Neuladen ist verlustfrei: Der Prüfungsbogen wird im Sekundentakt gesichert |
| 341 | und der Lernstand liegt ohnehin in der Datenbank. Deshalb wird es getan, |
| 342 | statt danach zu fragen – und höchstens {@link NEULADEVERSUCHE} Mal, damit |
| 343 | aus einem Absturz beim Laden keine Schleife wird, die sich nicht mehr |
| 344 | beenden lässt. |
| 345 | |
| 346 | Der Merker der laufenden Prüfung fällt in jedem Fall: Sonst hinge das |
| 347 | Fenster an einer Rückfrage zu einer Prüfung, die es nicht mehr gibt. |
| 348 | */ |
| 349 | let neuladeVersuche = 0; |
| 350 | let letzterAbsturz = 0; |
| 351 | |
| 352 | fenster.webContents.on('render-process-gone', (_ereignis, einzelheiten) => { |
| 353 | laufVergessen(fenster.webContents); |
| 354 | if (fenster.isDestroyed()) { |
| 355 | return; |
| 356 | } |
| 357 | |
| 358 | const jetzt = Date.now(); |
| 359 | const antwort = absturzAntwort(einzelheiten.reason, neuladeVersuche, jetzt - letzterAbsturz); |
| 360 | if (antwort === 'ruhen') { |
| 361 | return; |
| 362 | } |
| 363 | |
| 364 | if (jetzt - letzterAbsturz > SCHLEIFENFENSTER_MS) { |
| 365 | neuladeVersuche = 0; |
| 366 | } |
| 367 | letzterAbsturz = jetzt; |
| 368 | const spur = `Renderer beendet (${einzelheiten.reason}, Code ${String(einzelheiten.exitCode)}), Antwort: ${antwort}`; |
| 369 | console.error(`[fenster] ${spur}`); |
| 370 | protokollieren('fenster', spur); |
| 371 | |
| 372 | if (antwort === 'neu-laden') { |
| 373 | neuladeVersuche += 1; |
| 374 | fenster.webContents.reload(); |
| 375 | return; |
| 376 | } |
| 377 | |
| 378 | /* Nach dem zweiten vergeblichen Versuch nicht stumm bleiben. Ein nativer |
| 379 | Dialog geht auch ohne lebende Oberfläche auf und wird vorgelesen. */ |
| 380 | dialog.showErrorBox( |
| 381 | 'Waffensachkunde – Lernsoftware', |
| 382 | [ |
| 383 | 'Die Anzeige ist mehrfach hintereinander abgestürzt und wurde nicht erneut geladen.', |
| 384 | 'Ihr Lernstand ist davon nicht betroffen; er liegt als Datei auf Ihrer Festplatte, und ein laufender Prüfungsbogen lässt sich beim nächsten Start fortsetzen.', |
| 385 | 'Bitte beenden Sie das Programm und starten Sie es neu. Bleibt es dabei, melden Sie es bitte.', |
| 386 | ].join('\n\n'), |
| 387 | ); |
| 388 | }); |
| 389 | |
| 390 | fenster.webContents.on('destroyed', () => { |
| 391 | laufVergessen(fenster.webContents); |
| 392 | }); |
| 393 | |
| 394 | /* Kein zweiter setWindowOpenHandler an dieser Stelle: `sicherheit.ts` |
| 395 | setzt ihn bereits für jeden neu entstehenden `webContents`, und der |
| 396 | zuletzt gesetzte gewinnt. Zwei Fassungen derselben Regel wären eine |
| 397 | Einladung, nur eine davon zu verschärfen. */ |
| 398 | |
| 399 | const devServerUrl = process.env['ELECTRON_RENDERER_URL']; |
| 400 | if (devServerUrl) { |
| 401 | void fenster.loadURL(devServerUrl); |
| 402 | } else { |
| 403 | void fenster.loadFile(oberflaechenDatei()); |
| 404 | } |
| 405 | |
| 406 | return fenster; |
| 407 | } |