/** * Erzeugung des Hauptfensters mit gehärteten `webPreferences`. */ import { join } from 'node:path'; import { BrowserWindow, dialog, nativeTheme, screen } from 'electron'; import { FENSTER_MINDESTBREITE, FENSTER_MINDESTHOEHE, startlage, type Fensterlage, } from '../shared/fensterlage'; import { startfarbeFuer } from '../shared/theme'; import { anwendenAuf } from './anzeige'; import { einstellungenLesen, einstellungenSchreiben } from './einstellungen'; import { laeuftPruefung, laufVergessen } from './laufwaechter'; import { oberflaechenDatei } from './oberflaeche'; import { protokollieren } from './protokoll'; /** * Wie lange der erste Frame auf sich warten lassen darf. * * Danach wird das Fenster auch ohne ihn gezeigt. Vier Sekunden sind reichlich * über dem, was ein gesunder Start braucht — gemessen liegt `ready-to-show` * hier deutlich unter einer Sekunde —, und kurz genug, dass niemand vor einem * leeren Bildschirm sitzt und sich fragt, ob er zweimal geklickt hat. */ const FRIST_MS = 4000; /** * Hintergrundfarbe des Fensters – verhindert ein Aufblitzen beim Start. * * Maßgeblich ist die **gespeicherte** Wahl aus `einstellungen.json`, nicht * das Systemdesign: Bis 0.21.0 fragte diese Stelle nur `nativeTheme`, und wer * „Dunkel“ eingestellt hatte, während Windows hell läuft, sah bei jedem Start * kurz eine helle Fläche. Der Hauptprozess liest die Datei synchron und weiß * die Antwort damit, bevor der erste Bildpunkt fällt – derselbe Weg, den die * Anzeigegröße geht. * * `system` bleibt `system`: Dann entscheidet weiterhin `nativeTheme`, und * `themeAufloesen` rechnet das mit denselben Regeln aus wie der Renderer. */ function startHintergrund(): string { return startfarbeFuer(einstellungenLesen().thema, { bevorzugtDunkel: nativeTheme.shouldUseDarkColors, erzwungeneFarben: nativeTheme.shouldUseHighContrastColors, /* `prefers-contrast: more` meldet Electron nicht getrennt; unter erzwungenen Farben ist die Frage ohnehin beantwortet. */ bevorzugtMehrKontrast: false, }); } /** Wie oft ein gestorbener Renderer selbsttätig neu geladen wird. */ export const NEULADEVERSUCHE = 2; /** * Ab dieser Ruhe gilt ein Absturz als neuer Vorfall statt als Schleife. * * Ohne diese Frist zählte ein Absturz aus der vorigen Woche noch mit, und * das dritte Vorkommnis in einem Jahr bekäme kein Neuladen mehr. */ export const SCHLEIFENFENSTER_MS = 60_000; /** Was nach dem Tod eines Renderer-Prozesses zu tun ist. */ export type Absturzantwort = 'ruhen' | 'neu-laden' | 'aufgeben'; /** * Entscheidet über die Antwort auf einen gestorbenen Renderer. * * Als reine Funktion herausgezogen, weil an ihr das Verhalten hängt und ein * echter Absturz sich nicht auf Bestellung herstellen lässt. * * `clean-exit` und `killed` sind kein Absturz: Das erste ist das gewöhnliche * Beenden, das zweite ein Abschuss von außen. Beide neu zu laden hieße, ein * geschlossenes Fenster wieder aufzumachen. */ export function absturzAntwort( grund: string, versucheBisher: number, seitLetztemAbsturzMs: number, ): Absturzantwort { if (grund === 'clean-exit' || grund === 'killed') { return 'ruhen'; } const versuche = seitLetztemAbsturzMs > SCHLEIFENFENSTER_MS ? 0 : versucheBisher; return versuche < NEULADEVERSUCHE ? 'neu-laden' : 'aufgeben'; } export function hauptfensterErstellen(): BrowserWindow { /* Mindestbreite: 360 statt der früheren 720. 720 Pixel waren die zweite Hälfte des Reflow-Problems (WCAG 1.4.10). Das Kriterium fragt, ob sich der Inhalt bei 320 CSS-Pixeln ohne seitliches Rollen benutzen lässt. Mit 720 Pixeln Mindestbreite und einer Zoomleiter bis 200 Prozent war der schmalste erreichbare Bereich 360 CSS-Pixel – der Prüffall ließ sich nicht einmal herstellen. Die Zoomleiter reicht jetzt bis 400 Prozent und stellt den Fall auf einem gewöhnlichen Fenster her (1280 / 4 = 320). Die Mindestbreite fällt trotzdem: Wer das Fenster neben ein anderes stellt oder mit einer Bildschirmlupe arbeitet, will es schmal ziehen können. 360 und nicht 320, weil `minWidth` die Fenster- und nicht die Inhaltsbreite ist – der Rahmen kostet unter Windows ein gutes Dutzend Pixel, und bei 100 Prozent Anzeige sollen 320 CSS-Pixel Inhalt übrig bleiben. Dass das Layout bei 320 Pixeln trägt, ist gemessen: einspaltig, kein seitliches Rollen, nichts abgeschnitten (`e2e/anzeigegroesse.spec.ts`). */ /* Größe und Lage der letzten Sitzung. Gegen die vorhandenen Bildschirme geprüft (`shared/fensterlage.ts`): Eine Lage auf einem abgesteckten zweiten Monitor ergäbe ein Fenster, das aufgeht und unsichtbar bleibt – und ohne Fenster gibt es keinen Weg, die Einstellung zurückzusetzen. */ const lage = startlage( einstellungenLesen().fenster, screen.getAllDisplays().map((anzeige) => anzeige.workArea), ); const fenster = new BrowserWindow({ width: lage.breite, height: lage.hoehe, ...(lage.x === undefined || lage.y === undefined ? {} : { x: lage.x, y: lage.y }), minWidth: FENSTER_MINDESTBREITE, minHeight: FENSTER_MINDESTHOEHE, show: false, backgroundColor: startHintergrund(), title: 'Waffensachkunde – Lernsoftware', autoHideMenuBar: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), // ── Sicherheitsgrundlagen (nicht verhandelbar) ────────────────── contextIsolation: true, nodeIntegration: false, sandbox: true, // ── Ergänzende Härtung ────────────────────────────────────────── nodeIntegrationInWorker: false, nodeIntegrationInSubFrames: false, webSecurity: true, allowRunningInsecureContent: false, experimentalFeatures: false, webviewTag: false, // Die Rechtschreibprüfung lädt Wörterbücher aus dem Netz nach – die // Anwendung soll vollständig offline funktionieren. spellcheck: false, }, }); /* Die gespeicherte Anzeigegröße muss anliegen, BEVOR gezeichnet wird – sonst blitzt die Anwendung beim Start in der falschen Größe auf. Der Zoomfaktor hängt an den webContents und wird nicht vererbt; jedes neue Fenster braucht ihn erneut. */ fenster.webContents.on('did-finish-load', () => { anwendenAuf(fenster.webContents); /* Ein frisch geladenes Dokument prüft nicht. Der Renderer meldet seinen Zustand danach ohnehin selbst; das hier ist die Absicherung dagegen, dass ein Merker ein Neuladen überlebt. */ laufVergessen(fenster.webContents); }); /* Zeigen, sobald der erste Frame steht — und notfalls auch ohne ihn. `ready-to-show` ist der richtige Zeitpunkt: Vorher zeigte das Fenster einen weißen Blitz. Aber es ist keine Zusage, die immer eingelöst wird. **Was am 29.08.2026 passiert ist.** Seit 0.23.0 merkt sich die Anwendung Größe und Lage des Fensters. Sobald eine Position gespeichert war und beim Start wieder angelegt wurde, starb auf einem Windows-11-Rechner der GPU-Prozess (`GPU process exited unexpectedly: exit_code=-1`), danach der Renderer. Ohne ersten Frame feuerte `ready-to-show` nie — und das Fenster blieb für immer unsichtbar. Nachgemessen: Das Fenster existierte, saß an der gespeicherten Stelle und war vollständig geladen; nur zeigte es niemand. Vier von vier Versuchen, mit jeder Position, auch mit 0/0. Ohne gespeicherte Position trat es nie auf. Für den Anwender heißt das: Die Anwendung startet einmal, er verschiebt das Fenster, und danach lässt sie sich nie wieder öffnen — es erscheint nichts, es meldet nichts, und an die Einstellung kommt er nicht heran, weil dafür ein Fenster nötig wäre. Genau der Fall, den der Kommentar über `startlage()` befürchtet, nur aus einer anderen Richtung. Die Rückfallebene ist deshalb nicht Kosmetik: Ein Fenster ohne ersten Frame ist unschön, ein Programm ohne Fenster ist unbenutzbar. Nach `did-finish-load` — der Punkt, an dem die Oberfläche geladen ist — bekommt der erste Frame noch eine Frist; verstreicht sie, wird trotzdem gezeigt und der Vorfall ins Fehlerprotokoll geschrieben. */ let gezeigt = false; const zeigen = (grund: 'ready-to-show' | 'notbremse'): void => { if (gezeigt || fenster.isDestroyed()) { return; } gezeigt = true; anwendenAuf(fenster.webContents); /* Maximieren vor dem Zeigen: Andersherum blitzte das Fenster einen Augenblick in seiner nicht maximierten Größe auf. */ if (lage.maximiert === true) { fenster.maximize(); } fenster.show(); if (grund === 'notbremse') { const spur = `Fenster ohne ersten Frame gezeigt — ready-to-show blieb ${String(FRIST_MS)} ms aus.`; console.warn(`[fenster] ${spur}`); protokollieren('fenster', spur); } }; fenster.once('ready-to-show', () => { zeigen('ready-to-show'); }); fenster.webContents.once('did-finish-load', () => { setTimeout(() => { zeigen('notbremse'); }, FRIST_MS); }); // Der Renderer darf den Fenstertitel nicht überschreiben. fenster.on('page-title-updated', (ereignis) => { ereignis.preventDefault(); }); /* Größe und Lage merken. Gemessen wird `getNormalBounds()` und nicht `getBounds()`: Ein maximiertes Fenster meldet sonst die Bildschirmgröße, und wer die Maximierung einmal aufhebt, säße vor einem Fenster, das den ganzen Schirm füllt, ohne maximiert zu sein — die vorige, bewusst gewählte Größe wäre weg. Entprellt, weil `resize` und `move` beim Ziehen im Dutzend feuern: Ohne Verzögerung schriebe jeder Pixel eine Datei. Und beim Schließen noch einmal ohne Verzögerung, damit die letzte Änderung nicht verfällt. */ let schreibuhr: NodeJS.Timeout | null = null; const lageMerken = (): void => { if (fenster.isDestroyed()) { return; } const bounds = fenster.getNormalBounds(); const neu: Fensterlage = { breite: bounds.width, hoehe: bounds.height, x: bounds.x, y: bounds.y, ...(fenster.isMaximized() ? { maximiert: true } : {}), }; try { einstellungenSchreiben({ fenster: neu }); } catch (fehler: unknown) { /* Eine nicht gemerkte Fenstergröße ist ein Schönheitsfehler; das Beenden daran scheitern zu lassen wäre der schlechtere Handel. */ console.warn('[fenster] Lage konnte nicht gemerkt werden:', fehler); } }; const lageSpaeterMerken = (): void => { if (schreibuhr !== null) { clearTimeout(schreibuhr); } schreibuhr = setTimeout(lageMerken, 400); }; fenster.on('resize', lageSpaeterMerken); fenster.on('move', lageSpaeterMerken); fenster.on('maximize', lageSpaeterMerken); fenster.on('unmaximize', lageSpaeterMerken); fenster.on('close', () => { if (schreibuhr !== null) { clearTimeout(schreibuhr); schreibuhr = null; } lageMerken(); }); /* Mitten in der Prüfung wird nachgefragt. Ein Bogen läuft bis zu zwei Stunden. Er ist zwar gesichert und lässt sich fortsetzen – aber ein Fenster, das auf Alt+F4 wortlos zugeht, während jemand bei Frage 63 sitzt, ist trotzdem ein Schrecken. Die Frage kostet einen Tastendruck und nimmt ihn weg. Bewusst `showMessageBoxSync`: Der Beschluss muss vorliegen, bevor dieser Behandler zurückkehrt. Ein asynchroner Dialog käme zu spät – das Fenster wäre längst zu. Native Dialoge liest der Screenreader; die Tastatur bedient sie ohnehin. `cancelId` zeigt ausdrücklich auf „Weiter prüfen", nicht über die Reihenfolge: Escape soll nichts tun, unabhängig davon, wie die Knöpfe später einmal angeordnet sind. */ fenster.on('close', (ereignis) => { if (!laeuftPruefung(fenster)) { return; } const wahl = dialog.showMessageBoxSync(fenster, { type: 'question', buttons: ['Weiter prüfen', 'Beenden'], defaultId: 0, cancelId: 0, noLink: true, title: 'Prüfungssimulation läuft', message: 'Es läuft eine Prüfungssimulation. Wirklich beenden?', detail: 'Ihr Bogen bleibt erhalten. Beim nächsten Start können Sie ihn fortsetzen; ' + 'die Uhr steht so lange still.', }); if (wahl === 0) { ereignis.preventDefault(); } else { laufVergessen(fenster.webContents); } }); /* Ein gestorbener Renderer hinterließ ein totes, leeres Fenster. Der Fehlerauffang der Oberfläche (`Fehlerauffang.tsx`) fängt Ausnahmen im Renderbaum und zeigt eine Fehlerseite. Stirbt aber der Renderer-**Prozess** selbst – Speichermangel, Grafiktreiber –, kommt er nicht mehr zum Zuge: Das Fenster bleibt weiß, ohne Text, ohne Fokus, ohne Ansage. Für jemanden am Screenreader ist das nichts, worüber sich etwas sagen ließe. Ein Ausweg bestand zwar (das Anwendungsmenü bleibt bedienbar, „Ansicht → Neu laden“), aber niemand wusste davon. Neuladen ist verlustfrei: Der Prüfungsbogen wird im Sekundentakt gesichert und der Lernstand liegt ohnehin in der Datenbank. Deshalb wird es getan, statt danach zu fragen – und höchstens {@link NEULADEVERSUCHE} Mal, damit aus einem Absturz beim Laden keine Schleife wird, die sich nicht mehr beenden lässt. Der Merker der laufenden Prüfung fällt in jedem Fall: Sonst hinge das Fenster an einer Rückfrage zu einer Prüfung, die es nicht mehr gibt. */ let neuladeVersuche = 0; let letzterAbsturz = 0; fenster.webContents.on('render-process-gone', (_ereignis, einzelheiten) => { laufVergessen(fenster.webContents); if (fenster.isDestroyed()) { return; } const jetzt = Date.now(); const antwort = absturzAntwort(einzelheiten.reason, neuladeVersuche, jetzt - letzterAbsturz); if (antwort === 'ruhen') { return; } if (jetzt - letzterAbsturz > SCHLEIFENFENSTER_MS) { neuladeVersuche = 0; } letzterAbsturz = jetzt; const spur = `Renderer beendet (${einzelheiten.reason}, Code ${String(einzelheiten.exitCode)}), Antwort: ${antwort}`; console.error(`[fenster] ${spur}`); protokollieren('fenster', spur); if (antwort === 'neu-laden') { neuladeVersuche += 1; fenster.webContents.reload(); return; } /* Nach dem zweiten vergeblichen Versuch nicht stumm bleiben. Ein nativer Dialog geht auch ohne lebende Oberfläche auf und wird vorgelesen. */ dialog.showErrorBox( 'Waffensachkunde – Lernsoftware', [ 'Die Anzeige ist mehrfach hintereinander abgestürzt und wurde nicht erneut geladen.', '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.', 'Bitte beenden Sie das Programm und starten Sie es neu. Bleibt es dabei, melden Sie es bitte.', ].join('\n\n'), ); }); fenster.webContents.on('destroyed', () => { laufVergessen(fenster.webContents); }); /* Kein zweiter setWindowOpenHandler an dieser Stelle: `sicherheit.ts` setzt ihn bereits für jeden neu entstehenden `webContents`, und der zuletzt gesetzte gewinnt. Zwei Fassungen derselben Regel wären eine Einladung, nur eine davon zu verschärfen. */ const devServerUrl = process.env['ELECTRON_RENDERER_URL']; if (devServerUrl) { void fenster.loadURL(devServerUrl); } else { void fenster.loadFile(oberflaechenDatei()); } return fenster; }