The new build downloaded and OpsLog quit, but never came back. Two Windows causes: the freshly written exe carried the internet Zone.Identifier mark, so SmartScreen wanted to prompt "are you sure you want to open this?" — invisibly, since we launch it programmatically — and silently blocked the launch; and starting the new exe while the old one was still exiting raced the single- instance mutex. Now the swapped exe's Zone.Identifier stream is removed, and the relaunch is done by a detached, hidden PowerShell that Wait-Process's on our PID (so we're fully gone and the mutex is free) before Start-Process'ing the new exe.
299 lines
8.6 KiB
Go
299 lines
8.6 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"hamlog/internal/applog"
|
|
)
|
|
|
|
// updateCheckURL is the GitHub Releases "latest" endpoint for the public OpsLog
|
|
// 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 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 (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
|
|
// build. Best effort — on any failure it reports "no update" so the app never
|
|
// nags about a check it couldn't complete.
|
|
func (a *App) CheckForUpdate() UpdateInfo {
|
|
out := UpdateInfo{Current: appVersion}
|
|
client := &http.Client{Timeout: 8 * time.Second}
|
|
req, err := http.NewRequest(http.MethodGet, updateCheckURL, nil)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
applog.Printf("update: check failed: %v", err)
|
|
return out
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return out // no release yet (404) or rate-limited — treat as up to date
|
|
}
|
|
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
|
|
}
|
|
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 asset=%q", appVersion, out.Latest, out.DownloadURL)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// versionLess reports whether version a is older than b. Compares dot-separated
|
|
// numeric parts ("0.9" < "0.10" < "1.0"); non-numeric junk in a part counts as 0.
|
|
func versionLess(a, b string) bool {
|
|
pa := strings.Split(a, ".")
|
|
pb := strings.Split(b, ".")
|
|
n := len(pa)
|
|
if len(pb) > n {
|
|
n = len(pb)
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
ai, bi := 0, 0
|
|
if i < len(pa) {
|
|
ai = leadingInt(pa[i])
|
|
}
|
|
if i < len(pb) {
|
|
bi = leadingInt(pb[i])
|
|
}
|
|
if ai != bi {
|
|
return ai < bi
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
// Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
|
|
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
|
|
// this?" — but since we launch the exe programmatically that prompt never shows,
|
|
// and the launch is silently blocked. This is exactly why the relaunch failed.
|
|
_ = os.Remove(exe + ":Zone.Identifier")
|
|
applog.Printf("update: installed new exe, scheduling relaunch")
|
|
|
|
// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
|
|
// (so the single-instance mutex is free) and THEN starts the new exe. Launching
|
|
// the new exe directly while we're still alive raced the mutex and often left
|
|
// nothing running; waiting for our own exit first makes the restart reliable,
|
|
// and the launcher outlives us.
|
|
quoted := strings.ReplaceAll(exe, "'", "''")
|
|
ps := fmt.Sprintf(
|
|
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
|
|
os.Getpid(), quoted)
|
|
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("schedule 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)
|
|
end := 0
|
|
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
|
end++
|
|
}
|
|
if end == 0 {
|
|
return 0
|
|
}
|
|
n, _ := strconv.Atoi(s[:end])
|
|
return n
|
|
}
|