waffensachkunde

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

/ app src main fenster.ts

15,4 KB Rohdatei
app/src/main/fenster.ts — 407 Zeilen
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 }