From ae5aa7b5477574206629a83a15cba3484e7b8eab Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 25 Aug 2026 21:03:16 +0200 Subject: [PATCH] fix(startup): a launch that fails before the window says why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a Windows 10 machine: the process appears, no data folder is created, nothing starts. There was nothing to read because the only log OpsLog has lives inside the folder that was never created. Three exits happen before any window or log exists, and all three were silent. Another instance already running — correct to refuse, since two would fight over the rig, but indistinguishable from a crash. The data folder unwritable — it is created BESIDE the executable, so a copy dropped into Program Files is refused by Windows outright. And wails.Run failing, which is where a missing WebView2 runtime lands; its error went to println, which in a GUI-subsystem program goes nowhere at all. Each now writes to %LOCALAPPDATA%\OpsLog\startup.log — a folder Windows guarantees the user can write to, whatever OpsLog was installed into — and shows a message box naming the fault and what to do about it. --- bootlog.go | 95 +++++++++++++++++++++++++++++++++++++++++++++ changelog.json | 6 ++- fatalbox_other.go | 6 +++ fatalbox_windows.go | 27 +++++++++++++ main.go | 26 ++++++++++++- 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 bootlog.go create mode 100644 fatalbox_other.go create mode 100644 fatalbox_windows.go diff --git a/bootlog.go b/bootlog.go new file mode 100644 index 0000000..26bcbf3 --- /dev/null +++ b/bootlog.go @@ -0,0 +1,95 @@ +package main + +// The first breadcrumbs, written before anything else can fail. +// +// OpsLog keeps its log in the data folder, which lives beside the executable — +// so every fault that happens BEFORE that folder exists is invisible. Reported +// from a Windows 10 machine: "the process appears, no data folder is created, +// nothing starts", with no file anywhere to say why. There was nothing to read +// because the only place we write to had not been created yet. +// +// This writes to %LOCALAPPDATA%\OpsLog\startup.log instead: a folder Windows +// guarantees is writable for the user, whatever OpsLog itself was installed +// into. It records the handful of milestones between the process starting and +// the window appearing, and nothing else — it is not a second log, it is the +// answer to "it does not start". + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// bootLogPath is the file, or "" when even LOCALAPPDATA is unavailable. +func bootLogPath() string { + dir := os.Getenv("LOCALAPPDATA") + if strings.TrimSpace(dir) == "" { + dir = os.TempDir() + } + if dir == "" { + return "" + } + dir = filepath.Join(dir, "OpsLog") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "" + } + return filepath.Join(dir, "startup.log") +} + +// bootLog appends one line. Never fails loudly: it exists to explain a failure, +// so it must not become one. +func bootLog(format string, args ...any) { + p := bootLogPath() + if p == "" { + return + } + f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + defer f.Close() + // Trimmed when it gets long. A startup log that grows for two years is a + // file nobody opens, and the interesting launch is always the last one. + if fi, err := f.Stat(); err == nil && fi.Size() > 256*1024 { + f.Close() + _ = os.Remove(p) + f, err = os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + defer f.Close() + } + fmt.Fprintf(f, "%s %s\n", time.Now().Format("2006-01-02 15:04:05.000"), fmt.Sprintf(format, args...)) +} + +// bootLogLaunch records what was launched and from where. +func bootLogLaunch() { + exe, _ := os.Executable() + bootLog("launch: %s %v", exe, os.Args[1:]) +} + +// checkDataDirWritable makes sure the folder OpsLog keeps everything in can +// actually be created and written to, and says exactly what failed if not. +// +// The data folder sits beside the executable, which is fine on a stick or in a +// home directory and refused outright under Program Files — where Windows +// silently denies the write to anything not elevated. That refusal used to end +// the launch with no window and no message. +func checkDataDirWritable() error { + dir, err := userDataDir() + if err != nil { + return fmt.Errorf("cannot work out where to keep the data: %w", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("cannot create the data folder %s: %w", dir, err) + } + probe := filepath.Join(dir, ".writetest") + if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil { + return fmt.Errorf("the data folder %s cannot be written to: %w", dir, err) + } + _ = os.Remove(probe) + bootLog("data dir ok: %s", dir) + return nil +} diff --git a/changelog.json b/changelog.json index 2dea2d6..e0d3367 100644 --- a/changelog.json +++ b/changelog.json @@ -16,7 +16,8 @@ "Awards, RDA: the compare and fill-districts buttons say they are working, and the comparison reports its own result — including 'no disagreement' and 'no Russian contacts', which are results and were previously indistinguishable from a button that did nothing. A failed comparison also used to print its error beside the OTHER button.", "The satellite dropdown now follows the list as it is edited: the field read it once when the panel first appeared, so a list saved afterwards only showed up after a restart.", "Preferences no longer redraw with the main window. Being a child of the main view, the whole panel was rebuilt on every cluster spot and every CAT update — several times a second on a busy evening — which looked like a page refreshing constantly and made buttons miss their clicks, the element under the pointer being replaced between the press and the release.", - "Dropdown lists open over whatever is beneath them instead of being cut off by it. The satellite list showed one entry of eight, clipped by the panel it sat in." + "Dropdown lists open over whatever is beneath them instead of being cut off by it. The satellite list showed one entry of eight, clipped by the panel it sat in.", + "A launch that fails before the window exists now says why. OpsLog keeps its log in the data folder beside the program, so anything that went wrong BEFORE that folder existed left nothing to read anywhere — the process appeared and vanished in silence. Startup milestones are written to %LOCALAPPDATA%OpsLogstartup.log, and the three silent exits (already running, the data folder cannot be written — Program Files refuses it — and the window itself failing to open, usually a missing WebView2) now show a message saying which one it was." ], "fr": [ "Console Elecraft : les mesures d'émission sont lues dès que la RADIO se déclare en émission, et plus seulement quand OpsLog l'a mise en émission. Passer en émission par le PTT de façade, une pédale ou le bouton du micro laissait le panneau croire à une réception — et comme les barres de puissance et de ROS ne sont lues qu'en émission, elles ne l'étaient jamais pour qui manipule à la main.", @@ -32,7 +33,8 @@ "Diplômes, RDA : les boutons de comparaison et de remplissage des districts indiquent qu'ils travaillent, et la comparaison rend compte de son propre résultat — y compris « aucune divergence » et « aucun contact russe », qui sont des résultats et étaient jusqu'ici indiscernables d'un bouton sans effet. Une comparaison en échec affichait par ailleurs son erreur à côté de l'AUTRE bouton.", "La liste déroulante des satellites suit maintenant la liste au fil des modifications : le champ la lisait une seule fois à l'apparition du panneau, si bien qu'une liste enregistrée ensuite n'apparaissait qu'après un redémarrage.", "Les préférences ne se redessinent plus au rythme de la fenêtre principale. Étant un enfant de la vue principale, tout le panneau était reconstruit à chaque spot du cluster et à chaque mise à jour CAT — plusieurs fois par seconde un soir chargé — ce qui donnait l'impression d'une page qui se rafraîchit sans arrêt et faisait rater les clics, l'élément sous le pointeur étant remplacé entre l'appui et le relâchement.", - "Les listes déroulantes s'ouvrent par-dessus ce qui les entoure au lieu d'être coupées par lui. Celle des satellites n'affichait qu'une entrée sur huit, tronquée par le panneau qui la contenait." + "Les listes déroulantes s'ouvrent par-dessus ce qui les entoure au lieu d'être coupées par lui. Celle des satellites n'affichait qu'une entrée sur huit, tronquée par le panneau qui la contenait.", + "Un lancement qui échoue avant l'apparition de la fenêtre dit maintenant pourquoi. OpsLog tient son journal dans le dossier data, à côté du programme : tout ce qui échouait AVANT la création de ce dossier ne laissait donc aucune trace nulle part — le processus apparaissait et disparaissait en silence. Les étapes du démarrage sont écrites dans %LOCALAPPDATA%OpsLogstartup.log, et les trois sorties silencieuses (déjà lancé, dossier data impossible à écrire — Program Files le refuse — et fenêtre qui ne s'ouvre pas, généralement un WebView2 manquant) affichent désormais un message disant laquelle c'était." ] }, { diff --git a/fatalbox_other.go b/fatalbox_other.go new file mode 100644 index 0000000..b43570f --- /dev/null +++ b/fatalbox_other.go @@ -0,0 +1,6 @@ +//go:build !windows + +package main + +// fatalBox is Windows-only; elsewhere the terminal carries the message. +func fatalBox(title, text string) { println(title + ": " + text) } diff --git a/fatalbox_windows.go b/fatalbox_windows.go new file mode 100644 index 0000000..9249cf0 --- /dev/null +++ b/fatalbox_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package main + +import ( + "syscall" + "unsafe" +) + +// fatalBox shows a message box and returns. +// +// A GUI-subsystem program has no console: println goes nowhere, and a launch +// that ends before the window exists ends in complete silence. Every silent exit +// in main now says something here first — an operator who is told "another +// OpsLog is already running" can act on it; one who sees nothing files "it does +// not start", which is the report nobody can answer. +func fatalBox(title, text string) { + user32 := syscall.NewLazyDLL("user32.dll") + proc := user32.NewProc("MessageBoxW") + t, err1 := syscall.UTF16PtrFromString(text) + ti, err2 := syscall.UTF16PtrFromString(title) + if err1 != nil || err2 != nil { + return + } + const mbIconError = 0x00000010 + proc.Call(0, uintptr(unsafe.Pointer(t)), uintptr(unsafe.Pointer(ti)), mbIconError) +} diff --git a/main.go b/main.go index 1a8bb5d..b170529 100644 --- a/main.go +++ b/main.go @@ -87,8 +87,27 @@ func main() { // A --post-update relaunch (from the auto-updater) may start while the previous // instance is still exiting and holding the single-instance mutex — wait for it // to free instead of bailing out. Then clear the old exe it left behind. + bootLogLaunch() postUpdate := hasFlag(os.Args[1:], "--post-update") if !acquireInstance(postUpdate) { + // SAID, not merely done. This exit is correct — a second instance would + // fight the first over the rig — but it happened in total silence: no + // window, no data folder, no log, which is indistinguishable from a + // program that died on its first instruction. + bootLog("another instance already holds the single-instance mutex - exiting") + fatalBox("OpsLog", "OpsLog is already running.\n\nLook for its window, or for a leftover OpsLog.exe in the Task Manager, and close it before starting another.") + return + } + bootLog("single-instance mutex acquired") + + // The data folder is created BESIDE the executable, so a copy dropped into + // Program Files is refused the write by Windows — and that refusal ended the + // launch with nothing on screen and nothing in any file, since the only log + // OpsLog had was inside the folder it could not create. + if err := checkDataDirWritable(); err != nil { + bootLog("FATAL %v", err) + fatalBox("OpsLog", "OpsLog cannot write next to its own program file.\n\n"+err.Error()+ + "\n\nMove OpsLog.exe somewhere your account can write — a folder in Documents, or the desktop — and start it again. Program Files is refused to anything not running as administrator.") return } if postUpdate { @@ -150,6 +169,11 @@ func main() { }) if err != nil { - println("Error:", err.Error()) + // The last thing that can fail before a window exists, and the most + // opaque of them: a missing WebView2 runtime lands here. println goes + // nowhere in a GUI-subsystem program, so this went unseen and unlogged. + bootLog("FATAL wails.Run: %v", err) + fatalBox("OpsLog", "OpsLog could not open its window.\n\n"+err.Error()+ + "\n\nThis is usually a missing WebView2 runtime — install \"Microsoft Edge WebView2 Runtime\" and try again.") } }