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" ) // 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) } 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) 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 }