import { BrowserWindow, Menu, app, dialog, ipcMain, nativeTheme, session, shell, systemPreferences, type SaveDialogOptions, } from 'electron'; import { open, readFile, rename, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { entscheideNavigation } from './navigation'; import { verweisadresse } from './verweise'; import { IPC, type ErrorReport, type FileDialogFilter, type KartenAnfrage, type MenuCommand, type OpenFileResult, type SaveFileRequest, type SaveFileResult, } from '../shared/ipc'; import { buildMenu, schriftgroesseMenueId, type SchriftgroessenSteuerung } from './menu'; import { ANSICHT_DATEINAME, SCHRIFT_STANDARD, gueltigeStufe, liesSchriftgroesse, nachbarstufe, schriftsicherung, type Schriftsicherung, } from './schriftgroesse'; import { fensterGrundfarbe } from './fenstergrund'; import { erlaubteRechner, holeKartenbild } from './karte'; import { istProtokolldatei, liesProtokolldatei, protokollDateiname, protokolliere, protokolliereMeldung, protokolliereStart, protokollkopie, protokollPfad, } from './protokoll'; /** * Electron-Hauptprozess. * * Sicherheitsgrundsaetze: * - Der Renderer erhaelt keinen freien Dateizugriff. Geschrieben und gelesen * werden nur Pfade, die der Anwender zuvor in einem Dialog bestaetigt hat. * - Keine Navigation aus der Anwendung heraus, keine neuen Fenster. * - Eine Inhaltssicherheitsrichtlinie wird zusaetzlich als Antwortkopf gesetzt, * damit sie auch dann greift, wenn das Meta-Element im Dokument fehlt. * - Berechtigungsanfragen (Kamera, Standort, Benachrichtigungen) werden * grundsaetzlich abgelehnt; die Anwendung benoetigt keine davon. */ const isDevelopment = !app.isPackaged; const RENDERER_DIR = path.join(__dirname, '..', 'renderer'); const RENDERER_INDEX = path.join(RENDERER_DIR, 'index.html'); let mainWindow: BrowserWindow | null = null; let rendererIsDirty = false; let closeApproved = false; let pendingCloseResolve: ((allow: boolean) => void) | null = null; /** * Die laufende Rueckfrage ans Fenster, solange eine laeuft. * * Ein zweiter Klick auf das Schliesskreuz bekommt dieselbe Zusage zurueck, * statt eine zweite Rueckfrage zu stellen (siehe `askRendererToClose`). */ let pendingClosePromise: Promise | null = null; /** * Eingestellte Schriftgroesse in Prozent (Befund L11). Wird vor dem ersten * Fenster aus dem Benutzerdatenverzeichnis gelesen; naeheres in * electron/schriftgroesse.ts. */ let schriftgroesse = SCHRIFT_STANDARD; /** Das gebaute Anwendungsmenue, um die Auswahlmarke der Stufe nachzufuehren. */ let anwendungsmenue: Menu | null = null; /** * Pfade, die der Anwender in einem Dialog bestaetigt hat. * Nur auf diese darf der Renderer spaeter ohne erneuten Dialog zugreifen. */ const approvedPaths = new Set(); function approvePath(filePath: string): string { const resolved = path.resolve(filePath); approvedPaths.add(resolved); return resolved; } function isApproved(filePath: string): boolean { return approvedPaths.has(path.resolve(filePath)); } // --- Darstellung des Fensters ----------------------------------------------- /** * Grundfarbe des Fensters (Befund L12). * * Zwei Eingangsgroessen, weil Windows zwei voneinander unabhaengige Schalter * fuehrt: den fuer App-Farben (`shouldUseDarkColors`) und das Kontrastdesign. * Die beiden Farbwerte und die Begruendung stehen in electron/fenstergrund.ts. * * WAS DIESE LOESUNG NICHT KANN: Sie folgt dem Betriebssystem. Hat der Anwender * in den Vorgaben ausdruecklich "Hell" gewaehlt, waehrend das System dunkel * steht, ist der Ton eine Bildfolge lang falsch herum. Diese Wahl liegt in * `AppSettings` im Anzeigeprozess und ist von hier aus nicht lesbar. Der haeufige * Fall - Voreinstellung "System" plus dunkles Betriebssystem - ist damit behoben, * der seltene bleibt; das Fenster wird ohnehin erst bei `ready-to-show` gezeigt. */ function grundfarbe(): string { return fensterGrundfarbe(nativeTheme.shouldUseDarkColors, kontrastgrund()); } /** * Fenstergrundfarbe des Kontrastdesigns, sonst `null`. * * `systemPreferences.getColor` gibt es nur unter Windows und macOS. Ein Wurf * darf den Start nicht anhalten - dann gilt weiter die Anwendungsfarbe. */ function kontrastgrund(): string | null { if (!nativeTheme.shouldUseHighContrastColors) return null; try { return systemPreferences.getColor('window'); } catch { return null; } } function beobachteErscheinungsbild(): void { // Wechselt das Betriebssystem waehrend des Betriebs, gilt die neue Farbe beim // naechsten Neuzeichnen des Fensterrahmens und beim naechsten "Neu laden". nativeTheme.on('updated', () => { mainWindow?.setBackgroundColor(grundfarbe()); }); } /** Ort der gesicherten Ansichtseinstellungen. */ function ansichtsDatei(): string { return path.join(app.getPath('userData'), ANSICHT_DATEINAME); } /** * Setzt die Schriftgroesse, sichert sie und fuehrt die Auswahlmarke im Menue nach * (Befund L11). * * Genau eine Buchfuehrung: Auswahlliste, "Vergroessern/Verkleinern" und * Strg+Mausrad laufen alle hier durch. */ /** * Verzoegerte Sicherung der Stufe (Begruendung bei `Schriftsicherung` in * electron/schriftgroesse.ts). * * Wird beim Start eingerichtet, sobald die gesicherte Stufe gelesen ist. Bis * dahin - und in Tests, die nur das Menue bauen - bleibt sie ungesetzt; die * Stufe wirkt dann, wird aber nicht geschrieben. */ let schriftSicherung: Schriftsicherung | null = null; function setzeSchriftgroesse(prozent: number): void { schriftgroesse = gueltigeStufe(prozent); wendeSchriftgroesseAn(); // Nicht bei jeder Mausradraste auf den Datentraeger: Die Sicherung wartet die // Ruhezeit ab und schreibt nur, wenn sich der Wert wirklich geaendert hat. schriftSicherung?.plane(schriftgroesse); const eintrag = anwendungsmenue?.getMenuItemById(schriftgroesseMenueId(schriftgroesse)); if (eintrag) eintrag.checked = true; } function wendeSchriftgroesseAn(): void { mainWindow?.webContents.setZoomFactor(schriftgroesse / 100); } const schriftgroessenSteuerung: SchriftgroessenSteuerung = { aktuell: () => schriftgroesse, setze: (prozent) => { setzeSchriftgroesse(prozent); }, schritt: (richtung) => { setzeSchriftgroesse(nachbarstufe(schriftgroesse, richtung)); }, }; // --- Fenster ---------------------------------------------------------------- function createWindow(): void { mainWindow = new BrowserWindow({ width: 1440, height: 920, minWidth: 960, minHeight: 640, show: false, backgroundColor: grundfarbe(), icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'), title: 'LSA-Planer Professional', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false, spellcheck: false, // Schon beim Aufbau des Fensters, damit die erste Darstellung in der // gewaehlten Groesse erscheint und nicht sichtbar von 100 % dorthin // springt (Befund L11). zoomFactor: schriftgroesse / 100, }, }); anwendungsmenue = buildMenu(sendMenuCommand, isDevelopment, schriftgroessenSteuerung, () => { void speichereProtokollkopie(); }); Menu.setApplicationMenu(anwendungsmenue); void mainWindow.loadFile(RENDERER_INDEX); // Der Vergroesserungsgrad haengt am geladenen Dokument und faellt bei jeder // Navigation auf 1 zurueck - auch beim "Neu laden" und bei dem Neustart, den // `render-process-gone` weiter unten ausloest. Ohne diese Zeile waere die // Einstellung nach jedem Absturz wieder weg, also genau der Zustand, den // Befund L11 ruegt. mainWindow.webContents.on('did-finish-load', () => { wendeSchriftgroesseAn(); }); // Strg+Mausrad geht durch dieselbe Stufenliste wie das Menue und wird ebenso // gesichert; sonst zeigte die Auswahlmarke auf eine Stufe, die nicht gilt. mainWindow.webContents.on('zoom-changed', (_event, richtung) => { setzeSchriftgroesse(nachbarstufe(schriftgroesse, richtung === 'in' ? 1 : -1)); }); mainWindow.once('ready-to-show', () => { mainWindow?.show(); }); // Fehler aus dem Renderer sichtbar machen. Ohne diese Weiterleitung bleibt // ein Fehler im Auslieferungsstand unbemerkt, weil es dort keine geoeffneten // Entwicklerwerkzeuge gibt. Verstoesse gegen die Inhaltssicherheitsrichtlinie // erscheinen hier ebenfalls. mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => { if (level < 2) return; protokolliere('Anzeige/Konsole', `${message} (${sourceId}:${line})`); }); mainWindow.webContents.on('render-process-gone', (_event, details) => { // `exitCode` steht in Electrons Typbeschreibung als Zahl; der Linter haelt // die Abfrage deshalb fuer ueberfluessig. Sie bleibt: Hier wird ein // Absturz protokolliert, und das Protokoll ist alles, was von ihm uebrig // bleibt. Fehlt das Feld einmal, steht sonst "Beendigungscode undefined" // in der Zeile, die die Untersuchung tragen soll. protokolliere( 'Anzeige/Prozessende', // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld kann zur Laufzeit fehlen; siehe darueber `Grund: ${details.reason}${details.exitCode !== undefined ? ` · Beendigungscode ${details.exitCode}` : ''}`, ); // Der Menueweg ist hier erreichbar: Das Menue gehoert dem Hauptprozess // und nicht der abgestuerzten Anzeige, und das Fenster bleibt stehen und // wird nur neu geladen. Siehe `HINWEIS_PROTOKOLLKOPIE`. dialog.showErrorBox( 'Die Anzeige wurde unerwartet beendet', 'Die Anwendung wird neu geladen. Der zuletzt gesicherte Stand bleibt erhalten.\n\n' + `Einzelheiten stehen im Fehlerprotokoll:\n${protokollPfad()}\n\n` + HINWEIS_PROTOKOLLKOPIE, ); mainWindow?.reload(); }); mainWindow.webContents.on('did-fail-load', (_event, code, description) => { protokolliere('Anzeige/Laden', `Fehlgeschlagen (${code}): ${description}`); }); // Ein abgestuerzter Hilfsprozess (GPU, Netzdienst) beendet die Anwendung nicht, // erklaert aber spaetere Auffaelligkeiten. mainWindow.webContents.on('unresponsive', () => { protokolliere('Anzeige', 'Die Anzeige antwortet nicht mehr.'); }); mainWindow.webContents.on('responsive', () => { protokolliere('Anzeige', 'Die Anzeige antwortet wieder.'); }); /* * Kein Verlassen der Anwendung im eigenen Fenster. * * Die Entscheidung selbst steht in `entscheideNavigation` (navigation.ts) - * als reine Funktion, damit sie sich erschoepfend pruefen laesst, ohne ein * Fenster zu oeffnen und ohne einen Browser zu starten. Hier steht nur noch * die Verdrahtung. * * `setWindowOpenHandler` prueft damit dasselbe wie `will-navigate`; bis * hierher fragte er `url.startsWith('https://')` und damit die Zeichenkette * statt der ausgewerteten Adresse - eine schwaechere Pruefung an derselben * Grenze. */ const erlaubteSeite = pathToFileURL(RENDERER_INDEX).toString(); mainWindow.webContents.on('will-navigate', (event, url) => { const entscheidung = entscheideNavigation(url, erlaubteSeite); if (entscheidung === 'laden') return; event.preventDefault(); if (entscheidung === 'extern') void shell.openExternal(url); }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { // Ein neues Fenster gibt es nie - hoechstens den Standardbrowser. if (entscheideNavigation(url, erlaubteSeite) === 'extern') void shell.openExternal(url); return { action: 'deny' }; }); // Vor dem Schliessen beim Renderer nachfragen, solange Aenderungen bestehen. mainWindow.on('close', (event) => { if (closeApproved || !rendererIsDirty || mainWindow === null) return; event.preventDefault(); void askRendererToClose().then((allow) => { if (!allow) return; closeApproved = true; mainWindow?.close(); }); }); mainWindow.on('closed', () => { mainWindow = null; }); } /** * Fragt den Anzeigeprozess, ob trotz ungespeicherter Aenderungen geschlossen * werden darf. * * WARUM AUF DIE ANTWORT KEINE FRIST LIEGT * * Die Rueckfrage beantwortet ein MENSCH: Der Anzeigeprozess zeigt * "Ungespeicherte Änderungen" mit drei Schaltflaechen und sendet erst, wenn * eine davon gewaehlt ist - beim Weg "Speichern und schließen" sogar erst nach * dem Sichern, das bei einem noch nie gesicherten Projekt einen Dateidialog * oeffnet. Eine Frist auf die Antwort misst also die Bedenkzeit des Anwenders * und nicht die Antwortfaehigkeit der Anzeige. * * Bis 5.8.0 stand hier eine Frist von fuenf Sekunden. Wer laenger las, bekam * eine zweite, modale Meldung "Die Anwendung antwortet nicht." ueber den noch * offenen Dialog gelegt, mit dem Angebot, die ungespeicherte Arbeit * wegzuwerfen; und weil der Wartezustand dabei geloescht wurde, verpuffte die * spaeter eintreffende echte Antwort - das Fenster liess sich trotz * beantworteter Rueckfrage nicht mehr schliessen. * * WORAN DER AUSWEG STATTDESSEN HAENGT * * Ohne einen Ausweg liesse sich ein haengendes Fenster nicht mehr schliessen. * Er bleibt, haengt aber an einem Lebenszeichen statt an einer Uhr: an * `isCrashed()` vor dem Senden und am Ereignis `unresponsive` waehrend des * Wartens. Nur dann behauptet die Meldung, die Anwendung antworte nicht - und * dann stimmt es auch. * * Solange eine Rueckfrage laeuft, wird keine zweite gestellt: Ein zweiter Klick * auf das Schliesskreuz bekommt dieselbe Zusage. Sonst stapelten sich * Wartezustaende, von denen nur der letzte je beantwortet wurde. */ function askRendererToClose(): Promise { if (mainWindow === null) return Promise.resolve(true); if (pendingClosePromise !== null) return pendingClosePromise; const fenster = mainWindow; const inhalt = fenster.webContents; // Ist die Anzeige schon fort, kann sie nicht mehr gefragt werden - und ein // modaler Kasten ueber einem verschwindenden Fenster hilft niemandem. if (inhalt.isDestroyed()) return Promise.resolve(true); // Eine abgestuerzte Anzeige wird gar nicht erst befragt. Das steht VOR dem // Erzeuger, nicht darin: Ein synchroner Abschluss innerhalb des Erzeugers // wuerde von `pendingClosePromise = versprechen` ueberholt, und das Feld // hielte danach eine bereits aufgeloeste Zusage. Jeder weitere Klick auf das // Schliesskreuz bekaeme sie zurueck - nach einmaligem "Abbrechen" liesse sich // das Fenster nie wieder schliessen. if (inhalt.isCrashed()) return Promise.resolve(frageObTrotzdemGeschlossenWird(fenster)); const versprechen = new Promise((resolve) => { const wache: { abmelden: () => void } = { abmelden: () => undefined }; const fertig = (allow: boolean): void => { wache.abmelden(); pendingCloseResolve = null; pendingClosePromise = null; resolve(allow); }; const keineAntwort = (): void => { fertig(frageObTrotzdemGeschlossenWird(fenster)); }; pendingCloseResolve = fertig; inhalt.on('unresponsive', keineAntwort); wache.abmelden = () => { inhalt.removeListener('unresponsive', keineAntwort); }; inhalt.send(IPC.requestClose); }); pendingClosePromise = versprechen; return versprechen; } /** * Letzter Ausweg, wenn die Anzeige nachweislich nicht mehr antwortet. * * Nur von `askRendererToClose` aus erreichbar, und dort nur bei fehlendem * Lebenszeichen. `defaultId` und `cancelId` stehen beide auf "Abbrechen": * Eingabe- und Fluchttaste verwerfen nichts. */ function frageObTrotzdemGeschlossenWird(fenster: BrowserWindow): boolean { return ( dialog.showMessageBoxSync(fenster, { type: 'warning', buttons: ['Abbrechen', 'Trotzdem schließen'], defaultId: 0, cancelId: 0, title: 'Anwendung antwortet nicht', message: 'Die Anwendung antwortet nicht.', detail: 'Beim Schließen gehen ungespeicherte Änderungen verloren.', }) === 1 ); } function sendMenuCommand(command: MenuCommand): void { mainWindow?.webContents.send(IPC.menuCommand, command); } // --- Sicherheitsvorgaben der Sitzung ---------------------------------------- function hardenSession(): void { const defaultSession = session.defaultSession; defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': [ "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' blob:; " + "base-uri 'none'; form-action 'none'; object-src 'none'; frame-ancestors 'none'", ], 'X-Content-Type-Options': ['nosniff'], }, }); }); // Die Anwendung braucht keinerlei Geraeteberechtigungen. defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => { callback(false); }); defaultSession.setPermissionCheckHandler(() => false); } // --- Datei-Schnittstelle ---------------------------------------------------- /** * Ein Fehler, dessen Meldung bereits fuer den Anwender geschrieben ist. * * `describe` gibt sie unveraendert weiter, statt einen zweiten Satz darum zu * legen. */ class AnwenderFehler extends Error {} /** * Obergrenze einer eingelesenen Projektdatei. * * WARUM ES SIE UEBERHAUPT BRAUCHT * * `readFile(..., 'utf8')` baut eine EINZIGE Zeichenkette auf. V8 laesst * hoechstens 0x1fffffe8 Zeichen zu, also rund 512 MiB; darueber wirft der Aufruf * einen `RangeError` mit der Meldung "Invalid string length" und OHNE `code`. * Ohne vorgelagerte Pruefung stand dieser englische Satz woertlich auf dem * Bildschirm, und der Anwender erfuhr nicht, dass die Datei schlicht zu gross * ist. * * WARUM 256 MiB * * Die Schranke soll nur fangen, was nie eine Planunterlage dieses Programms * gewesen sein kann - eine zu enge Grenze wuerde eine lesbare Datei abweisen, * und das waere der schlimmere Fehler. Das groesste Stueck, das dieses Programm * schreibt, ist das eingebettete Hintergrundbild: MAX_BILD_ZEICHEN in * src/domain/model/schema.ts laesst 33,55 Mio Zeichen zu, also 32 MiB. 256 MiB * sind das Achtfache und lassen daneben reichlich Raum fuer die Plandaten. * * Zugleich liegt die Grenze sicher unter der von V8: In UTF-8 traegt jedes * Zeichen mindestens ein Byte, 256 MiB Datei ergeben also hoechstens * 268.435.456 Zeichen - die Haelfte dessen, was V8 zulaesst. Was diese Pruefung * durchlaesst, scheitert nicht mehr an der Zeichenkette. */ const MAX_PROJEKTDATEI_BYTES = 256 * 1024 * 1024; /** Byte in MB mit Dezimalkomma - die Meldung liest ein Anwender. */ function megabyte(bytes: number, nachkommastellen: number): string { return (bytes / (1024 * 1024)).toFixed(nachkommastellen).replace('.', ','); } /** * Weist eine Datei ab, die zu gross ist, um als Zeichenkette gelesen zu werden. * * Geprueft wird VOR dem Lesen; die Meldung nennt die tatsaechliche und die * zulaessige Groesse, so wie es die Meldungen zum Hintergrundbild und zum * Kartenabruf ebenfalls tun. */ async function pruefeLesbareGroesse(pfad: string): Promise { const { size } = await stat(pfad); if (size <= MAX_PROJEKTDATEI_BYTES) return null; return ( `Die Datei belegt ${megabyte(size, 1)} MB und liegt damit über der Grenze von ` + `${megabyte(MAX_PROJEKTDATEI_BYTES, 0)} MB; sie wurde nicht gelesen. Eine Planunterlage ` + 'dieser Größe kann dieses Programm nicht geschrieben haben - bitte prüfen Sie, ob die ' + 'richtige Datei gewählt wurde.' ); } /** * Nutzlast eines Schreibgriffs. * * Der Typ `string | Uint8Array` ist eine Zusage des Uebersetzers; ueber die * Bruecke kommt an, was der Anzeigeprozess sendet. Was weder das eine noch das * andere ist, wird hier abgewiesen - mit `AnwenderFehler`, weil der Satz bereits * fuer den Anwender geschrieben ist. Ein `TypeError` liefe in den Auffangzweig * von `describe` und stuende dem Anwender als Meldung der Laufzeitumgebung * gegenueber: "Das System meldet: Ungültige Daten ...". */ function toBuffer(data: string | Uint8Array): Buffer | string { if (typeof data === 'string') return data; if (data instanceof Uint8Array) return Buffer.from(data); throw new AnwenderFehler('Ungültige Daten: erwartet wird Text oder ein Bytefeld.'); } /** * Endung der Nachbardatei, in die zuerst geschrieben wird. * * Sie haengt am Zielnamen, damit die Nachbardatei im selben Verzeichnis - und * damit auf demselben Datentraeger - liegt. Ueber Datentraegergrenzen hinweg * waere das Umbenennen ein Kopieren und damit wieder teilbar. */ const TEILDATEI_ENDUNG = '.teil'; /** * Groesste Laenge eines Namensbestandteils. * * NTFS laesst je Bestandteil 255 Zeichen zu, die gaengigen POSIX-Dateisysteme * ebenso; MAX_PATH begrenzt daneben nur, was der Windows-Dialog ueberhaupt * zurueckgibt. Der Name der Nachbardatei ist um `.teil` laenger als der des * Ziels - ein Zielname von 251 bis 255 Zeichen liess sich also anlegen, die * Nachbardatei daneben nicht mehr. Windows meldete das mit ENOENT, und * `describe` machte daraus "Die Datei oder der Ordner wurde nicht gefunden.": * eine Auskunft, die hier falsch ist. Der Ordner besteht, und den Namen hat der * Dialog eben noch angenommen. * * Der Name wird deshalb nicht gekuerzt - er ist der, den der Anwender gewaehlt * hat -, sondern vor dem Oeffnen gemessen, damit die Meldung den wirklichen * Grund nennt. */ const MAX_NAMENSLAENGE = 255; /** * Ein Schreibvorgang je Zielpfad, einer nach dem anderen. * * `ipcMain.handle` reiht nichts ein: Ein zweiter Aufruf laeuft los, sobald der * erste an einem `await` haengt. Weil der Name der Nachbardatei allein aus dem * Ziel entsteht, benutzten zwei Speichervorgaenge auf dieselbe Projektdatei * zwangslaeufig dieselbe `.teil`. Das zweite `open(nachbar, 'w')` kuerzte * die Datei, die der erste gerade beschreibt; wer zuerst umbenannte, schob eine * halbe Datei auf das Ziel, und der ueberlebende Griff zeigte danach auf den * Dateikoerper, der jetzt DAS ZIEL ist, und schrieb unmittelbar hinein. Der * zweite `rename` fand seine Nachbardatei nicht mehr vor: Der Anwender las * "Gespeichert: ..." und daneben "Die Datei oder der Ordner wurde nicht * gefunden.", und beim naechsten Oeffnen kam "Die Datei enthält kein gültiges * JSON". Ausloeser genuegten zwei: Strg+S bei einem eingebetteten Luftbild auf * einem Netzlaufwerk, waehrenddessen eine Eingabe und erneut Strg+S - oder das * Schliesskreuz mit "Speichern und schliessen". * * Mit der Einreihung laeuft je Zielpfad hoechstens ein `schreibeUnteilbar`; * beide Laeufe gelingen, und am Ende steht der zuletzt angeforderte Stand. * * Der Schluessel ist der aufgeloeste Zielpfad - dieselbe Groesse, ueber die * auch die Freigabeliste entscheidet (`approvePath`, `isApproved`); er stammt * nie aus Rendererdaten. */ const laufendeSchreibvorgaenge = new Map>(); function nacheinanderJeZiel(ziel: string, arbeit: () => Promise): Promise { const schluessel = path.resolve(ziel); const vorherige = laufendeSchreibvorgaenge.get(schluessel); const naechste = vorherige === undefined ? arbeit() : vorherige.then(arbeit, arbeit); // Die Kette darf nicht am Fehlschlag des Vorgaengers haengenbleiben; der // Fehler geht ueber `naechste` an den eigenen Aufrufer. const abgesichert = naechste.catch(() => undefined); laufendeSchreibvorgaenge.set(schluessel, abgesichert); void abgesichert.then(() => { // Aufraeumen, sobald nichts mehr aussteht - sonst waechst die Aufstellung // mit jeder je beschriebenen Datei. if (laufendeSchreibvorgaenge.get(schluessel) === abgesichert) { laufendeSchreibvorgaenge.delete(schluessel); } }); return naechste; } /** * Schreibt eine Datei unteilbar: erst in eine Nachbardatei, dann umbenennen. * * WARUM NICHT UNMITTELBAR AUF DAS ZIEL * * `fs/promises.writeFile` oeffnet mit dem Kennzeichen 'w' und kuerzt die * vorhandene Datei damit auf null Byte, BEVOR das erste Byte des neuen Inhalts * geschrieben ist. Scheitert das Schreiben danach - kein Platz mehr auf dem * Datentraeger (`describe` uebersetzt ENOSPC eigens fuer diesen Fall), * abgezogener Wechseldatentraeger, weggebrochenes Netzlaufwerk, Absturz -, ist * die zuletzt gesicherte Planunterlage fort und an ihrer Stelle steht eine * leere oder halbe Datei. Der Anwender liest eine Fehlermeldung, die ihn im * Glauben laesst, seine alte Datei stehe noch, und erfaehrt das Gegenteil erst * beim naechsten Oeffnen. Der Sitzungsspeicher fuehrt fuer genau diesen Fall * seit jeher die Zusicherung "kein Datenverlust bei einem Fehler mitten im * Schreiben"; die Langzeitablage - die weitergegebene und archivierte Fassung - * hatte sie nicht. * * WAS DIESE LOESUNG ZUSAGT * * Am Zielort steht stets entweder die alte oder die neue vollstaendige Fassung. * Der Inhalt wird zuerst in die Nachbardatei geschrieben und von dort auf den * Datentraeger geleert; erst danach tritt sie durch Umbenennen an die Stelle * des Ziels. Umbenennen innerhalb eines Datentraegers ist unteilbar - auf NTFS * wie auf POSIX. Scheitert irgendetwas davor, wird die Nachbardatei entfernt * und das Ziel bleibt unberuehrt; ist das Ziel von einem anderen Programm * gesperrt, scheitert das Umbenennen - ebenfalls, ohne die alte Fassung * anzutasten. * * Der Name der Nachbardatei wird hier aus dem bereits bestaetigten Zielpfad * abgeleitet und stammt nicht aus Rendererdaten. Bestaetigt ist der Zielpfad * in jedem der drei Aufrufer durch einen Dialog: bei "Speichern unter" und * "Speichern" ueber die Freigabeliste, die damit die einzige Wache ueber * Zielpfade aus dem Renderer bleibt; bei der Kopie des Fehlerprotokolls * (`speichereProtokollkopie`) unmittelbar aus dem Speichern-Dialog des * Hauptprozesses, ohne dass der Renderer beteiligt ist. * * Weil dieselbe Nachbardatei zu demselben Ziel gehoert, laeuft je Zielpfad * hoechstens ein Vorgang - siehe `nacheinanderJeZiel` darueber. Erst damit gilt * die Zusage auch dann, wenn der Anwender ein zweites Mal speichert, waehrend * der erste Vorgang noch schreibt. */ function schreibeUnteilbar(ziel: string, inhalt: Buffer | string): Promise { return nacheinanderJeZiel(ziel, async () => { const nachbar = `${ziel}${TEILDATEI_ENDUNG}`; // Die Laenge zuerst: Sonst scheiterte das Oeffnen der Nachbardatei mit // ENOENT und der Anwender bekaeme eine nachweislich falsche Begruendung. const erlaubt = MAX_NAMENSLAENGE - TEILDATEI_ENDUNG.length; if (path.basename(nachbar).length > MAX_NAMENSLAENGE) { throw new AnwenderFehler( `Der Dateiname ist zu lang: ${path.basename(ziel).length} Zeichen. Zulässig sind ` + `höchstens ${erlaubt} Zeichen, weil beim Speichern eine Nachbardatei mit der Endung ` + `"${TEILDATEI_ENDUNG}" daneben entsteht. Bitte kürzen Sie den Namen.`, ); } try { const griff = await open(nachbar, 'w'); try { await griff.writeFile(inhalt); await griff.sync(); } finally { await griff.close(); } await rename(nachbar, ziel); } catch (fehler) { await entferneStill(nachbar); throw fehler; } }); } /** Raeumt die Nachbardatei ab. Ein Fehler dabei darf den eigentlichen nicht verdecken. */ async function entferneStill(pfad: string): Promise { try { await rm(pfad, { force: true }); } catch { /* Beim naechsten Speichern wird sie ohnehin ueberschrieben. */ } } /** * Uebernimmt nur Dateifilter, die auch wirklich wie welche aussehen. * Die Rueckgabe ist bewusst veraenderbar getypt, weil Electrons Dialog-API * `FileFilter[]` mit veraenderbaren Feldern erwartet. */ function sanitizeFilters(filters: unknown): { name: string; extensions: string[] }[] { if (!Array.isArray(filters)) return [{ name: 'Alle Dateien', extensions: ['*'] }]; const result = filters .filter( (f): f is FileDialogFilter => typeof f === 'object' && f !== null && typeof (f as FileDialogFilter).name === 'string', ) .map((f) => ({ name: f.name, extensions: Array.isArray(f.extensions) ? f.extensions.map((e) => String(e)) : ['*'], })); return result.length > 0 ? result : [{ name: 'Alle Dateien', extensions: ['*'] }]; } function registerFileHandlers(): void { ipcMain.handle(IPC.saveFile, async (_event, raw: SaveFileRequest): Promise => { if (mainWindow === null) { return { ok: false, canceled: false, filePath: null, error: 'Kein Fenster vorhanden.' }; } try { /* * `raw` ist als SaveFileRequest getypt, weil beide Seiten shared/ipc.ts * einbinden. Das ist eine Verabredung, keine Pruefung: Ueber den Kanal * kommt, was der Renderer sendet, und der Linter beanstandet die * Absicherungen hier deshalb zu Unrecht. Ein Renderer, der `undefined` * oder ein leeres Objekt schickt, soll den Speichern-Dialog mit dem * Vorgabenamen oeffnen und nicht den Hauptprozess in eine Ausnahme * laufen lassen. */ const result = await dialog.showSaveDialog(mainWindow, { title: 'Speichern unter', // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- IPC-Nutzlast ist die Behauptung des Aufrufers; siehe darueber defaultPath: path.basename(String(raw?.defaultName ?? 'Projekt')), // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- IPC-Nutzlast ist die Behauptung des Aufrufers filters: sanitizeFilters(raw?.filters), properties: ['createDirectory', 'showOverwriteConfirmation'], }); // Auch `filePath` wird gegen undefined geprueft, obwohl Electron es als // Zeichenkette fuehrt: Der Wert geht unmittelbar in approvePath und // damit in die Freigabeliste ein. Was diese Wache passiert, darf // geschrieben werden - hier auf die Typzusage zu bauen waere die // falsche Stelle dafuer. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- der Wert geht in die Freigabeliste ein; siehe darueber if (result.canceled || result.filePath === undefined) { return { ok: false, canceled: true, filePath: null, error: null }; } const target = approvePath(result.filePath); await schreibeUnteilbar(target, toBuffer(raw.data)); return { ok: true, canceled: false, filePath: target, error: null }; } catch (error) { return { ok: false, canceled: false, filePath: null, error: describe(error) }; } }); ipcMain.handle(IPC.openFile, async (_event, filters: unknown): Promise => { if (mainWindow === null) { return { ok: false, canceled: false, filePath: null, content: null, error: 'Kein Fenster vorhanden.', }; } try { const result = await dialog.showOpenDialog(mainWindow, { title: 'Projekt öffnen', filters: sanitizeFilters(filters), properties: ['openFile'], }); const selected = result.filePaths[0]; if (result.canceled || selected === undefined) { return { ok: false, canceled: true, filePath: null, content: null, error: null }; } const target = approvePath(selected); const zuGross = await pruefeLesbareGroesse(target); if (zuGross !== null) { return { ok: false, canceled: false, filePath: null, content: null, error: zuGross }; } const content = await readFile(target, 'utf8'); return { ok: true, canceled: false, filePath: target, content, error: null }; } catch (error) { return { ok: false, canceled: false, filePath: null, content: null, error: describe(error) }; } }); ipcMain.handle( IPC.writeFile, async ( _event, raw: { filePath: string; data: string | Uint8Array }, ): Promise => { // Der Typ von `raw` ist eine Zusage des Renderers, keine Pruefung. // Faellt sie aus, darf der Kanal nicht in eine Ausnahme laufen und ohne // Antwort bleiben; der Linter haelt die beiden Absicherungen deshalb zu // Unrecht fuer ueberfluessig. Was sie auffangen: `undefined` und `null` // werden zur leeren Zeichenkette und scheitern gleich darunter an // `=== ''`. Alles andere macht String zu einer Zeichenkette, die in der // Freigabeliste nicht vorkommt ('[object Object]', '42'), und scheitert // an isApproved. Der Leseweg unten (readFile) weist Nicht-Zeichenketten // dagegen selbst ab - hier laufen sie erst an der Freigabeliste auf. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- IPC-Nutzlast vor der Freigabeliste; siehe darueber const filePath = String(raw?.filePath ?? ''); // Ohne vorherige Bestaetigung durch den Anwender wird nichts geschrieben. if (filePath === '' || !isApproved(filePath)) { return { ok: false, canceled: false, filePath: null, error: 'Der Pfad wurde nicht über einen Dateidialog bestätigt. Bitte "Speichern unter" verwenden.', }; } try { await schreibeUnteilbar(filePath, toBuffer(raw.data)); return { ok: true, canceled: false, filePath, error: null }; } catch (error) { return { ok: false, canceled: false, filePath: null, error: describe(error) }; } }, ); ipcMain.handle(IPC.readFile, async (_event, rawPath: unknown): Promise => { // Nicht String(...): Ein Objekt aus dem Renderer wuerde daraus // '[object Object]' machen - eine Zeichenkette, die zwar an der // Freigabeliste scheitert, aber eben erst dort. Was keine // Zeichenkette ist, wird hier abgewiesen. const filePath = typeof rawPath === 'string' ? rawPath : ''; if (filePath === '' || !isApproved(filePath)) { return { ok: false, canceled: false, filePath: null, content: null, error: 'Der Pfad wurde nicht über einen Dateidialog bestätigt.', }; } try { const zuGross = await pruefeLesbareGroesse(filePath); if (zuGross !== null) { return { ok: false, canceled: false, filePath: null, content: null, error: zuGross }; } const content = await readFile(filePath, 'utf8'); return { ok: true, canceled: false, filePath, content, error: null }; } catch (error) { return { ok: false, canceled: false, filePath: null, content: null, error: describe(error) }; } }); ipcMain.on(IPC.setDirty, (_event, dirty: unknown) => { rendererIsDirty = dirty === true; // Zwei Fragezeichen, zwei Gruende: `mainWindow` kann null sein, und // `setDocumentEdited` ist der gepunktete Kreis in der Titelleiste von // macOS. Electron fuehrt die Methode im Typ fuer alle Betriebssysteme, // der Linter beanstandet den zweiten Aufruf deshalb. Er bleibt: Eine // fehlende Zierde darf den Aenderungsvermerk nicht in eine Ausnahme // laufen lassen, und Windows ist die Zielplattform. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- setDocumentEdited gibt es nur auf macOS; siehe darueber mainWindow?.setDocumentEdited?.(rendererIsDirty); }); ipcMain.on(IPC.closeResponse, (_event, allow: unknown) => { pendingCloseResolve?.(allow === true); }); ipcMain.on(IPC.logError, (_event, bericht: ErrorReport) => { protokolliereMeldung(bericht); }); ipcMain.handle(IPC.logPath, () => protokollPfad()); /* * Verweise nach draussen - Schluessel herein, Adresse aus dem Hauptprozess. * * Kein Rueckfall und keine Meldung an den Renderer: Was nicht in der Liste * steht, oeffnet nichts. Ein Protokolleintrag bleibt trotzdem, denn ein * unbekannter Schluessel heisst entweder, dass jemand die Liste geaendert * hat, ohne die Oberflaeche mitzuziehen - oder dass etwas sendet, das nicht * die Oberflaeche ist. */ ipcMain.on(IPC.oeffneVerweis, (_event, ziel: unknown) => { const adresse = verweisadresse(ziel); if (adresse === null) { protokolliere('Verweis', `Unbekanntes Verweisziel abgewiesen: ${String(ziel)}`); return; } void shell.openExternal(adresse); }); ipcMain.handle(IPC.fetchMapImage, async (_event, anfrage: KartenAnfrage) => { const antwort = await holeKartenbild(anfrage); // Nur die technische Einordnung ins Protokoll, nicht die Antwort des // Dienstes: Sie kommt aus fremder Hand. Ein WMS meldet Fehler nach WMS 1.3.0 // als `ServiceExceptionReport` mit Status 200 und zitiert darin // ueblicherweise die gestellte Anfrage - samt BBOX, also den UTM-Koordinaten // des geplanten Knotenpunkts. `karte.ts` reicht bis zu 400 Byte dieses // Koerpers in `fehler` weiter; von dort stand der Standort der Planung // woertlich in einer Datei, die der Anwender laut Hilfefenster einer // Fehlermeldung beilegen soll (Befund 31). Aus demselben Grund bleibt auch // die Abrufadresse aussen vor - die BBOX steht in ihrer Abfrage. Was der // Dienst gemeldet hat, sieht der Anwender an der Oberflaeche; // `antwort.fehler` geht unveraendert dorthin zurueck. if (!antwort.ok) protokolliere('Kartendienst', `Abruf über ${dienstname(anfrage)} misslungen.`); return antwort; }); } /** * Rechnername des angefragten Dienstes - eine technische Angabe. * * Die Adresse kommt aus dem Anzeigeprozess, und gerade der Fehlschlagsgrund * "nicht freigegeben" bringt einen Namen mit, der nicht in der festen Liste aus * electron/karte.ts steht. Ausgegeben wird deshalb nur, was in dieser Liste * steht; alles andere wird benannt statt zitiert. Sonst schriebe der * Anzeigeprozess ueber diesen Umweg frei gewaehlten Text in eine Datei, die * "keine Projektinhalte" zusagt. */ function dienstname(anfrage: KartenAnfrage): string { // Der Zugriff steht im `try`: Kommt aus dem Anzeigeprozess etwas anderes als // eine Anfrage, wirft er - und der Protokolleintrag bleibt trotzdem stehen. try { // `hostname` und nicht `host`, damit hier dieselbe Groesse verglichen wird, // ueber die auch `istErlaubt` in electron/karte.ts entscheidet. const name = new URL(String(anfrage.url)).hostname.toLowerCase(); return erlaubteRechner().includes(name) ? name : 'einen nicht freigegebenen Dienst'; } catch { return 'einen unbekannten Dienst'; } } /** * Fehler im Hauptprozess. * * Ohne diese Behandlung beendet Electron die Anwendung bei einem unbehandelten * Fehler mit einem nichtssagenden Hinweis - oder, je nach Zeitpunkt, ganz ohne * Meldung. Beides macht eine spaetere Untersuchung unmoeglich. Hier wird jeder * Fehler zuerst protokolliert; erst danach entscheidet sich, ob weitergearbeitet * werden kann. */ function registerProcessHandlers(): void { process.on('uncaughtException', (error) => { protokolliere('Hauptprozess/Ausnahme', error.message, error.stack); if (mainWindow !== null && !mainWindow.isDestroyed()) { // Der Menueweg ist hier erreichbar: Der Kasten erscheint nur bei // bestehendem Fenster, und mit dem Horcher auf `uncaughtException` // laeuft der Hauptprozess weiter. Nach dem angeratenen Neustart steht er // ohnehin wieder bereit - das Protokoll ueberdauert ihn. dialog.showErrorBox( 'Unerwarteter Fehler', 'Es ist ein unerwarteter Fehler aufgetreten. Speichern Sie Ihr Projekt und starten Sie ' + 'das Programm neu.\n\n' + `Einzelheiten stehen im Fehlerprotokoll:\n${protokollPfad()}\n\n` + HINWEIS_PROTOKOLLKOPIE, ); } }); process.on('unhandledRejection', (reason) => { const error = reason instanceof Error ? reason : new Error(String(reason)); protokolliere('Hauptprozess/Zusage', error.message, error.stack); }); app.on('child-process-gone', (_event, details) => { // Wie bei 'render-process-gone' weiter oben: Die Abfrage auf `exitCode` // ist nach Electrons Typbeschreibung ueberfluessig und bleibt trotzdem // stehen - eine Protokollzeile ueber einen Absturz soll nicht selbst mit // "undefined" enden. protokolliere( 'Hilfsprozess', `${details.type} beendet · Grund: ${details.reason}${ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld kann zur Laufzeit fehlen; siehe darueber details.exitCode !== undefined ? ` · Beendigungscode ${details.exitCode}` : '' }`, ); }); } function describe(error: unknown): string { if (error instanceof AnwenderFehler) return error.message; if (error instanceof Error) { const code = (error as NodeJS.ErrnoException).code; if (code === 'EACCES' || code === 'EPERM') { return 'Kein Schreibrecht für diesen Ort. Wählen Sie einen anderen Ordner.'; } if (code === 'ENOSPC') return 'Auf dem Datenträger ist kein Platz mehr frei.'; if (code === 'ENOENT') return 'Die Datei oder der Ordner wurde nicht gefunden.'; if (code === 'EBUSY') return 'Die Datei wird von einem anderen Programm verwendet.'; // Auffangzweig. Was hier ankommt - EIO, ELOOP, EMFILE und alles ohne `code` // -, traegt nur die Meldung der Laufzeitumgebung, und die steht auf // Englisch. Sie wird nicht unterschlagen, sie taugt fuer die Fehlersuche; // aber der Satz davor sagt auf Deutsch, was ueberhaupt geschehen ist, und // schreibt den fremden Text als das aus, was er ist. return `Der Vorgang ist fehlgeschlagen. Das System meldet: ${error.message}`; } return `Der Vorgang ist fehlgeschlagen. Das System meldet: ${String(error)}`; } // --- Fehlerprotokoll weitergeben -------------------------------------------- /** * Satz der beiden Fehlerdialoge, der auf die Kopie des Protokolls verweist. * * Die Dialoge nennen den Pfad weiterhin: Meist stimmt er, und wer ihn kennt, * findet die Datei. Je nach Installationsart stimmt er nur, wenn * %APPDATA%\lsa-planer-professional vorher schon bestand - vom Setup oder vom * tragbaren Programm; sonst findet der Explorer die Datei dort nicht (siehe * Kopf von electron/protokoll.ts). Der Menueweg fuehrt in jedem Fall zu einer * Datei. Die Beschriftung steht woertlich wie in electron/menu.ts - ein Dialog, * der einen Eintrag anders nennt, als das Menue ihn zeigt, schickt den Anwender * auf die Suche. * * Er steht nur in Dialogen, bei denen das Menue danach noch erreichbar ist; * die Begruendung steht jeweils an der Stelle. */ const HINWEIS_PROTOKOLLKOPIE = 'Über „Hilfe → Fehlerprotokoll speichern …“ legen Sie eine Kopie an einem Ort Ihrer Wahl an.'; /** Titel des Fehlerkastens, wenn die Kopie nicht zustande kommt. */ const TITEL_KOPIE_MISSLUNGEN = 'Fehlerprotokoll nicht gespeichert'; /** * Vorgeschlagener Ort der Kopie: der Dokumente-Ordner, mit oertlichem Datum im * Namen. * * `app.getPath` wirft laut Electron, wenn sich der Ordner nicht ermitteln * laesst. Dann bleibt der blosse Dateiname, und der Dialog beginnt, wo das * Betriebssystem ihn beginnen laesst - ein fehlender Vorschlag ist kein Grund, * die Kopie zu verweigern. */ function protokollvorschlag(): string { const name = protokollDateiname(new Date()); try { return path.join(app.getPath('documents'), name); } catch { return name; } } /** * Hilfe -> "Fehlerprotokoll speichern …": schreibt eine Kopie des Protokolls an * einen Ort, den der Anwender im Dialog waehlt. * * Warum es den Weg gibt, steht im Kopf von electron/protokoll.ts (umgeleitete * Benutzerdaten); was in die Kopie kommt, bei `protokollkopie`. * * WAS HIER BEWUSST NICHT GESCHIEHT * * - Kein IPC-Kanal: Der Befehl entsteht im Menue des Hauptprozesses, und der * Renderer ist weder Ausloeser noch Datenquelle. Die Quelle ist fest * (`protokollPfad` und die abgeloeste Datei davor), und keine der beiden * ist als Ziel zugelassen (`istProtokolldatei`, seit 5.43.0) - auch nicht * unter einem anderen Pfad derselben Datei. * - Keine Freigabe: Das Ziel kommt allein aus dem Dialog und geht NICHT durch * `approvePath`. Die Freigabeliste sagt, was der Renderer ohne neuen Dialog * lesen und beschreiben darf - mit dieser Datei hat er nichts zu tun. * - Kein eigener Schreibweg: Geschrieben wird ueber `schreibeUnteilbar`, * dieselbe Stelle wie fuer die Projektdatei. Eine vorhandene Datei am Ziel - * etwa die Kopie vom Vortag - ueberlebt einen Fehlschlag. * - Nichts wird verschluckt: Jeder Fehlschlag endet in einem Fehlerkasten. * Ins Protokoll geht er nicht, denn die Meldung des Systems nennt den * gewaehlten Zielpfad, und ein Ordnername kann Projektinhalt sein (vgl. * Befund 31 am Griff `IPC.fetchMapImage`). * * Gelesen wird NACH dem Dialog, damit die Kopie enthaelt, was bis dahin * hinzukam. Lese- und Schreibfehler haben getrennte Meldungen: `describe` raet * bei EACCES, einen anderen Ordner zu waehlen - bei einem Protokoll, das sich * nicht lesen laesst, waere das ein falscher Rat. * * Die Zusage wird nie verworfen; der Menueeintrag gibt sie deshalb mit `void` ab. */ async function speichereProtokollkopie(): Promise { try { const optionen: SaveDialogOptions = { title: 'Fehlerprotokoll speichern', defaultPath: protokollvorschlag(), filters: [{ name: 'Textdatei', extensions: ['txt'] }], properties: ['createDirectory', 'showOverwriteConfirmation'], }; const ergebnis = mainWindow === null ? await dialog.showSaveDialog(optionen) : await dialog.showSaveDialog(mainWindow, optionen); // `!filePath` statt `=== ''`: Die Typbeschreibung verspricht eine // Zeichenkette, aeltere Electron-Fassungen lieferten beim Abbrechen // `undefined`. Kaeme ein undefiniertes Ziel bis `schreibeUnteilbar`, wuerfe // `path.resolve` in `nacheinanderJeZiel`, bevor eine Datei entsteht - und // der Anwender, der nichts gewaehlt hat, bekaeme den Fehlerkasten unten mit // der englischen Meldung des Laufzeitsystems ("The "paths[0]" argument // must be of type string"). if (ergebnis.canceled || !ergebnis.filePath) return; // Nicht an die Stelle des Protokolls selbst - siehe `istProtokolldatei`. // Vor dem Lesen, und ohne zu schreiben: Weder die laufende noch die // abgeloeste Datei darf durch ihre eigene Kopie ersetzt werden. if (istProtokolldatei(ergebnis.filePath, protokollPfad())) { dialog.showErrorBox( TITEL_KOPIE_MISSLUNGEN, 'Die Kopie kann nicht an die Stelle des Protokolls selbst geschrieben werden. ' + 'Bitte wählen Sie einen anderen Ort oder Dateinamen.', ); return; } let inhalt: string; try { inhalt = protokollkopie(protokollPfad(), liesProtokolldatei); } catch (fehler) { dialog.showErrorBox( TITEL_KOPIE_MISSLUNGEN, 'Das Fehlerprotokoll ließ sich nicht lesen; es wurde keine Kopie angelegt.\n\n' + `Das System meldet: ${fehler instanceof Error ? fehler.message : String(fehler)}`, ); return; } await schreibeUnteilbar(ergebnis.filePath, inhalt); } catch (fehler) { dialog.showErrorBox( TITEL_KOPIE_MISSLUNGEN, `Die Kopie des Fehlerprotokolls ließ sich nicht speichern.\n\n${describe(fehler)}`, ); } } // --- Programmablauf --------------------------------------------------------- // Nur eine Instanz: sonst arbeiten zwei Fenster auf demselben Sitzungsspeicher // und ueberschreiben sich gegenseitig. if (!app.requestSingleInstanceLock()) { app.quit(); } else { app.on('second-instance', () => { if (mainWindow === null) return; if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); }); registerProcessHandlers(); app.whenReady().then( () => { protokolliereStart(); hardenSession(); registerFileHandlers(); // Vor dem Fenster: Die Stufe geht als `zoomFactor` in seine Vorgaben ein. schriftgroesse = liesSchriftgroesse(ansichtsDatei()); schriftSicherung = schriftsicherung(ansichtsDatei(), schriftgroesse); beobachteErscheinungsbild(); createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }, (error: unknown) => { dialog.showErrorBox('Startfehler', describe(error)); app.quit(); }, ); // Was noch aussteht, geht vor dem Beenden heraus: Eine Schriftgroesse, die // eine halbe Sekunde vor dem Schliessen eingestellt wurde, darf nicht // verlorengehen. app.on('before-quit', () => { schriftSicherung?.jetzt(); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); // Keine Zertifikatsausnahmen und keine Fremdinhalte. app.on('web-contents-created', (_event, contents) => { contents.on('will-attach-webview', (event) => { event.preventDefault(); }); }); }