lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 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 | } |