feat: report UI crashes instead of showing a blank window
Two white-screen reports are open — one after logging a 10 GHz QSO, one on
starting a DVK auto-call — and neither could be investigated, for the same
reason: a render error emptied the window and left NOTHING. No line in
opslog.log, no dialog, nothing the operator could send but the words "white
screen". A fault the user cannot report is a fault that cannot be fixed.
So before hunting either trigger, the reporting path:
- An error boundary catches a render throw, writes the error and component
stack to the app log through the new LogUIError binding, and shows it on
screen — selectable, with Copy and Reload.
- Global handlers catch what a boundary cannot: throws from timers, event
handlers and rejected promises. An auto-call loop lives entirely in
callbacks, which is exactly where the second report came from, and those
leave no trace at all today.
It deliberately does not try to resume: React cannot promise sane state after a
render throw, and a half-working logger would be worse than a clear stop.
This does not fix either crash. It makes the next occurrence arrive with its
cause attached, which is the step that has been missing.
This commit is contained in:
@@ -1267,6 +1267,27 @@ type StartupStatus struct {
|
|||||||
|
|
||||||
// GetStartupStatus exposes whatever happened during startup so the UI
|
// GetStartupStatus exposes whatever happened during startup so the UI
|
||||||
// can show a useful error instead of just "db not initialized".
|
// 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 {
|
func (a *App) GetStartupStatus() StartupStatus {
|
||||||
return StartupStatus{
|
return StartupStatus{
|
||||||
OK: a.startupErr == "",
|
OK: a.startupErr == "",
|
||||||
|
|||||||
+4
-2
@@ -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.",
|
"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.",
|
"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.",
|
"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": [
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<Props, State> {
|
||||||
|
state: State = { error: null, info: '' };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
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 (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||||
|
overflow: 'auto', zIndex: 9999,
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: 820, width: '100%' }}>
|
||||||
|
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
|
||||||
|
OpsLog hit an error and stopped drawing
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<pre style={{
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
|
||||||
|
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
|
||||||
|
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
|
||||||
|
}}>{report}</pre>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { void navigator.clipboard?.writeText(report); }}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
|
||||||
|
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Copy the details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
|
||||||
|
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Reload the window
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import App from './App'
|
|||||||
import { syncPortablePrefs } from './lib/uiPref'
|
import { syncPortablePrefs } from './lib/uiPref'
|
||||||
import { I18nProvider } from './lib/i18n'
|
import { I18nProvider } from './lib/i18n'
|
||||||
import { ThemeProvider, initTheme } from './lib/theme'
|
import { ThemeProvider, initTheme } from './lib/theme'
|
||||||
|
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
@@ -17,13 +18,19 @@ syncPortablePrefs().finally(() => {
|
|||||||
// Stamp the persisted theme onto <html> before render so the correct
|
// Stamp the persisted theme onto <html> before render so the correct
|
||||||
// palette is applied immediately (portable pref hydrated just above).
|
// palette is applied immediately (portable pref hydrated just above).
|
||||||
initTheme()
|
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(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App/>
|
<App/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+2
@@ -637,6 +637,8 @@ export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
|||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||||
|
|||||||
@@ -1222,6 +1222,10 @@ export function LogUDPLoggedADIF(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function LookupCallsign(arg1) {
|
||||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user