From 5d0906f00e616d4c92fdf563acaa8000bd689bab Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 19 Jul 2026 18:14:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20in-app=20auto-updater=20=E2=80=94=20dow?= =?UTF-8?q?nload,=20swap=20and=20relaunch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckForUpdate now also resolves the release's downloadable asset (portable .exe, or a .zip to unpack). DownloadAndApplyUpdate streams it with progress events (update:progress 0-100), swaps it in for the running exe (rename the current one to .old — allowed for a running image on Windows — move the new one into place, roll back on failure), then relaunches with --post-update and quits. main: a --post-update relaunch retries the single-instance mutex for ~20 s so the fresh process waits for the old one to exit and free it, then clears the .old exe left behind. Falls back to opening the release page when a release has no auto-download asset. --- main.go | 41 ++++++++++- update.go | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 234 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index 1c41b6e..809537e 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "embed" "os" "strings" + "time" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -35,15 +36,53 @@ func profileArg(args []string) string { 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 +} + 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. - if !acquireSingleInstance() { + // 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. + postUpdate := hasFlag(os.Args[1:], "--post-update") + if !acquireInstance(postUpdate) { return } + if postUpdate { + cleanupOldUpdateBinary() + } // Create an instance of the app structure app := NewApp() diff --git a/update.go b/update.go index 9818965..37b94b8 100644 --- a/update.go +++ b/update.go @@ -1,12 +1,20 @@ package main import ( + "archive/zip" "encoding/json" + "fmt" + "io" "net/http" + "os" + "os/exec" + "path/filepath" "strconv" "strings" "time" + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" + "hamlog/internal/applog" ) @@ -14,12 +22,13 @@ import ( // build (the exe lives there; source stays on Gitea). Adjust the repo if needed. const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest" -// UpdateInfo is the result of the startup version check. +// UpdateInfo is the result of the version check. type UpdateInfo struct { - Current string `json:"current"` // this build's version (appVersion) - Latest string `json:"latest"` // newest published release, "" if unknown - Available bool `json:"available"` // Latest > Current - URL string `json:"url"` // release page to open + Current string `json:"current"` // this build's version (appVersion) + Latest string `json:"latest"` // newest published release, "" if unknown + Available bool `json:"available"` // Latest > Current + URL string `json:"url"` // release page to open (manual fallback) + DownloadURL string `json:"download_url"` // the .exe/.zip asset to auto-download, "" if none } // CheckForUpdate asks GitHub for the latest release and compares it to this @@ -45,6 +54,10 @@ func (a *App) CheckForUpdate() UpdateInfo { var r struct { TagName string `json:"tag_name"` HTMLURL string `json:"html_url"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return out @@ -52,8 +65,25 @@ func (a *App) CheckForUpdate() UpdateInfo { out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v") out.URL = r.HTMLURL out.Available = versionLess(appVersion, out.Latest) + // Pick the auto-download asset: a bare Windows .exe (portable build) first, + // else a .zip we can unpack. The frontend hands this straight to + // DownloadAndApplyUpdate for a one-click in-app update. + for _, as := range r.Assets { + if strings.HasSuffix(strings.ToLower(as.Name), ".exe") { + out.DownloadURL = as.URL + break + } + } + if out.DownloadURL == "" { + for _, as := range r.Assets { + if strings.HasSuffix(strings.ToLower(as.Name), ".zip") { + out.DownloadURL = as.URL + break + } + } + } if out.Available { - applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest) + applog.Printf("update: newer version available — current=%s latest=%s asset=%q", appVersion, out.Latest, out.DownloadURL) } return out } @@ -82,6 +112,164 @@ func versionLess(a, b string) bool { return false } +// DownloadAndApplyUpdate downloads the new build, swaps it in for the running exe +// and relaunches — the fully in-app update. Progress is emitted on "update:progress" +// (0-100) so the UI can show a bar. On success it never returns normally: it starts +// the new process and quits this one. +func (a *App) DownloadAndApplyUpdate(url string) error { + if strings.TrimSpace(url) == "" { + return fmt.Errorf("no download URL") + } + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("locate executable: %w", err) + } + dir := filepath.Dir(exe) + + // Download to a temp file next to the exe (same volume, so the rename-swap is + // atomic and can't fail across drives). + tmp := filepath.Join(dir, ".opslog-update.download") + _ = os.Remove(tmp) + if err := a.downloadWithProgress(url, tmp); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("download: %w", err) + } + + // The asset is either the bare exe or a zip holding it. Resolve to the new exe. + newExe := tmp + if strings.HasSuffix(strings.ToLower(url), ".zip") { + extracted, xerr := extractExeFromZip(tmp, dir) + _ = os.Remove(tmp) + if xerr != nil { + return fmt.Errorf("unpack: %w", xerr) + } + newExe = extracted + } + + // Swap: rename the running exe out of the way (Windows allows renaming a + // running image), move the new one into its place, then relaunch. Roll back if + // the second rename fails so we never end up with no exe. + oldExe := exe + ".old" + _ = os.Remove(oldExe) + if err := os.Rename(exe, oldExe); err != nil { + _ = os.Remove(newExe) + return fmt.Errorf("stage current exe: %w", err) + } + if err := os.Rename(newExe, exe); err != nil { + _ = os.Rename(oldExe, exe) // roll back + return fmt.Errorf("install new exe: %w", err) + } + applog.Printf("update: installed new exe, relaunching") + + // Relaunch with a flag so the fresh instance waits for THIS one to exit and + // free the single-instance mutex instead of bailing out immediately. + cmd := exec.Command(exe, "--post-update") + cmd.Dir = dir + if err := cmd.Start(); err != nil { + return fmt.Errorf("relaunch: %w", err) + } + if a.ctx != nil { + wruntime.Quit(a.ctx) + } else { + os.Exit(0) + } + return nil +} + +// downloadWithProgress streams url into dest, emitting "update:progress" (0-100). +func (a *App) downloadWithProgress(url, dest string) error { + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + + total := resp.ContentLength + var read int64 + last := -1 + buf := make([]byte, 64*1024) + emit := func(pct int) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "update:progress", pct) + } + } + emit(0) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := f.Write(buf[:n]); werr != nil { + return werr + } + read += int64(n) + if total > 0 { + if pct := int(read * 100 / total); pct != last { + last = pct + emit(pct) + } + } + } + if rerr == io.EOF { + break + } + if rerr != nil { + return rerr + } + } + emit(100) + return nil +} + +// extractExeFromZip unpacks the first *.exe found in the zip into dir and returns +// its path. +func extractExeFromZip(zipPath, dir string) (string, error) { + zr, err := zip.OpenReader(zipPath) + if err != nil { + return "", err + } + defer zr.Close() + for _, zf := range zr.File { + if !strings.HasSuffix(strings.ToLower(zf.Name), ".exe") { + continue + } + rc, err := zf.Open() + if err != nil { + return "", err + } + out := filepath.Join(dir, ".opslog-update.exe") + f, err := os.Create(out) + if err != nil { + rc.Close() + return "", err + } + _, cerr := io.Copy(f, rc) + rc.Close() + f.Close() + if cerr != nil { + return "", cerr + } + return out, nil + } + return "", fmt.Errorf("no .exe inside the archive") +} + +// cleanupOldUpdateBinary removes the previous exe left behind by a self-update +// (exe + ".old"). Called at startup after a --post-update relaunch. Best-effort: +// the file may still be briefly locked, in which case the next launch gets it. +func cleanupOldUpdateBinary() { + if exe, err := os.Executable(); err == nil { + _ = os.Remove(exe + ".old") + } +} + // leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none. func leadingInt(s string) int { s = strings.TrimSpace(s)