package main import ( "embed" "os" "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 relaunch the // previous instance may still be shutting down and holding the mutex, so retry for // a few seconds until it frees. func acquireInstance(postUpdate bool) bool { if acquireSingleInstance() { return true } if !postUpdate { return false } deadline := time.Now().Add(20 * time.Second) for time.Now().Before(deadline) { time.Sleep(300 * time.Millisecond) if acquireSingleInstance() { return true } } return false } // 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") 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 { 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, MinWidth: 1100, MinHeight: 700, 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.") } }