lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ electron main.ts

48,1 KB Rohdatei
electron/main.ts — 1176 Zeilen
1 import {
2 BrowserWindow,
3 Menu,
4 app,
5 dialog,
6 ipcMain,
7 nativeTheme,
8 session,
9 shell,
10 systemPreferences,
11 type SaveDialogOptions,
12 } from 'electron';
13 import { open, readFile, rename, rm, stat } from 'node:fs/promises';
14 import path from 'node:path';
15 import { pathToFileURL } from 'node:url';
16 import { entscheideNavigation } from './navigation';
17 import { verweisadresse } from './verweise';
18 import {
19 IPC,
20 type ErrorReport,
21 type FileDialogFilter,
22 type KartenAnfrage,
23 type MenuCommand,
24 type OpenFileResult,
25 type SaveFileRequest,
26 type SaveFileResult,
27 } from '../shared/ipc';
28 import { buildMenu, schriftgroesseMenueId, type SchriftgroessenSteuerung } from './menu';
29 import {
30 ANSICHT_DATEINAME,
31 SCHRIFT_STANDARD,
32 gueltigeStufe,
33 liesSchriftgroesse,
34 nachbarstufe,
35 schriftsicherung,
36 type Schriftsicherung,
37 } from './schriftgroesse';
38 import { fensterGrundfarbe } from './fenstergrund';
39 import { erlaubteRechner, holeKartenbild } from './karte';
40 import {
41 istProtokolldatei,
42 liesProtokolldatei,
43 protokollDateiname,
44 protokolliere,
45 protokolliereMeldung,
46 protokolliereStart,
47 protokollkopie,
48 protokollPfad,
49 } from './protokoll';
50
51 /**
52 * Electron-Hauptprozess.
53 *
54 * Sicherheitsgrundsaetze:
55 * - Der Renderer erhaelt keinen freien Dateizugriff. Geschrieben und gelesen
56 * werden nur Pfade, die der Anwender zuvor in einem Dialog bestaetigt hat.
57 * - Keine Navigation aus der Anwendung heraus, keine neuen Fenster.
58 * - Eine Inhaltssicherheitsrichtlinie wird zusaetzlich als Antwortkopf gesetzt,
59 * damit sie auch dann greift, wenn das Meta-Element im Dokument fehlt.
60 * - Berechtigungsanfragen (Kamera, Standort, Benachrichtigungen) werden
61 * grundsaetzlich abgelehnt; die Anwendung benoetigt keine davon.
62 */
63
64 const isDevelopment = !app.isPackaged;
65 const RENDERER_DIR = path.join(__dirname, '..', 'renderer');
66 const RENDERER_INDEX = path.join(RENDERER_DIR, 'index.html');
67
68 let mainWindow: BrowserWindow | null = null;
69 let rendererIsDirty = false;
70 let closeApproved = false;
71 let pendingCloseResolve: ((allow: boolean) => void) | null = null;
72 /**
73 * Die laufende Rueckfrage ans Fenster, solange eine laeuft.
74 *
75 * Ein zweiter Klick auf das Schliesskreuz bekommt dieselbe Zusage zurueck,
76 * statt eine zweite Rueckfrage zu stellen (siehe `askRendererToClose`).
77 */
78 let pendingClosePromise: Promise<boolean> | null = null;
79
80 /**
81 * Eingestellte Schriftgroesse in Prozent (Befund L11). Wird vor dem ersten
82 * Fenster aus dem Benutzerdatenverzeichnis gelesen; naeheres in
83 * electron/schriftgroesse.ts.
84 */
85 let schriftgroesse = SCHRIFT_STANDARD;
86
87 /** Das gebaute Anwendungsmenue, um die Auswahlmarke der Stufe nachzufuehren. */
88 let anwendungsmenue: Menu | null = null;
89
90 /**
91 * Pfade, die der Anwender in einem Dialog bestaetigt hat.
92 * Nur auf diese darf der Renderer spaeter ohne erneuten Dialog zugreifen.
93 */
94 const approvedPaths = new Set<string>();
95
96 function approvePath(filePath: string): string {
97 const resolved = path.resolve(filePath);
98 approvedPaths.add(resolved);
99 return resolved;
100 }
101
102 function isApproved(filePath: string): boolean {
103 return approvedPaths.has(path.resolve(filePath));
104 }
105
106 // --- Darstellung des Fensters -----------------------------------------------
107
108 /**
109 * Grundfarbe des Fensters (Befund L12).
110 *
111 * Zwei Eingangsgroessen, weil Windows zwei voneinander unabhaengige Schalter
112 * fuehrt: den fuer App-Farben (`shouldUseDarkColors`) und das Kontrastdesign.
113 * Die beiden Farbwerte und die Begruendung stehen in electron/fenstergrund.ts.
114 *
115 * WAS DIESE LOESUNG NICHT KANN: Sie folgt dem Betriebssystem. Hat der Anwender
116 * in den Vorgaben ausdruecklich "Hell" gewaehlt, waehrend das System dunkel
117 * steht, ist der Ton eine Bildfolge lang falsch herum. Diese Wahl liegt in
118 * `AppSettings` im Anzeigeprozess und ist von hier aus nicht lesbar. Der haeufige
119 * Fall - Voreinstellung "System" plus dunkles Betriebssystem - ist damit behoben,
120 * der seltene bleibt; das Fenster wird ohnehin erst bei `ready-to-show` gezeigt.
121 */
122 function grundfarbe(): string {
123 return fensterGrundfarbe(nativeTheme.shouldUseDarkColors, kontrastgrund());
124 }
125
126 /**
127 * Fenstergrundfarbe des Kontrastdesigns, sonst `null`.
128 *
129 * `systemPreferences.getColor` gibt es nur unter Windows und macOS. Ein Wurf
130 * darf den Start nicht anhalten - dann gilt weiter die Anwendungsfarbe.
131 */
132 function kontrastgrund(): string | null {
133 if (!nativeTheme.shouldUseHighContrastColors) return null;
134 try {
135 return systemPreferences.getColor('window');
136 } catch {
137 return null;
138 }
139 }
140
141 function beobachteErscheinungsbild(): void {
142 // Wechselt das Betriebssystem waehrend des Betriebs, gilt die neue Farbe beim
143 // naechsten Neuzeichnen des Fensterrahmens und beim naechsten "Neu laden".
144 nativeTheme.on('updated', () => {
145 mainWindow?.setBackgroundColor(grundfarbe());
146 });
147 }
148
149 /** Ort der gesicherten Ansichtseinstellungen. */
150 function ansichtsDatei(): string {
151 return path.join(app.getPath('userData'), ANSICHT_DATEINAME);
152 }
153
154 /**
155 * Setzt die Schriftgroesse, sichert sie und fuehrt die Auswahlmarke im Menue nach
156 * (Befund L11).
157 *
158 * Genau eine Buchfuehrung: Auswahlliste, "Vergroessern/Verkleinern" und
159 * Strg+Mausrad laufen alle hier durch.
160 */
161 /**
162 * Verzoegerte Sicherung der Stufe (Begruendung bei `Schriftsicherung` in
163 * electron/schriftgroesse.ts).
164 *
165 * Wird beim Start eingerichtet, sobald die gesicherte Stufe gelesen ist. Bis
166 * dahin - und in Tests, die nur das Menue bauen - bleibt sie ungesetzt; die
167 * Stufe wirkt dann, wird aber nicht geschrieben.
168 */
169 let schriftSicherung: Schriftsicherung | null = null;
170
171 function setzeSchriftgroesse(prozent: number): void {
172 schriftgroesse = gueltigeStufe(prozent);
173 wendeSchriftgroesseAn();
174 // Nicht bei jeder Mausradraste auf den Datentraeger: Die Sicherung wartet die
175 // Ruhezeit ab und schreibt nur, wenn sich der Wert wirklich geaendert hat.
176 schriftSicherung?.plane(schriftgroesse);
177 const eintrag = anwendungsmenue?.getMenuItemById(schriftgroesseMenueId(schriftgroesse));
178 if (eintrag) eintrag.checked = true;
179 }
180
181 function wendeSchriftgroesseAn(): void {
182 mainWindow?.webContents.setZoomFactor(schriftgroesse / 100);
183 }
184
185 const schriftgroessenSteuerung: SchriftgroessenSteuerung = {
186 aktuell: () => schriftgroesse,
187 setze: (prozent) => {
188 setzeSchriftgroesse(prozent);
189 },
190 schritt: (richtung) => {
191 setzeSchriftgroesse(nachbarstufe(schriftgroesse, richtung));
192 },
193 };
194
195 // --- Fenster ----------------------------------------------------------------
196
197 function createWindow(): void {
198 mainWindow = new BrowserWindow({
199 width: 1440,
200 height: 920,
201 minWidth: 960,
202 minHeight: 640,
203 show: false,
204 backgroundColor: grundfarbe(),
205 icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
206 title: 'LSA-Planer Professional',
207 webPreferences: {
208 preload: path.join(__dirname, 'preload.js'),
209 contextIsolation: true,
210 nodeIntegration: false,
211 sandbox: true,
212 webSecurity: true,
213 allowRunningInsecureContent: false,
214 spellcheck: false,
215 // Schon beim Aufbau des Fensters, damit die erste Darstellung in der
216 // gewaehlten Groesse erscheint und nicht sichtbar von 100 % dorthin
217 // springt (Befund L11).
218 zoomFactor: schriftgroesse / 100,
219 },
220 });
221
222 anwendungsmenue = buildMenu(sendMenuCommand, isDevelopment, schriftgroessenSteuerung, () => {
223 void speichereProtokollkopie();
224 });
225 Menu.setApplicationMenu(anwendungsmenue);
226
227 void mainWindow.loadFile(RENDERER_INDEX);
228
229 // Der Vergroesserungsgrad haengt am geladenen Dokument und faellt bei jeder
230 // Navigation auf 1 zurueck - auch beim "Neu laden" und bei dem Neustart, den
231 // `render-process-gone` weiter unten ausloest. Ohne diese Zeile waere die
232 // Einstellung nach jedem Absturz wieder weg, also genau der Zustand, den
233 // Befund L11 ruegt.
234 mainWindow.webContents.on('did-finish-load', () => {
235 wendeSchriftgroesseAn();
236 });
237
238 // Strg+Mausrad geht durch dieselbe Stufenliste wie das Menue und wird ebenso
239 // gesichert; sonst zeigte die Auswahlmarke auf eine Stufe, die nicht gilt.
240 mainWindow.webContents.on('zoom-changed', (_event, richtung) => {
241 setzeSchriftgroesse(nachbarstufe(schriftgroesse, richtung === 'in' ? 1 : -1));
242 });
243
244 mainWindow.once('ready-to-show', () => {
245 mainWindow?.show();
246 });
247
248 // Fehler aus dem Renderer sichtbar machen. Ohne diese Weiterleitung bleibt
249 // ein Fehler im Auslieferungsstand unbemerkt, weil es dort keine geoeffneten
250 // Entwicklerwerkzeuge gibt. Verstoesse gegen die Inhaltssicherheitsrichtlinie
251 // erscheinen hier ebenfalls.
252 mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => {
253 if (level < 2) return;
254 protokolliere('Anzeige/Konsole', `${message} (${sourceId}:${line})`);
255 });
256
257 mainWindow.webContents.on('render-process-gone', (_event, details) => {
258 // `exitCode` steht in Electrons Typbeschreibung als Zahl; der Linter haelt
259 // die Abfrage deshalb fuer ueberfluessig. Sie bleibt: Hier wird ein
260 // Absturz protokolliert, und das Protokoll ist alles, was von ihm uebrig
261 // bleibt. Fehlt das Feld einmal, steht sonst "Beendigungscode undefined"
262 // in der Zeile, die die Untersuchung tragen soll.
263 protokolliere(
264 'Anzeige/Prozessende',
265 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld kann zur Laufzeit fehlen; siehe darueber
266 `Grund: ${details.reason}${details.exitCode !== undefined ? ` · Beendigungscode ${details.exitCode}` : ''}`,
267 );
268 // Der Menueweg ist hier erreichbar: Das Menue gehoert dem Hauptprozess
269 // und nicht der abgestuerzten Anzeige, und das Fenster bleibt stehen und
270 // wird nur neu geladen. Siehe `HINWEIS_PROTOKOLLKOPIE`.
271 dialog.showErrorBox(
272 'Die Anzeige wurde unerwartet beendet',
273 'Die Anwendung wird neu geladen. Der zuletzt gesicherte Stand bleibt erhalten.\n\n' +
274 `Einzelheiten stehen im Fehlerprotokoll:\n${protokollPfad()}\n\n` +
275 HINWEIS_PROTOKOLLKOPIE,
276 );
277 mainWindow?.reload();
278 });
279
280 mainWindow.webContents.on('did-fail-load', (_event, code, description) => {
281 protokolliere('Anzeige/Laden', `Fehlgeschlagen (${code}): ${description}`);
282 });
283
284 // Ein abgestuerzter Hilfsprozess (GPU, Netzdienst) beendet die Anwendung nicht,
285 // erklaert aber spaetere Auffaelligkeiten.
286 mainWindow.webContents.on('unresponsive', () => {
287 protokolliere('Anzeige', 'Die Anzeige antwortet nicht mehr.');
288 });
289 mainWindow.webContents.on('responsive', () => {
290 protokolliere('Anzeige', 'Die Anzeige antwortet wieder.');
291 });
292
293 /*
294 * Kein Verlassen der Anwendung im eigenen Fenster.
295 *
296 * Die Entscheidung selbst steht in `entscheideNavigation` (navigation.ts) -
297 * als reine Funktion, damit sie sich erschoepfend pruefen laesst, ohne ein
298 * Fenster zu oeffnen und ohne einen Browser zu starten. Hier steht nur noch
299 * die Verdrahtung.
300 *
301 * `setWindowOpenHandler` prueft damit dasselbe wie `will-navigate`; bis
302 * hierher fragte er `url.startsWith('https://')` und damit die Zeichenkette
303 * statt der ausgewerteten Adresse - eine schwaechere Pruefung an derselben
304 * Grenze.
305 */
306 const erlaubteSeite = pathToFileURL(RENDERER_INDEX).toString();
307
308 mainWindow.webContents.on('will-navigate', (event, url) => {
309 const entscheidung = entscheideNavigation(url, erlaubteSeite);
310 if (entscheidung === 'laden') return;
311 event.preventDefault();
312 if (entscheidung === 'extern') void shell.openExternal(url);
313 });
314
315 mainWindow.webContents.setWindowOpenHandler(({ url }) => {
316 // Ein neues Fenster gibt es nie - hoechstens den Standardbrowser.
317 if (entscheideNavigation(url, erlaubteSeite) === 'extern') void shell.openExternal(url);
318 return { action: 'deny' };
319 });
320
321 // Vor dem Schliessen beim Renderer nachfragen, solange Aenderungen bestehen.
322 mainWindow.on('close', (event) => {
323 if (closeApproved || !rendererIsDirty || mainWindow === null) return;
324 event.preventDefault();
325 void askRendererToClose().then((allow) => {
326 if (!allow) return;
327 closeApproved = true;
328 mainWindow?.close();
329 });
330 });
331
332 mainWindow.on('closed', () => {
333 mainWindow = null;
334 });
335 }
336
337 /**
338 * Fragt den Anzeigeprozess, ob trotz ungespeicherter Aenderungen geschlossen
339 * werden darf.
340 *
341 * WARUM AUF DIE ANTWORT KEINE FRIST LIEGT
342 *
343 * Die Rueckfrage beantwortet ein MENSCH: Der Anzeigeprozess zeigt
344 * "Ungespeicherte Änderungen" mit drei Schaltflaechen und sendet erst, wenn
345 * eine davon gewaehlt ist - beim Weg "Speichern und schließen" sogar erst nach
346 * dem Sichern, das bei einem noch nie gesicherten Projekt einen Dateidialog
347 * oeffnet. Eine Frist auf die Antwort misst also die Bedenkzeit des Anwenders
348 * und nicht die Antwortfaehigkeit der Anzeige.
349 *
350 * Bis 5.8.0 stand hier eine Frist von fuenf Sekunden. Wer laenger las, bekam
351 * eine zweite, modale Meldung "Die Anwendung antwortet nicht." ueber den noch
352 * offenen Dialog gelegt, mit dem Angebot, die ungespeicherte Arbeit
353 * wegzuwerfen; und weil der Wartezustand dabei geloescht wurde, verpuffte die
354 * spaeter eintreffende echte Antwort - das Fenster liess sich trotz
355 * beantworteter Rueckfrage nicht mehr schliessen.
356 *
357 * WORAN DER AUSWEG STATTDESSEN HAENGT
358 *
359 * Ohne einen Ausweg liesse sich ein haengendes Fenster nicht mehr schliessen.
360 * Er bleibt, haengt aber an einem Lebenszeichen statt an einer Uhr: an
361 * `isCrashed()` vor dem Senden und am Ereignis `unresponsive` waehrend des
362 * Wartens. Nur dann behauptet die Meldung, die Anwendung antworte nicht - und
363 * dann stimmt es auch.
364 *
365 * Solange eine Rueckfrage laeuft, wird keine zweite gestellt: Ein zweiter Klick
366 * auf das Schliesskreuz bekommt dieselbe Zusage. Sonst stapelten sich
367 * Wartezustaende, von denen nur der letzte je beantwortet wurde.
368 */
369 function askRendererToClose(): Promise<boolean> {
370 if (mainWindow === null) return Promise.resolve(true);
371 if (pendingClosePromise !== null) return pendingClosePromise;
372
373 const fenster = mainWindow;
374 const inhalt = fenster.webContents;
375
376 // Ist die Anzeige schon fort, kann sie nicht mehr gefragt werden - und ein
377 // modaler Kasten ueber einem verschwindenden Fenster hilft niemandem.
378 if (inhalt.isDestroyed()) return Promise.resolve(true);
379
380 // Eine abgestuerzte Anzeige wird gar nicht erst befragt. Das steht VOR dem
381 // Erzeuger, nicht darin: Ein synchroner Abschluss innerhalb des Erzeugers
382 // wuerde von `pendingClosePromise = versprechen` ueberholt, und das Feld
383 // hielte danach eine bereits aufgeloeste Zusage. Jeder weitere Klick auf das
384 // Schliesskreuz bekaeme sie zurueck - nach einmaligem "Abbrechen" liesse sich
385 // das Fenster nie wieder schliessen.
386 if (inhalt.isCrashed()) return Promise.resolve(frageObTrotzdemGeschlossenWird(fenster));
387
388 const versprechen = new Promise<boolean>((resolve) => {
389 const wache: { abmelden: () => void } = { abmelden: () => undefined };
390
391 const fertig = (allow: boolean): void => {
392 wache.abmelden();
393 pendingCloseResolve = null;
394 pendingClosePromise = null;
395 resolve(allow);
396 };
397
398 const keineAntwort = (): void => {
399 fertig(frageObTrotzdemGeschlossenWird(fenster));
400 };
401
402 pendingCloseResolve = fertig;
403
404 inhalt.on('unresponsive', keineAntwort);
405 wache.abmelden = () => {
406 inhalt.removeListener('unresponsive', keineAntwort);
407 };
408
409 inhalt.send(IPC.requestClose);
410 });
411
412 pendingClosePromise = versprechen;
413 return versprechen;
414 }
415
416 /**
417 * Letzter Ausweg, wenn die Anzeige nachweislich nicht mehr antwortet.
418 *
419 * Nur von `askRendererToClose` aus erreichbar, und dort nur bei fehlendem
420 * Lebenszeichen. `defaultId` und `cancelId` stehen beide auf "Abbrechen":
421 * Eingabe- und Fluchttaste verwerfen nichts.
422 */
423 function frageObTrotzdemGeschlossenWird(fenster: BrowserWindow): boolean {
424 return (
425 dialog.showMessageBoxSync(fenster, {
426 type: 'warning',
427 buttons: ['Abbrechen', 'Trotzdem schließen'],
428 defaultId: 0,
429 cancelId: 0,
430 title: 'Anwendung antwortet nicht',
431 message: 'Die Anwendung antwortet nicht.',
432 detail: 'Beim Schließen gehen ungespeicherte Änderungen verloren.',
433 }) === 1
434 );
435 }
436
437 function sendMenuCommand(command: MenuCommand): void {
438 mainWindow?.webContents.send(IPC.menuCommand, command);
439 }
440
441 // --- Sicherheitsvorgaben der Sitzung ----------------------------------------
442
443 function hardenSession(): void {
444 const defaultSession = session.defaultSession;
445
446 defaultSession.webRequest.onHeadersReceived((details, callback) => {
447 callback({
448 responseHeaders: {
449 ...details.responseHeaders,
450 'Content-Security-Policy': [
451 "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
452 "img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' blob:; " +
453 "base-uri 'none'; form-action 'none'; object-src 'none'; frame-ancestors 'none'",
454 ],
455 'X-Content-Type-Options': ['nosniff'],
456 },
457 });
458 });
459
460 // Die Anwendung braucht keinerlei Geraeteberechtigungen.
461 defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
462 callback(false);
463 });
464 defaultSession.setPermissionCheckHandler(() => false);
465 }
466
467 // --- Datei-Schnittstelle ----------------------------------------------------
468
469 /**
470 * Ein Fehler, dessen Meldung bereits fuer den Anwender geschrieben ist.
471 *
472 * `describe` gibt sie unveraendert weiter, statt einen zweiten Satz darum zu
473 * legen.
474 */
475 class AnwenderFehler extends Error {}
476
477 /**
478 * Obergrenze einer eingelesenen Projektdatei.
479 *
480 * WARUM ES SIE UEBERHAUPT BRAUCHT
481 *
482 * `readFile(..., 'utf8')` baut eine EINZIGE Zeichenkette auf. V8 laesst
483 * hoechstens 0x1fffffe8 Zeichen zu, also rund 512 MiB; darueber wirft der Aufruf
484 * einen `RangeError` mit der Meldung "Invalid string length" und OHNE `code`.
485 * Ohne vorgelagerte Pruefung stand dieser englische Satz woertlich auf dem
486 * Bildschirm, und der Anwender erfuhr nicht, dass die Datei schlicht zu gross
487 * ist.
488 *
489 * WARUM 256 MiB
490 *
491 * Die Schranke soll nur fangen, was nie eine Planunterlage dieses Programms
492 * gewesen sein kann - eine zu enge Grenze wuerde eine lesbare Datei abweisen,
493 * und das waere der schlimmere Fehler. Das groesste Stueck, das dieses Programm
494 * schreibt, ist das eingebettete Hintergrundbild: MAX_BILD_ZEICHEN in
495 * src/domain/model/schema.ts laesst 33,55 Mio Zeichen zu, also 32 MiB. 256 MiB
496 * sind das Achtfache und lassen daneben reichlich Raum fuer die Plandaten.
497 *
498 * Zugleich liegt die Grenze sicher unter der von V8: In UTF-8 traegt jedes
499 * Zeichen mindestens ein Byte, 256 MiB Datei ergeben also hoechstens
500 * 268.435.456 Zeichen - die Haelfte dessen, was V8 zulaesst. Was diese Pruefung
501 * durchlaesst, scheitert nicht mehr an der Zeichenkette.
502 */
503 const MAX_PROJEKTDATEI_BYTES = 256 * 1024 * 1024;
504
505 /** Byte in MB mit Dezimalkomma - die Meldung liest ein Anwender. */
506 function megabyte(bytes: number, nachkommastellen: number): string {
507 return (bytes / (1024 * 1024)).toFixed(nachkommastellen).replace('.', ',');
508 }
509
510 /**
511 * Weist eine Datei ab, die zu gross ist, um als Zeichenkette gelesen zu werden.
512 *
513 * Geprueft wird VOR dem Lesen; die Meldung nennt die tatsaechliche und die
514 * zulaessige Groesse, so wie es die Meldungen zum Hintergrundbild und zum
515 * Kartenabruf ebenfalls tun.
516 */
517 async function pruefeLesbareGroesse(pfad: string): Promise<string | null> {
518 const { size } = await stat(pfad);
519 if (size <= MAX_PROJEKTDATEI_BYTES) return null;
520 return (
521 `Die Datei belegt ${megabyte(size, 1)} MB und liegt damit über der Grenze von ` +
522 `${megabyte(MAX_PROJEKTDATEI_BYTES, 0)} MB; sie wurde nicht gelesen. Eine Planunterlage ` +
523 'dieser Größe kann dieses Programm nicht geschrieben haben - bitte prüfen Sie, ob die ' +
524 'richtige Datei gewählt wurde.'
525 );
526 }
527
528 /**
529 * Nutzlast eines Schreibgriffs.
530 *
531 * Der Typ `string | Uint8Array` ist eine Zusage des Uebersetzers; ueber die
532 * Bruecke kommt an, was der Anzeigeprozess sendet. Was weder das eine noch das
533 * andere ist, wird hier abgewiesen - mit `AnwenderFehler`, weil der Satz bereits
534 * fuer den Anwender geschrieben ist. Ein `TypeError` liefe in den Auffangzweig
535 * von `describe` und stuende dem Anwender als Meldung der Laufzeitumgebung
536 * gegenueber: "Das System meldet: Ungültige Daten ...".
537 */
538 function toBuffer(data: string | Uint8Array): Buffer | string {
539 if (typeof data === 'string') return data;
540 if (data instanceof Uint8Array) return Buffer.from(data);
541 throw new AnwenderFehler('Ungültige Daten: erwartet wird Text oder ein Bytefeld.');
542 }
543
544 /**
545 * Endung der Nachbardatei, in die zuerst geschrieben wird.
546 *
547 * Sie haengt am Zielnamen, damit die Nachbardatei im selben Verzeichnis - und
548 * damit auf demselben Datentraeger - liegt. Ueber Datentraegergrenzen hinweg
549 * waere das Umbenennen ein Kopieren und damit wieder teilbar.
550 */
551 const TEILDATEI_ENDUNG = '.teil';
552
553 /**
554 * Groesste Laenge eines Namensbestandteils.
555 *
556 * NTFS laesst je Bestandteil 255 Zeichen zu, die gaengigen POSIX-Dateisysteme
557 * ebenso; MAX_PATH begrenzt daneben nur, was der Windows-Dialog ueberhaupt
558 * zurueckgibt. Der Name der Nachbardatei ist um `.teil` laenger als der des
559 * Ziels - ein Zielname von 251 bis 255 Zeichen liess sich also anlegen, die
560 * Nachbardatei daneben nicht mehr. Windows meldete das mit ENOENT, und
561 * `describe` machte daraus "Die Datei oder der Ordner wurde nicht gefunden.":
562 * eine Auskunft, die hier falsch ist. Der Ordner besteht, und den Namen hat der
563 * Dialog eben noch angenommen.
564 *
565 * Der Name wird deshalb nicht gekuerzt - er ist der, den der Anwender gewaehlt
566 * hat -, sondern vor dem Oeffnen gemessen, damit die Meldung den wirklichen
567 * Grund nennt.
568 */
569 const MAX_NAMENSLAENGE = 255;
570
571 /**
572 * Ein Schreibvorgang je Zielpfad, einer nach dem anderen.
573 *
574 * `ipcMain.handle` reiht nichts ein: Ein zweiter Aufruf laeuft los, sobald der
575 * erste an einem `await` haengt. Weil der Name der Nachbardatei allein aus dem
576 * Ziel entsteht, benutzten zwei Speichervorgaenge auf dieselbe Projektdatei
577 * zwangslaeufig dieselbe `<Ziel>.teil`. Das zweite `open(nachbar, 'w')` kuerzte
578 * die Datei, die der erste gerade beschreibt; wer zuerst umbenannte, schob eine
579 * halbe Datei auf das Ziel, und der ueberlebende Griff zeigte danach auf den
580 * Dateikoerper, der jetzt DAS ZIEL ist, und schrieb unmittelbar hinein. Der
581 * zweite `rename` fand seine Nachbardatei nicht mehr vor: Der Anwender las
582 * "Gespeichert: ..." und daneben "Die Datei oder der Ordner wurde nicht
583 * gefunden.", und beim naechsten Oeffnen kam "Die Datei enthält kein gültiges
584 * JSON". Ausloeser genuegten zwei: Strg+S bei einem eingebetteten Luftbild auf
585 * einem Netzlaufwerk, waehrenddessen eine Eingabe und erneut Strg+S - oder das
586 * Schliesskreuz mit "Speichern und schliessen".
587 *
588 * Mit der Einreihung laeuft je Zielpfad hoechstens ein `schreibeUnteilbar`;
589 * beide Laeufe gelingen, und am Ende steht der zuletzt angeforderte Stand.
590 *
591 * Der Schluessel ist der aufgeloeste Zielpfad - dieselbe Groesse, ueber die
592 * auch die Freigabeliste entscheidet (`approvePath`, `isApproved`); er stammt
593 * nie aus Rendererdaten.
594 */
595 const laufendeSchreibvorgaenge = new Map<string, Promise<void>>();
596
597 function nacheinanderJeZiel(ziel: string, arbeit: () => Promise<void>): Promise<void> {
598 const schluessel = path.resolve(ziel);
599 const vorherige = laufendeSchreibvorgaenge.get(schluessel);
600 const naechste = vorherige === undefined ? arbeit() : vorherige.then(arbeit, arbeit);
601 // Die Kette darf nicht am Fehlschlag des Vorgaengers haengenbleiben; der
602 // Fehler geht ueber `naechste` an den eigenen Aufrufer.
603 const abgesichert = naechste.catch(() => undefined);
604 laufendeSchreibvorgaenge.set(schluessel, abgesichert);
605 void abgesichert.then(() => {
606 // Aufraeumen, sobald nichts mehr aussteht - sonst waechst die Aufstellung
607 // mit jeder je beschriebenen Datei.
608 if (laufendeSchreibvorgaenge.get(schluessel) === abgesichert) {
609 laufendeSchreibvorgaenge.delete(schluessel);
610 }
611 });
612 return naechste;
613 }
614
615 /**
616 * Schreibt eine Datei unteilbar: erst in eine Nachbardatei, dann umbenennen.
617 *
618 * WARUM NICHT UNMITTELBAR AUF DAS ZIEL
619 *
620 * `fs/promises.writeFile` oeffnet mit dem Kennzeichen 'w' und kuerzt die
621 * vorhandene Datei damit auf null Byte, BEVOR das erste Byte des neuen Inhalts
622 * geschrieben ist. Scheitert das Schreiben danach - kein Platz mehr auf dem
623 * Datentraeger (`describe` uebersetzt ENOSPC eigens fuer diesen Fall),
624 * abgezogener Wechseldatentraeger, weggebrochenes Netzlaufwerk, Absturz -, ist
625 * die zuletzt gesicherte Planunterlage fort und an ihrer Stelle steht eine
626 * leere oder halbe Datei. Der Anwender liest eine Fehlermeldung, die ihn im
627 * Glauben laesst, seine alte Datei stehe noch, und erfaehrt das Gegenteil erst
628 * beim naechsten Oeffnen. Der Sitzungsspeicher fuehrt fuer genau diesen Fall
629 * seit jeher die Zusicherung "kein Datenverlust bei einem Fehler mitten im
630 * Schreiben"; die Langzeitablage - die weitergegebene und archivierte Fassung -
631 * hatte sie nicht.
632 *
633 * WAS DIESE LOESUNG ZUSAGT
634 *
635 * Am Zielort steht stets entweder die alte oder die neue vollstaendige Fassung.
636 * Der Inhalt wird zuerst in die Nachbardatei geschrieben und von dort auf den
637 * Datentraeger geleert; erst danach tritt sie durch Umbenennen an die Stelle
638 * des Ziels. Umbenennen innerhalb eines Datentraegers ist unteilbar - auf NTFS
639 * wie auf POSIX. Scheitert irgendetwas davor, wird die Nachbardatei entfernt
640 * und das Ziel bleibt unberuehrt; ist das Ziel von einem anderen Programm
641 * gesperrt, scheitert das Umbenennen - ebenfalls, ohne die alte Fassung
642 * anzutasten.
643 *
644 * Der Name der Nachbardatei wird hier aus dem bereits bestaetigten Zielpfad
645 * abgeleitet und stammt nicht aus Rendererdaten. Bestaetigt ist der Zielpfad
646 * in jedem der drei Aufrufer durch einen Dialog: bei "Speichern unter" und
647 * "Speichern" ueber die Freigabeliste, die damit die einzige Wache ueber
648 * Zielpfade aus dem Renderer bleibt; bei der Kopie des Fehlerprotokolls
649 * (`speichereProtokollkopie`) unmittelbar aus dem Speichern-Dialog des
650 * Hauptprozesses, ohne dass der Renderer beteiligt ist.
651 *
652 * Weil dieselbe Nachbardatei zu demselben Ziel gehoert, laeuft je Zielpfad
653 * hoechstens ein Vorgang - siehe `nacheinanderJeZiel` darueber. Erst damit gilt
654 * die Zusage auch dann, wenn der Anwender ein zweites Mal speichert, waehrend
655 * der erste Vorgang noch schreibt.
656 */
657 function schreibeUnteilbar(ziel: string, inhalt: Buffer | string): Promise<void> {
658 return nacheinanderJeZiel(ziel, async () => {
659 const nachbar = `${ziel}${TEILDATEI_ENDUNG}`;
660 // Die Laenge zuerst: Sonst scheiterte das Oeffnen der Nachbardatei mit
661 // ENOENT und der Anwender bekaeme eine nachweislich falsche Begruendung.
662 const erlaubt = MAX_NAMENSLAENGE - TEILDATEI_ENDUNG.length;
663 if (path.basename(nachbar).length > MAX_NAMENSLAENGE) {
664 throw new AnwenderFehler(
665 `Der Dateiname ist zu lang: ${path.basename(ziel).length} Zeichen. Zulässig sind ` +
666 `höchstens ${erlaubt} Zeichen, weil beim Speichern eine Nachbardatei mit der Endung ` +
667 `"${TEILDATEI_ENDUNG}" daneben entsteht. Bitte kürzen Sie den Namen.`,
668 );
669 }
670 try {
671 const griff = await open(nachbar, 'w');
672 try {
673 await griff.writeFile(inhalt);
674 await griff.sync();
675 } finally {
676 await griff.close();
677 }
678 await rename(nachbar, ziel);
679 } catch (fehler) {
680 await entferneStill(nachbar);
681 throw fehler;
682 }
683 });
684 }
685
686 /** Raeumt die Nachbardatei ab. Ein Fehler dabei darf den eigentlichen nicht verdecken. */
687 async function entferneStill(pfad: string): Promise<void> {
688 try {
689 await rm(pfad, { force: true });
690 } catch {
691 /* Beim naechsten Speichern wird sie ohnehin ueberschrieben. */
692 }
693 }
694
695 /**
696 * Uebernimmt nur Dateifilter, die auch wirklich wie welche aussehen.
697 * Die Rueckgabe ist bewusst veraenderbar getypt, weil Electrons Dialog-API
698 * `FileFilter[]` mit veraenderbaren Feldern erwartet.
699 */
700 function sanitizeFilters(filters: unknown): { name: string; extensions: string[] }[] {
701 if (!Array.isArray(filters)) return [{ name: 'Alle Dateien', extensions: ['*'] }];
702 const result = filters
703 .filter(
704 (f): f is FileDialogFilter =>
705 typeof f === 'object' && f !== null && typeof (f as FileDialogFilter).name === 'string',
706 )
707 .map((f) => ({
708 name: f.name,
709 extensions: Array.isArray(f.extensions) ? f.extensions.map((e) => String(e)) : ['*'],
710 }));
711 return result.length > 0 ? result : [{ name: 'Alle Dateien', extensions: ['*'] }];
712 }
713
714 function registerFileHandlers(): void {
715 ipcMain.handle(IPC.saveFile, async (_event, raw: SaveFileRequest): Promise<SaveFileResult> => {
716 if (mainWindow === null) {
717 return { ok: false, canceled: false, filePath: null, error: 'Kein Fenster vorhanden.' };
718 }
719 try {
720 /*
721 * `raw` ist als SaveFileRequest getypt, weil beide Seiten shared/ipc.ts
722 * einbinden. Das ist eine Verabredung, keine Pruefung: Ueber den Kanal
723 * kommt, was der Renderer sendet, und der Linter beanstandet die
724 * Absicherungen hier deshalb zu Unrecht. Ein Renderer, der `undefined`
725 * oder ein leeres Objekt schickt, soll den Speichern-Dialog mit dem
726 * Vorgabenamen oeffnen und nicht den Hauptprozess in eine Ausnahme
727 * laufen lassen.
728 */
729 const result = await dialog.showSaveDialog(mainWindow, {
730 title: 'Speichern unter',
731 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- IPC-Nutzlast ist die Behauptung des Aufrufers; siehe darueber
732 defaultPath: path.basename(String(raw?.defaultName ?? 'Projekt')),
733 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- IPC-Nutzlast ist die Behauptung des Aufrufers
734 filters: sanitizeFilters(raw?.filters),
735 properties: ['createDirectory', 'showOverwriteConfirmation'],
736 });
737 // Auch `filePath` wird gegen undefined geprueft, obwohl Electron es als
738 // Zeichenkette fuehrt: Der Wert geht unmittelbar in approvePath und
739 // damit in die Freigabeliste ein. Was diese Wache passiert, darf
740 // geschrieben werden - hier auf die Typzusage zu bauen waere die
741 // falsche Stelle dafuer.
742 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- der Wert geht in die Freigabeliste ein; siehe darueber
743 if (result.canceled || result.filePath === undefined) {
744 return { ok: false, canceled: true, filePath: null, error: null };
745 }
746 const target = approvePath(result.filePath);
747 await schreibeUnteilbar(target, toBuffer(raw.data));
748 return { ok: true, canceled: false, filePath: target, error: null };
749 } catch (error) {
750 return { ok: false, canceled: false, filePath: null, error: describe(error) };
751 }
752 });
753
754 ipcMain.handle(IPC.openFile, async (_event, filters: unknown): Promise<OpenFileResult> => {
755 if (mainWindow === null) {
756 return {
757 ok: false,
758 canceled: false,
759 filePath: null,
760 content: null,
761 error: 'Kein Fenster vorhanden.',
762 };
763 }
764 try {
765 const result = await dialog.showOpenDialog(mainWindow, {
766 title: 'Projekt öffnen',
767 filters: sanitizeFilters(filters),
768 properties: ['openFile'],
769 });
770 const selected = result.filePaths[0];
771 if (result.canceled || selected === undefined) {
772 return { ok: false, canceled: true, filePath: null, content: null, error: null };
773 }
774 const target = approvePath(selected);
775 const zuGross = await pruefeLesbareGroesse(target);
776 if (zuGross !== null) {
777 return { ok: false, canceled: false, filePath: null, content: null, error: zuGross };
778 }
779 const content = await readFile(target, 'utf8');
780 return { ok: true, canceled: false, filePath: target, content, error: null };
781 } catch (error) {
782 return { ok: false, canceled: false, filePath: null, content: null, error: describe(error) };
783 }
784 });
785
786 ipcMain.handle(
787 IPC.writeFile,
788 async (
789 _event,
790 raw: { filePath: string; data: string | Uint8Array },
791 ): Promise<SaveFileResult> => {
792 // Der Typ von `raw` ist eine Zusage des Renderers, keine Pruefung.
793 // Faellt sie aus, darf der Kanal nicht in eine Ausnahme laufen und ohne
794 // Antwort bleiben; der Linter haelt die beiden Absicherungen deshalb zu
795 // Unrecht fuer ueberfluessig. Was sie auffangen: `undefined` und `null`
796 // werden zur leeren Zeichenkette und scheitern gleich darunter an
797 // `=== ''`. Alles andere macht String zu einer Zeichenkette, die in der
798 // Freigabeliste nicht vorkommt ('[object Object]', '42'), und scheitert
799 // an isApproved. Der Leseweg unten (readFile) weist Nicht-Zeichenketten
800 // dagegen selbst ab - hier laufen sie erst an der Freigabeliste auf.
801 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- IPC-Nutzlast vor der Freigabeliste; siehe darueber
802 const filePath = String(raw?.filePath ?? '');
803 // Ohne vorherige Bestaetigung durch den Anwender wird nichts geschrieben.
804 if (filePath === '' || !isApproved(filePath)) {
805 return {
806 ok: false,
807 canceled: false,
808 filePath: null,
809 error:
810 'Der Pfad wurde nicht über einen Dateidialog bestätigt. Bitte "Speichern unter" verwenden.',
811 };
812 }
813 try {
814 await schreibeUnteilbar(filePath, toBuffer(raw.data));
815 return { ok: true, canceled: false, filePath, error: null };
816 } catch (error) {
817 return { ok: false, canceled: false, filePath: null, error: describe(error) };
818 }
819 },
820 );
821
822 ipcMain.handle(IPC.readFile, async (_event, rawPath: unknown): Promise<OpenFileResult> => {
823 // Nicht String(...): Ein Objekt aus dem Renderer wuerde daraus
824 // '[object Object]' machen - eine Zeichenkette, die zwar an der
825 // Freigabeliste scheitert, aber eben erst dort. Was keine
826 // Zeichenkette ist, wird hier abgewiesen.
827 const filePath = typeof rawPath === 'string' ? rawPath : '';
828 if (filePath === '' || !isApproved(filePath)) {
829 return {
830 ok: false,
831 canceled: false,
832 filePath: null,
833 content: null,
834 error: 'Der Pfad wurde nicht über einen Dateidialog bestätigt.',
835 };
836 }
837 try {
838 const zuGross = await pruefeLesbareGroesse(filePath);
839 if (zuGross !== null) {
840 return { ok: false, canceled: false, filePath: null, content: null, error: zuGross };
841 }
842 const content = await readFile(filePath, 'utf8');
843 return { ok: true, canceled: false, filePath, content, error: null };
844 } catch (error) {
845 return { ok: false, canceled: false, filePath: null, content: null, error: describe(error) };
846 }
847 });
848
849 ipcMain.on(IPC.setDirty, (_event, dirty: unknown) => {
850 rendererIsDirty = dirty === true;
851 // Zwei Fragezeichen, zwei Gruende: `mainWindow` kann null sein, und
852 // `setDocumentEdited` ist der gepunktete Kreis in der Titelleiste von
853 // macOS. Electron fuehrt die Methode im Typ fuer alle Betriebssysteme,
854 // der Linter beanstandet den zweiten Aufruf deshalb. Er bleibt: Eine
855 // fehlende Zierde darf den Aenderungsvermerk nicht in eine Ausnahme
856 // laufen lassen, und Windows ist die Zielplattform.
857 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- setDocumentEdited gibt es nur auf macOS; siehe darueber
858 mainWindow?.setDocumentEdited?.(rendererIsDirty);
859 });
860
861 ipcMain.on(IPC.closeResponse, (_event, allow: unknown) => {
862 pendingCloseResolve?.(allow === true);
863 });
864
865 ipcMain.on(IPC.logError, (_event, bericht: ErrorReport) => {
866 protokolliereMeldung(bericht);
867 });
868
869 ipcMain.handle(IPC.logPath, () => protokollPfad());
870
871 /*
872 * Verweise nach draussen - Schluessel herein, Adresse aus dem Hauptprozess.
873 *
874 * Kein Rueckfall und keine Meldung an den Renderer: Was nicht in der Liste
875 * steht, oeffnet nichts. Ein Protokolleintrag bleibt trotzdem, denn ein
876 * unbekannter Schluessel heisst entweder, dass jemand die Liste geaendert
877 * hat, ohne die Oberflaeche mitzuziehen - oder dass etwas sendet, das nicht
878 * die Oberflaeche ist.
879 */
880 ipcMain.on(IPC.oeffneVerweis, (_event, ziel: unknown) => {
881 const adresse = verweisadresse(ziel);
882 if (adresse === null) {
883 protokolliere('Verweis', `Unbekanntes Verweisziel abgewiesen: ${String(ziel)}`);
884 return;
885 }
886 void shell.openExternal(adresse);
887 });
888
889 ipcMain.handle(IPC.fetchMapImage, async (_event, anfrage: KartenAnfrage) => {
890 const antwort = await holeKartenbild(anfrage);
891 // Nur die technische Einordnung ins Protokoll, nicht die Antwort des
892 // Dienstes: Sie kommt aus fremder Hand. Ein WMS meldet Fehler nach WMS 1.3.0
893 // als `ServiceExceptionReport` mit Status 200 und zitiert darin
894 // ueblicherweise die gestellte Anfrage - samt BBOX, also den UTM-Koordinaten
895 // des geplanten Knotenpunkts. `karte.ts` reicht bis zu 400 Byte dieses
896 // Koerpers in `fehler` weiter; von dort stand der Standort der Planung
897 // woertlich in einer Datei, die der Anwender laut Hilfefenster einer
898 // Fehlermeldung beilegen soll (Befund 31). Aus demselben Grund bleibt auch
899 // die Abrufadresse aussen vor - die BBOX steht in ihrer Abfrage. Was der
900 // Dienst gemeldet hat, sieht der Anwender an der Oberflaeche;
901 // `antwort.fehler` geht unveraendert dorthin zurueck.
902 if (!antwort.ok) protokolliere('Kartendienst', `Abruf über ${dienstname(anfrage)} misslungen.`);
903 return antwort;
904 });
905 }
906
907 /**
908 * Rechnername des angefragten Dienstes - eine technische Angabe.
909 *
910 * Die Adresse kommt aus dem Anzeigeprozess, und gerade der Fehlschlagsgrund
911 * "nicht freigegeben" bringt einen Namen mit, der nicht in der festen Liste aus
912 * electron/karte.ts steht. Ausgegeben wird deshalb nur, was in dieser Liste
913 * steht; alles andere wird benannt statt zitiert. Sonst schriebe der
914 * Anzeigeprozess ueber diesen Umweg frei gewaehlten Text in eine Datei, die
915 * "keine Projektinhalte" zusagt.
916 */
917 function dienstname(anfrage: KartenAnfrage): string {
918 // Der Zugriff steht im `try`: Kommt aus dem Anzeigeprozess etwas anderes als
919 // eine Anfrage, wirft er - und der Protokolleintrag bleibt trotzdem stehen.
920 try {
921 // `hostname` und nicht `host`, damit hier dieselbe Groesse verglichen wird,
922 // ueber die auch `istErlaubt` in electron/karte.ts entscheidet.
923 const name = new URL(String(anfrage.url)).hostname.toLowerCase();
924 return erlaubteRechner().includes(name) ? name : 'einen nicht freigegebenen Dienst';
925 } catch {
926 return 'einen unbekannten Dienst';
927 }
928 }
929
930 /**
931 * Fehler im Hauptprozess.
932 *
933 * Ohne diese Behandlung beendet Electron die Anwendung bei einem unbehandelten
934 * Fehler mit einem nichtssagenden Hinweis - oder, je nach Zeitpunkt, ganz ohne
935 * Meldung. Beides macht eine spaetere Untersuchung unmoeglich. Hier wird jeder
936 * Fehler zuerst protokolliert; erst danach entscheidet sich, ob weitergearbeitet
937 * werden kann.
938 */
939 function registerProcessHandlers(): void {
940 process.on('uncaughtException', (error) => {
941 protokolliere('Hauptprozess/Ausnahme', error.message, error.stack);
942 if (mainWindow !== null && !mainWindow.isDestroyed()) {
943 // Der Menueweg ist hier erreichbar: Der Kasten erscheint nur bei
944 // bestehendem Fenster, und mit dem Horcher auf `uncaughtException`
945 // laeuft der Hauptprozess weiter. Nach dem angeratenen Neustart steht er
946 // ohnehin wieder bereit - das Protokoll ueberdauert ihn.
947 dialog.showErrorBox(
948 'Unerwarteter Fehler',
949 'Es ist ein unerwarteter Fehler aufgetreten. Speichern Sie Ihr Projekt und starten Sie ' +
950 'das Programm neu.\n\n' +
951 `Einzelheiten stehen im Fehlerprotokoll:\n${protokollPfad()}\n\n` +
952 HINWEIS_PROTOKOLLKOPIE,
953 );
954 }
955 });
956
957 process.on('unhandledRejection', (reason) => {
958 const error = reason instanceof Error ? reason : new Error(String(reason));
959 protokolliere('Hauptprozess/Zusage', error.message, error.stack);
960 });
961
962 app.on('child-process-gone', (_event, details) => {
963 // Wie bei 'render-process-gone' weiter oben: Die Abfrage auf `exitCode`
964 // ist nach Electrons Typbeschreibung ueberfluessig und bleibt trotzdem
965 // stehen - eine Protokollzeile ueber einen Absturz soll nicht selbst mit
966 // "undefined" enden.
967 protokolliere(
968 'Hilfsprozess',
969 `${details.type} beendet · Grund: ${details.reason}${
970 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld kann zur Laufzeit fehlen; siehe darueber
971 details.exitCode !== undefined ? ` · Beendigungscode ${details.exitCode}` : ''
972 }`,
973 );
974 });
975 }
976
977 function describe(error: unknown): string {
978 if (error instanceof AnwenderFehler) return error.message;
979 if (error instanceof Error) {
980 const code = (error as NodeJS.ErrnoException).code;
981 if (code === 'EACCES' || code === 'EPERM') {
982 return 'Kein Schreibrecht für diesen Ort. Wählen Sie einen anderen Ordner.';
983 }
984 if (code === 'ENOSPC') return 'Auf dem Datenträger ist kein Platz mehr frei.';
985 if (code === 'ENOENT') return 'Die Datei oder der Ordner wurde nicht gefunden.';
986 if (code === 'EBUSY') return 'Die Datei wird von einem anderen Programm verwendet.';
987 // Auffangzweig. Was hier ankommt - EIO, ELOOP, EMFILE und alles ohne `code`
988 // -, traegt nur die Meldung der Laufzeitumgebung, und die steht auf
989 // Englisch. Sie wird nicht unterschlagen, sie taugt fuer die Fehlersuche;
990 // aber der Satz davor sagt auf Deutsch, was ueberhaupt geschehen ist, und
991 // schreibt den fremden Text als das aus, was er ist.
992 return `Der Vorgang ist fehlgeschlagen. Das System meldet: ${error.message}`;
993 }
994 return `Der Vorgang ist fehlgeschlagen. Das System meldet: ${String(error)}`;
995 }
996
997 // --- Fehlerprotokoll weitergeben --------------------------------------------
998
999 /**
1000 * Satz der beiden Fehlerdialoge, der auf die Kopie des Protokolls verweist.
1001 *
1002 * Die Dialoge nennen den Pfad weiterhin: Meist stimmt er, und wer ihn kennt,
1003 * findet die Datei. Je nach Installationsart stimmt er nur, wenn
1004 * %APPDATA%\lsa-planer-professional vorher schon bestand - vom Setup oder vom
1005 * tragbaren Programm; sonst findet der Explorer die Datei dort nicht (siehe
1006 * Kopf von electron/protokoll.ts). Der Menueweg fuehrt in jedem Fall zu einer
1007 * Datei. Die Beschriftung steht woertlich wie in electron/menu.ts - ein Dialog,
1008 * der einen Eintrag anders nennt, als das Menue ihn zeigt, schickt den Anwender
1009 * auf die Suche.
1010 *
1011 * Er steht nur in Dialogen, bei denen das Menue danach noch erreichbar ist;
1012 * die Begruendung steht jeweils an der Stelle.
1013 */
1014 const HINWEIS_PROTOKOLLKOPIE =
1015 'Über „Hilfe → Fehlerprotokoll speichern …“ legen Sie eine Kopie an einem Ort Ihrer Wahl an.';
1016
1017 /** Titel des Fehlerkastens, wenn die Kopie nicht zustande kommt. */
1018 const TITEL_KOPIE_MISSLUNGEN = 'Fehlerprotokoll nicht gespeichert';
1019
1020 /**
1021 * Vorgeschlagener Ort der Kopie: der Dokumente-Ordner, mit oertlichem Datum im
1022 * Namen.
1023 *
1024 * `app.getPath` wirft laut Electron, wenn sich der Ordner nicht ermitteln
1025 * laesst. Dann bleibt der blosse Dateiname, und der Dialog beginnt, wo das
1026 * Betriebssystem ihn beginnen laesst - ein fehlender Vorschlag ist kein Grund,
1027 * die Kopie zu verweigern.
1028 */
1029 function protokollvorschlag(): string {
1030 const name = protokollDateiname(new Date());
1031 try {
1032 return path.join(app.getPath('documents'), name);
1033 } catch {
1034 return name;
1035 }
1036 }
1037
1038 /**
1039 * Hilfe -> "Fehlerprotokoll speichern …": schreibt eine Kopie des Protokolls an
1040 * einen Ort, den der Anwender im Dialog waehlt.
1041 *
1042 * Warum es den Weg gibt, steht im Kopf von electron/protokoll.ts (umgeleitete
1043 * Benutzerdaten); was in die Kopie kommt, bei `protokollkopie`.
1044 *
1045 * WAS HIER BEWUSST NICHT GESCHIEHT
1046 *
1047 * - Kein IPC-Kanal: Der Befehl entsteht im Menue des Hauptprozesses, und der
1048 * Renderer ist weder Ausloeser noch Datenquelle. Die Quelle ist fest
1049 * (`protokollPfad` und die abgeloeste Datei davor), und keine der beiden
1050 * ist als Ziel zugelassen (`istProtokolldatei`, seit 5.43.0) - auch nicht
1051 * unter einem anderen Pfad derselben Datei.
1052 * - Keine Freigabe: Das Ziel kommt allein aus dem Dialog und geht NICHT durch
1053 * `approvePath`. Die Freigabeliste sagt, was der Renderer ohne neuen Dialog
1054 * lesen und beschreiben darf - mit dieser Datei hat er nichts zu tun.
1055 * - Kein eigener Schreibweg: Geschrieben wird ueber `schreibeUnteilbar`,
1056 * dieselbe Stelle wie fuer die Projektdatei. Eine vorhandene Datei am Ziel -
1057 * etwa die Kopie vom Vortag - ueberlebt einen Fehlschlag.
1058 * - Nichts wird verschluckt: Jeder Fehlschlag endet in einem Fehlerkasten.
1059 * Ins Protokoll geht er nicht, denn die Meldung des Systems nennt den
1060 * gewaehlten Zielpfad, und ein Ordnername kann Projektinhalt sein (vgl.
1061 * Befund 31 am Griff `IPC.fetchMapImage`).
1062 *
1063 * Gelesen wird NACH dem Dialog, damit die Kopie enthaelt, was bis dahin
1064 * hinzukam. Lese- und Schreibfehler haben getrennte Meldungen: `describe` raet
1065 * bei EACCES, einen anderen Ordner zu waehlen - bei einem Protokoll, das sich
1066 * nicht lesen laesst, waere das ein falscher Rat.
1067 *
1068 * Die Zusage wird nie verworfen; der Menueeintrag gibt sie deshalb mit `void` ab.
1069 */
1070 async function speichereProtokollkopie(): Promise<void> {
1071 try {
1072 const optionen: SaveDialogOptions = {
1073 title: 'Fehlerprotokoll speichern',
1074 defaultPath: protokollvorschlag(),
1075 filters: [{ name: 'Textdatei', extensions: ['txt'] }],
1076 properties: ['createDirectory', 'showOverwriteConfirmation'],
1077 };
1078 const ergebnis =
1079 mainWindow === null
1080 ? await dialog.showSaveDialog(optionen)
1081 : await dialog.showSaveDialog(mainWindow, optionen);
1082 // `!filePath` statt `=== ''`: Die Typbeschreibung verspricht eine
1083 // Zeichenkette, aeltere Electron-Fassungen lieferten beim Abbrechen
1084 // `undefined`. Kaeme ein undefiniertes Ziel bis `schreibeUnteilbar`, wuerfe
1085 // `path.resolve` in `nacheinanderJeZiel`, bevor eine Datei entsteht - und
1086 // der Anwender, der nichts gewaehlt hat, bekaeme den Fehlerkasten unten mit
1087 // der englischen Meldung des Laufzeitsystems ("The "paths[0]" argument
1088 // must be of type string").
1089 if (ergebnis.canceled || !ergebnis.filePath) return;
1090
1091 // Nicht an die Stelle des Protokolls selbst - siehe `istProtokolldatei`.
1092 // Vor dem Lesen, und ohne zu schreiben: Weder die laufende noch die
1093 // abgeloeste Datei darf durch ihre eigene Kopie ersetzt werden.
1094 if (istProtokolldatei(ergebnis.filePath, protokollPfad())) {
1095 dialog.showErrorBox(
1096 TITEL_KOPIE_MISSLUNGEN,
1097 'Die Kopie kann nicht an die Stelle des Protokolls selbst geschrieben werden. ' +
1098 'Bitte wählen Sie einen anderen Ort oder Dateinamen.',
1099 );
1100 return;
1101 }
1102
1103 let inhalt: string;
1104 try {
1105 inhalt = protokollkopie(protokollPfad(), liesProtokolldatei);
1106 } catch (fehler) {
1107 dialog.showErrorBox(
1108 TITEL_KOPIE_MISSLUNGEN,
1109 'Das Fehlerprotokoll ließ sich nicht lesen; es wurde keine Kopie angelegt.\n\n' +
1110 `Das System meldet: ${fehler instanceof Error ? fehler.message : String(fehler)}`,
1111 );
1112 return;
1113 }
1114 await schreibeUnteilbar(ergebnis.filePath, inhalt);
1115 } catch (fehler) {
1116 dialog.showErrorBox(
1117 TITEL_KOPIE_MISSLUNGEN,
1118 `Die Kopie des Fehlerprotokolls ließ sich nicht speichern.\n\n${describe(fehler)}`,
1119 );
1120 }
1121 }
1122
1123 // --- Programmablauf ---------------------------------------------------------
1124
1125 // Nur eine Instanz: sonst arbeiten zwei Fenster auf demselben Sitzungsspeicher
1126 // und ueberschreiben sich gegenseitig.
1127 if (!app.requestSingleInstanceLock()) {
1128 app.quit();
1129 } else {
1130 app.on('second-instance', () => {
1131 if (mainWindow === null) return;
1132 if (mainWindow.isMinimized()) mainWindow.restore();
1133 mainWindow.focus();
1134 });
1135
1136 registerProcessHandlers();
1137
1138 app.whenReady().then(
1139 () => {
1140 protokolliereStart();
1141 hardenSession();
1142 registerFileHandlers();
1143 // Vor dem Fenster: Die Stufe geht als `zoomFactor` in seine Vorgaben ein.
1144 schriftgroesse = liesSchriftgroesse(ansichtsDatei());
1145 schriftSicherung = schriftsicherung(ansichtsDatei(), schriftgroesse);
1146 beobachteErscheinungsbild();
1147 createWindow();
1148
1149 app.on('activate', () => {
1150 if (BrowserWindow.getAllWindows().length === 0) createWindow();
1151 });
1152 },
1153 (error: unknown) => {
1154 dialog.showErrorBox('Startfehler', describe(error));
1155 app.quit();
1156 },
1157 );
1158
1159 // Was noch aussteht, geht vor dem Beenden heraus: Eine Schriftgroesse, die
1160 // eine halbe Sekunde vor dem Schliessen eingestellt wurde, darf nicht
1161 // verlorengehen.
1162 app.on('before-quit', () => {
1163 schriftSicherung?.jetzt();
1164 });
1165
1166 app.on('window-all-closed', () => {
1167 if (process.platform !== 'darwin') app.quit();
1168 });
1169
1170 // Keine Zertifikatsausnahmen und keine Fremdinhalte.
1171 app.on('web-contents-created', (_event, contents) => {
1172 contents.on('will-attach-webview', (event) => {
1173 event.preventDefault();
1174 });
1175 });
1176 }