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. // // The staging name is UNIQUE, not a fixed ".old". With a fixed name, one // leftover that could not be deleted — an antivirus holding it open is the // usual reason — poisoned every later update: the rename replaces its target, // the target was locked, and the operator got "stage current exe: … Accès // refusé" for ever with no way out but deleting the file by hand. oldExe := fmt.Sprintf("%s.old-%d", exe, time.Now().UnixNano()) var stageErr error staged := false // Retry briefly: a real-time scanner opens the file it has just seen written // and holds it for a moment, so the first attempt lands exactly in that window. for attempt := 0; attempt < 5; attempt++ { if stageErr = os.Rename(exe, oldExe); stageErr == nil { staged = true break } time.Sleep(time.Duration(150*(attempt+1)) * time.Millisecond) } if staged { if err := os.Rename(newExe, exe); err != nil { _ = os.Rename(oldExe, exe) // roll back return fmt.Errorf("install new exe: %w", err) } } else { // Could not rename our own running image at all. Some endpoint protection // (Bitdefender's ransomware remediation among them) blocks precisely that, // and no amount of retrying gets past it. // // So don't fight it: leave the new build beside the old one and let the // relaunch helper do the swap AFTER this process has exited, when the file // is no longer a running image. Reported by several operators, all with the // same "Accès refusé" on the staging rename. applog.Printf("update: cannot rename the running exe (%v) — deferring the swap to after exit", stageErr) pending := exe + ".new" _ = os.Remove(pending) if err := os.Rename(newExe, pending); err != nil { _ = os.Remove(newExe) return fmt.Errorf("stage new exe: %w (the folder %s must be writable, and an antivirus may be holding OpsLog.exe)", err, dir) } if err := a.scheduleDeferredSwap(exe, pending); err != nil { return err } if a.ctx != nil { wruntime.Quit(a.ctx) } else { os.Exit(0) } return nil } // 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 } // scheduleDeferredSwap hands the exe swap to a detached helper that runs AFTER // this process is gone. // // The fallback for when the running image cannot be renamed at all. Once OpsLog // has exited its exe is an ordinary file again, so the move that was refused a // moment earlier succeeds — and the helper keeps trying for ten seconds, because // an antivirus that was holding the file usually lets go a beat after the // process dies rather than instantly. // // OpsLog is restarted either way. If the move failed, that starts the OLD build // — the update simply has not applied — and the operator keeps a working logger // instead of having it vanish mid-session, which for someone in a QSO is worse // than an update that waits. Only a successful swap passes --post-update, so a // failure leaves the .new file in place for the next attempt rather than having // the cleanup delete the download. func (a *App) scheduleDeferredSwap(exe, pending string) error { // Clear the "downloaded from the internet" mark before it becomes the exe — // SmartScreen silently blocks a programmatic launch of a marked file, and the // mark follows the file across the move. _ = os.Remove(pending + ":Zone.Identifier") q := func(s string) string { return strings.ReplaceAll(s, "'", "''") } ps := fmt.Sprintf( "Wait-Process -Id %d -ErrorAction SilentlyContinue; "+ "$ok=$false; "+ "for ($i=0; $i -lt 40; $i++) { "+ "try { Move-Item -LiteralPath '%s' -Destination '%s' -Force -ErrorAction Stop; $ok=$true; break } "+ "catch { Start-Sleep -Milliseconds 250 } }; "+ "if ($ok) { Start-Process -FilePath '%s' -ArgumentList '--post-update' } "+ "else { Start-Process -FilePath '%s' }", os.Getpid(), q(pending), q(exe), q(exe), q(exe)) 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 the update swap: %w", err) } applog.Printf("update: swap scheduled for after exit (%s → %s)", filepath.Base(pending), filepath.Base(exe)) 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 what a self-update left behind. Called at // startup after a --post-update relaunch. Best-effort throughout: a file may // still be locked by a scanner, and the next launch will get it. // // Sweeps a PATTERN, not one name. Staging uses a unique ".old-" precisely // so a locked leftover cannot block the next update, which means leftovers // accumulate unless something collects them — and the pre-0.24.1 ".old" may be // sitting there too, from the very update that could not delete it. func cleanupOldUpdateBinary() { exe, err := os.Executable() if err != nil { return } _ = os.Remove(exe + ".old") // the old fixed name _ = os.Remove(exe + ".new") // a deferred swap that has been applied matches, err := filepath.Glob(exe + ".old-*") if err != nil { return } for _, m := range matches { _ = os.Remove(m) } } // 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 }