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"
|
"embed"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
@@ -35,15 +36,53 @@ func profileArg(args []string) string {
|
|||||||
return ""
|
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() {
|
func main() {
|
||||||
// Single-instance guard: if OpsLog is already running, focus that window and
|
// 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
|
// exit instead of spawning a duplicate. A second process would open its own
|
||||||
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
// 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
|
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
||||||
// its own" when a windowless zombie instance was left running.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
if postUpdate {
|
||||||
|
cleanupOldUpdateBinary()
|
||||||
|
}
|
||||||
|
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,12 +22,13 @@ import (
|
|||||||
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
// 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"
|
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 {
|
type UpdateInfo struct {
|
||||||
Current string `json:"current"` // this build's version (appVersion)
|
Current string `json:"current"` // this build's version (appVersion)
|
||||||
Latest string `json:"latest"` // newest published release, "" if unknown
|
Latest string `json:"latest"` // newest published release, "" if unknown
|
||||||
Available bool `json:"available"` // Latest > Current
|
Available bool `json:"available"` // Latest > Current
|
||||||
URL string `json:"url"` // release page to open
|
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
|
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
||||||
@@ -45,6 +54,10 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
var r struct {
|
var r struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
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 {
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
return out
|
return out
|
||||||
@@ -52,8 +65,25 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
||||||
out.URL = r.HTMLURL
|
out.URL = r.HTMLURL
|
||||||
out.Available = versionLess(appVersion, out.Latest)
|
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 {
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -82,6 +112,164 @@ func versionLess(a, b string) bool {
|
|||||||
return false
|
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.
|
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
||||||
func leadingInt(s string) int {
|
func leadingInt(s string) int {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user