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" ) //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() } // 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 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}, OnStartup: app.startup, OnDomReady: app.domReady, OnBeforeClose: app.beforeClose, OnShutdown: app.shutdown, Bind: []interface{}{ app, }, }) 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.") } }