diff --git a/app.go b/app.go index 4a5a206..a20309f 100644 --- a/app.go +++ b/app.go @@ -1267,6 +1267,27 @@ type StartupStatus struct { // GetStartupStatus exposes whatever happened during startup so the UI // can show a useful error instead of just "db not initialized". +// LogUIError records a crash or unhandled error from the interface in the app +// log, with whatever context the window can give. +// +// Until this existed, a render error emptied the window and left NOTHING: no +// line in opslog.log, no dialog. Two separate white-screen reports — one after +// logging a 10 GHz QSO, one on starting an auto-call — could not be diagnosed at +// all, because the only evidence lived in a console the operator had to know to +// open. A fault the user cannot report is a fault that cannot be fixed. +func (a *App) LogUIError(kind, message, stack string) { + msg := strings.TrimSpace(message) + if msg == "" { + msg = "(no message)" + } + applog.Printf("ui %s: %s", strings.TrimSpace(kind), msg) + if st := strings.TrimSpace(stack); st != "" { + // The stack goes in whole: minified frames are still the difference between + // "somewhere in the app" and one component. + applog.Printf("ui stack: %s", st) + } +} + func (a *App) GetStartupStatus() StartupStatus { return StartupStatus{ OK: a.startupErr == "", diff --git a/changelog.json b/changelog.json index 877430b..d1954aa 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,8 @@ "Rotator control now covers any GS-232A controller, ERC (Easy Rotor Control) included: the serial speed is selectable and the entry is named after the protocol rather than one device. Set the ERC to GS-232 emulation, not Hy-Gain DCU-1.", "Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT.", "Icom and Xiegu: a CI-V frame read at the wrong offset no longer turns into a frequency. Values such as 16445550000 Hz appeared in the status bar while the rig sat on 145.550 MHz — the decoder treated non-decimal bytes as digits. Such frames are now refused and the last good reading stands.", - "Settings → CAT can log the CI-V protocol: every frame to and from an Icom or Xiegu, as hex. For reporting a radio that answers oddly — a button that does the wrong thing, a frequency that jumps — where a description alone leaves the cause to guesswork." + "Settings → CAT can log the CI-V protocol: every frame to and from an Icom or Xiegu, as hex. For reporting a radio that answers oddly — a button that does the wrong thing, a frequency that jumps — where a description alone leaves the cause to guesswork.", + "A crash in the window no longer leaves a blank screen. The error is written to the app log and shown on screen, selectable, with a button to copy it and one to reload — so a fault can be reported with its cause instead of a description of an empty window." ], "fr": [ "Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.", @@ -18,7 +19,8 @@ "Le contrôle de rotator couvre désormais tout contrôleur GS-232A, ERC (Easy Rotor Control) compris : la vitesse du port série est sélectionnable et l'entrée porte le nom du protocole plutôt que celui d'un seul appareil. Réglez l'ERC sur l'émulation GS-232, pas Hy-Gain DCU-1.", "CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT.", "Icom et Xiegu : une trame CI-V lue au mauvais décalage ne se transforme plus en fréquence. Des valeurs comme 16445550000 Hz apparaissaient dans la barre d'état alors que la radio était sur 145,550 MHz — le décodeur prenait des octets non décimaux pour des chiffres. Ces trames sont désormais refusées et la dernière lecture valable est conservée.", - "Réglages → CAT permet de journaliser le protocole CI-V : chaque trame échangée avec un Icom ou un Xiegu, en hexadécimal. Pour signaler une radio qui répond de travers — un bouton qui fait autre chose, une fréquence qui saute — là où une description seule laisse la cause à la devinette." + "Réglages → CAT permet de journaliser le protocole CI-V : chaque trame échangée avec un Icom ou un Xiegu, en hexadécimal. Pour signaler une radio qui répond de travers — un bouton qui fait autre chose, une fréquence qui saute — là où une description seule laisse la cause à la devinette.", + "Un plantage de la fenêtre ne laisse plus un écran vide. L'erreur est écrite dans le journal et affichée à l'écran, sélectionnable, avec un bouton pour la copier et un pour recharger — de quoi signaler un défaut avec sa cause plutôt qu'une description de fenêtre vide." ] }, { diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..d8a401c --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { LogUIError } from '../../wailsjs/go/main/App'; + +// A render error used to empty the window and leave nothing behind: no line in +// opslog.log, no dialog, no way for the operator to say more than "white screen". +// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK +// auto-call — stayed undiagnosed for weeks for exactly that reason. +// +// So this does two things, in order of importance: +// +// 1. Writes the error and its stack to the app log through LogUIError, so the +// next report arrives WITH the cause instead of a description of a blank +// screen. +// 2. Shows it, with the text selectable and a button to copy it, because the +// person who can act on it is the one looking at the screen. +// +// It deliberately does NOT try to recover the tree. React cannot guarantee the +// state is sane after a render throw, and a half-working logger is worse than +// one that says plainly what happened and offers a reload. + +type Props = { children: React.ReactNode }; +type State = { error: Error | null; info: string }; + +export class ErrorBoundary extends React.Component { + state: State = { error: null, info: '' }; + + static getDerivedStateFromError(error: Error): Partial { + return { error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + const stack = (info?.componentStack || error.stack || '').trim(); + this.setState({ info: stack }); + // Never let the reporting path throw: it runs while the app is already broken. + try { + LogUIError('render crash', `${error.name}: ${error.message}`, stack); + } catch { + /* the backend may be gone too — the on-screen copy still works */ + } + } + + render() { + const { error, info } = this.state; + if (!error) return this.props.children; + + const report = `${error.name}: ${error.message}\n\n${info}`; + return ( +
+
+

+ OpsLog hit an error and stopped drawing +

+

+ Your log is safe — this is the window, not the database. The details below + are already in the app log; send them with your report. +

+
{report}
+
+ + +
+
+
+ ); + } +} + +// installGlobalErrorLogging catches what a boundary cannot: an error thrown from +// a timer, an event handler or a rejected promise. Those do not unmount the tree, +// so they leave no trace at all — and an auto-call loop lives entirely in +// callbacks, which is precisely where a white screen was reported. +export function installGlobalErrorLogging() { + const report = (kind: string, message: string, stack?: string) => { + try { + LogUIError(kind, message, stack || ''); + } catch { + /* nothing more we can do from here */ + } + }; + + window.addEventListener('error', (e: ErrorEvent) => { + const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : ''; + report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack); + }); + + window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { + const r: any = e.reason; + report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack); + }); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7d8b1f2..6f6d693 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,7 @@ import App from './App' import { syncPortablePrefs } from './lib/uiPref' import { I18nProvider } from './lib/i18n' import { ThemeProvider, initTheme } from './lib/theme' +import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary' const container = document.getElementById('root') @@ -17,13 +18,19 @@ syncPortablePrefs().finally(() => { // Stamp the persisted theme onto before render so the correct // palette is applied immediately (portable pref hydrated just above). initTheme() + // Catch what a boundary cannot: errors thrown from timers, event handlers and + // rejected promises. Installed before the first render so a fault during + // startup is reported too. + installGlobalErrorLogging() root.render( - - - - - + + + + + + + ) }) diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 462cdbc..99c7f0c 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -637,6 +637,8 @@ export function LoTWUserInfo(arg1:string):Promise; export function LogUDPLoggedADIF(arg1:string):Promise; +export function LogUIError(arg1:string,arg2:string,arg3:string):Promise; + export function LookupCallsign(arg1:string):Promise; export function LookupCallsignFresh(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index ff324b9..292da39 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1222,6 +1222,10 @@ export function LogUDPLoggedADIF(arg1) { return window['go']['main']['App']['LogUDPLoggedADIF'](arg1); } +export function LogUIError(arg1, arg2, arg3) { + return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3); +} + export function LookupCallsign(arg1) { return window['go']['main']['App']['LookupCallsign'](arg1); }