feat: in-app auto-updater — download, swap and relaunch
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.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user