Operators are still losing the relaunch, so I went and read the code
from before 0430aab — the commit that removed the helper — instead of
theorising again. It was:
Wait-Process -Id <pid>; Start-Sleep -Milliseconds 400;
Start-Process -FilePath <exe> -ArgumentList '--post-update'
Two things in there that the direct launch never had:
1. Start-Process made the new OpsLog a child of PowerShell, which then
exited — so it was detached. The direct launch makes it a child of
the instance that is dying, in the same process group and console.
DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP puts it back on its own,
so nothing aimed at the old process can reach the new one.
2. It slept 400 ms AFTER the old process was gone, before starting
anything. A process's handles are released by the kernel as it dies,
so the mutex is free the moment the wait returns — but the things
around it are not on that clock: the WebView2 user-data lock, the log
file, an antivirus that woke up when the exe was replaced. This is
not a theory about which of those it was; it is the pause being put
back where it was.
Not restored: the PowerShell itself. Defender removed 0.27.14 from a
station as Trojan:Script/Wacatac.H!ml, and "Script/" was that helper —
an unsigned binary replacing itself and spawning a windowless script to
start another executable is, byte for byte, a dropper. Bringing it back
trades this fault for one that deletes the program.
Also: the relaunch now logs the child's pid, which the new instance's
startup.log already records on the other side. Without the pair there is
no telling "the new instance never started" from "it started and gave up
waiting", and those have different causes.
321 lines
14 KiB
Go
321 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v2"
|
|
"github.com/wailsapp/wails/v2/pkg/options"
|
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
|
wailswindows "github.com/wailsapp/wails/v2/pkg/options/windows"
|
|
)
|
|
|
|
//go:embed all:frontend/dist
|
|
var assets embed.FS
|
|
|
|
// profileArg extracts a profile name from the command line. Accepts
|
|
// "--profile NAME", "--profile=NAME", "-profile NAME", "-p NAME" so a desktop
|
|
// shortcut can launch OpsLog straight into a given profile (e.g. F4BPO / TM2Q).
|
|
func profileArg(args []string) string {
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
switch {
|
|
case a == "--profile" || a == "-profile" || a == "-p":
|
|
if i+1 < len(args) {
|
|
return strings.TrimSpace(args[i+1])
|
|
}
|
|
case strings.HasPrefix(a, "--profile="):
|
|
return strings.TrimSpace(strings.TrimPrefix(a, "--profile="))
|
|
case strings.HasPrefix(a, "-profile="):
|
|
return strings.TrimSpace(strings.TrimPrefix(a, "-profile="))
|
|
case strings.HasPrefix(a, "-p="):
|
|
return strings.TrimSpace(strings.TrimPrefix(a, "-p="))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// hasFlag reports whether flag is present in args.
|
|
func hasFlag(args []string, flag string) bool {
|
|
for _, a := range args {
|
|
if a == flag {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
|
|
// try (fail → another OpsLog is running, so exit). On a --post-update or
|
|
// --relaunch start the previous instance may still be shutting down and holding
|
|
// the mutex, so retry for a few seconds until it frees.
|
|
func acquireInstance(wait bool) bool {
|
|
if acquireSingleInstance() {
|
|
return true
|
|
}
|
|
if !wait {
|
|
return false
|
|
}
|
|
// Forty-five seconds, not twenty.
|
|
//
|
|
// The instance we are waiting for is allowed THIRTY to shut down — see
|
|
// armExitWatchdog, which force-exits it at that point — because it closes a
|
|
// remote logbook, a CAT session and sometimes a backup on the way out. A
|
|
// twenty-second patience was therefore shorter than the wait it existed for,
|
|
// and on a station where shutdown ran long the new instance gave up while
|
|
// the old one was still finishing: no new window after an update, and a
|
|
// leftover OpsLog in the task manager. This is the backstop; --wait-pid
|
|
// below is the real answer.
|
|
deadline := time.Now().Add(45 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
time.Sleep(300 * time.Millisecond)
|
|
if acquireSingleInstance() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// postExitSettle is the pause between the previous instance ending and this
|
|
// one claiming its place. 400 ms, the figure the PowerShell helper used.
|
|
const postExitSettle = 400 * time.Millisecond
|
|
|
|
// waitPidArg reads "--wait-pid N": the process this one must outlive.
|
|
func waitPidArg(args []string) int {
|
|
for i, a := range args {
|
|
if a == "--wait-pid" && i+1 < len(args) {
|
|
if n, err := strconv.Atoi(args[i+1]); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
if v, ok := strings.CutPrefix(a, "--wait-pid="); ok {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// processStart is stamped on the very first instruction of main, before the
|
|
// single-instance guard and before anything else runs.
|
|
//
|
|
// It exists to split a slow launch in two, because the log alone could not: the
|
|
// first line applog writes already sits inside startup(), so everything spent
|
|
// loading the binary and creating the WebView2 environment happened before the
|
|
// log begins and was invisible. An operator reporting "nothing happens for three
|
|
// seconds" was impossible to answer from a file whose first timestamp is the
|
|
// moment the app was already running.
|
|
var processStart = time.Now()
|
|
|
|
func main() {
|
|
// Single-instance guard: if OpsLog is already running, focus that window and
|
|
// exit instead of spawning a duplicate. A second process would open its own
|
|
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
|
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
|
// its own" when a windowless zombie instance was left running.
|
|
// 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")
|
|
// The instance that started us is still shutting down. Wait for it to
|
|
// actually END — a kernel wait on its handle, which finishes the instant it
|
|
// does — rather than hoping the mutex frees inside a fixed window. This is
|
|
// what the old PowerShell helper did, and losing it is what left an operator
|
|
// with no window after an update and the previous OpsLog still in the task
|
|
// manager.
|
|
if pid := waitPidArg(os.Args[1:]); pid > 0 {
|
|
bootLog("waiting for the previous instance (pid %d) to exit", pid)
|
|
if waitForProcessExit(pid, 60*time.Second) {
|
|
bootLog("the previous instance is gone")
|
|
} else {
|
|
bootLog("the previous instance (pid %d) is STILL running after 60s — trying anyway", pid)
|
|
}
|
|
// A breath after it is gone, which the PowerShell helper took as
|
|
// "Start-Sleep -Milliseconds 400" and the direct launch dropped.
|
|
//
|
|
// A process's handles are released by the kernel as it dies, so the
|
|
// mutex is free the instant the wait returns — but the things AROUND
|
|
// it are not on that clock: the WebView2 user-data lock, the log file,
|
|
// an antivirus that woke up when the exe was replaced. The old code
|
|
// waited here and worked; this is not a theory about which of those it
|
|
// was, it is the pause being put back.
|
|
time.Sleep(postExitSettle)
|
|
bootLog("settled %v after the previous instance — taking the lock", postExitSettle)
|
|
}
|
|
// A self-relaunch (database switch) races its own parent: the new process
|
|
// regularly wins the start against the old one's teardown, and the operator
|
|
// got "OpsLog is already running" for following instructions. Same patience
|
|
// as post-update.
|
|
if !acquireInstance(postUpdate || hasFlag(os.Args[1:], "--relaunch")) {
|
|
// 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")
|
|
if postUpdate {
|
|
// After an update the ordinary message is a lie by omission: the
|
|
// operator did not start a second copy, the update did, and what
|
|
// they need to know is that the PREVIOUS version never finished
|
|
// closing.
|
|
fatalBox("OpsLog", "The previous version of OpsLog has not finished closing, so the updated one cannot start.\n\n"+
|
|
"Close the leftover OpsLog.exe in the Task Manager, then start OpsLog again — the update is already installed.")
|
|
} else {
|
|
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()+dataDirAdvice)
|
|
return
|
|
}
|
|
if postUpdate {
|
|
cleanupOldUpdateBinary()
|
|
}
|
|
|
|
// The WebView2 runtime, named before the window is attempted. Its absence is
|
|
// the classic silent failure — the process starts, the browser environment
|
|
// cannot be created, and on some machines that ends the launch without an
|
|
// error anyone can see.
|
|
if v := webView2Version(); v != "" {
|
|
bootLog("WebView2 runtime: %s", v)
|
|
} else {
|
|
bootLog("WebView2 runtime: NOT FOUND — install Microsoft Edge WebView2 Runtime")
|
|
}
|
|
|
|
// Create an instance of the app structure
|
|
app := NewApp()
|
|
app.startupProfile = profileArg(os.Args[1:])
|
|
|
|
// Restore the window's SIZE and maximised state at CREATION, from the geometry
|
|
// saved on last close. Doing it here (not after startup) is what makes the
|
|
// window open already at the right size instead of maximising then snapping
|
|
// smaller. Position can't be set through options, so it is applied while the
|
|
// window is still hidden (domReady) — invisible, so no jump. First run, or a
|
|
// window closed maximised, keeps the historical maximised default.
|
|
width, height := 1400, 900
|
|
startState := options.Maximised
|
|
if dataDir, err := userDataDir(); err == nil {
|
|
if ws, ok := readWindowState(dataDir); ok && !ws.Maximised &&
|
|
ws.Width >= 1100 && ws.Height >= 700 && ws.Width <= 8000 && ws.Height <= 6000 {
|
|
width, height = ws.Width, ws.Height
|
|
startState = options.Normal
|
|
}
|
|
}
|
|
|
|
// Create application with options
|
|
// A HANG has no error to report, and that is the shape being chased here:
|
|
// the process stays up, the data folder is created and stays empty, and no
|
|
// window appears — WebView2 never finishes creating its environment, and
|
|
// nothing returns to be logged.
|
|
//
|
|
// So the absence of progress is what gets reported. If OnStartup has not
|
|
// been reached a few seconds in, this says so and names the two things that
|
|
// cause it, rather than leaving an operator watching a process that is doing
|
|
// nothing at all.
|
|
go func() {
|
|
time.Sleep(12 * time.Second)
|
|
if startupReached.Load() {
|
|
return
|
|
}
|
|
bootLog("STUCK: 12 s after wails.Run and the window has not started — WebView2 never handed control back")
|
|
fatalBox("OpsLog", startupStuckMessage)
|
|
}()
|
|
noteLaunchAttempt()
|
|
bootLog("Windows %s", windowsVersion())
|
|
bootLog("Edge policy: %s", edgePolicyNotes())
|
|
// The blocker utilities: they leave an IFEO entry that makes Windows refuse
|
|
// to run the WebView2 process at all. Named loudly, because a machine in
|
|
// this state looks identical to one with a broken graphics driver.
|
|
if blocked := edgeExecutionBlocked(); blocked != "" {
|
|
bootLog("EDGE EXECUTION IS BLOCKED: %s — an \"Edge blocker\" tool is stopping WebView2 from starting", blocked)
|
|
fatalBox("OpsLog", edgeBlockedMessage)
|
|
}
|
|
bootLog("WebView2 profile: %q", webviewDataPath())
|
|
bootLog("entering wails.Run (window %dx%d, state %v)", width, height, startState)
|
|
err := wails.Run(&options.App{
|
|
Title: "OpsLog",
|
|
Width: width,
|
|
Height: height,
|
|
// No minimum. Wails treats 0 as "no constraint" (winc only fills
|
|
// PtMinTrackSize when the value is above zero), so Windows applies its
|
|
// own floor — about the width of the caption buttons — and the operator
|
|
// decides the rest.
|
|
//
|
|
// It was 1100x700, which is a fair guess at where the layout stops being
|
|
// comfortable and no business of ours to enforce: a second screen used as
|
|
// a narrow strip, a window parked beside a decoder, a small laptop — all
|
|
// of them ran into a wall with nothing to show for it. The panels already
|
|
// scroll and collapse.
|
|
MinWidth: 0,
|
|
MinHeight: 0,
|
|
WindowStartState: startState,
|
|
// No OS title bar: it was a dead 32-pixel band above a window that already
|
|
// has its own title strip. The app header takes over — it carries the drag
|
|
// region, the double-click-to-maximise, and the minimise/maximise/close
|
|
// buttons. Windows still draws the resize borders and honours Aero Snap,
|
|
// which is why the frame is dropped rather than the whole chrome.
|
|
Frameless: true,
|
|
// Start hidden and reveal only once the saved position has been applied and
|
|
// the DOM has painted (OnDomReady → domReady) — so the window appears
|
|
// already at its final size and position, with no post-launch jump.
|
|
StartHidden: true,
|
|
AssetServer: &assetserver.Options{
|
|
Assets: assets,
|
|
},
|
|
// The colour the window is painted before the WebView draws. Matches the
|
|
// graphite theme's background (#16181d), which is the default on a fresh
|
|
// install — otherwise every launch flashes white first.
|
|
BackgroundColour: &options.RGBA{R: 0x16, G: 0x18, B: 0x1d, A: 1},
|
|
// WEBVIEW2, pinned down.
|
|
//
|
|
// Its profile folder is normally chosen by Wails under the roaming
|
|
// profile — a place that on a managed or redirected account can be slow,
|
|
// locked or simply gone, and the failure mode is a creation that never
|
|
// returns rather than an error. Naming it puts it on the local disk,
|
|
// beside the startup log, where it can also be DELETED when it is the
|
|
// thing that is broken.
|
|
//
|
|
// GPU acceleration is switched off only when the previous launch hung
|
|
// (see stuckMarker): a WebView2 that cannot get on with the graphics
|
|
// driver hangs exactly like one that cannot start at all, and this is the
|
|
// one lever that separates the two — without costing anything on the
|
|
// machines where it was never the problem.
|
|
Windows: &wailswindows.Options{
|
|
WebviewUserDataPath: webviewDataPath(),
|
|
// A fixed-version runtime carried beside OpsLog, when one is there.
|
|
// Empty means the installed Evergreen runtime, as before.
|
|
WebviewBrowserPath: fixedWebView2Path(),
|
|
WebviewGpuIsDisabled: lastLaunchHung,
|
|
},
|
|
OnStartup: app.startup,
|
|
OnDomReady: app.domReady,
|
|
OnBeforeClose: app.beforeClose,
|
|
OnShutdown: app.shutdown,
|
|
Bind: []interface{}{
|
|
app,
|
|
},
|
|
})
|
|
|
|
bootLog("wails.Run returned")
|
|
if err != nil {
|
|
// 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.")
|
|
}
|
|
}
|