Files
OpsLog/update.go
T
rouggy e0b110392a fix(update): wait for the old process, not for a fixed window
The relaunch after an update stopped working, and the regression is mine:
removing the PowerShell helper — which is what Defender was reading as a
dropper — also removed the wait it was doing. Nothing took over the job.

The numbers made it certain rather than unlucky. The instance being
replaced is allowed THIRTY seconds to shut down (armExitWatchdog forces
it out at that point) because it closes a remote logbook, a CAT session
and sometimes a backup. The new instance was patient with the
single-instance mutex for TWENTY. On any station where shutting down ran
past that, the new process gave up and exited in silence: no window after
an update, and the previous OpsLog still in the task manager. Exactly the
report.

Both relaunch paths now pass --wait-pid, and the new process waits on
that process's handle — a plain kernel wait, which ends the instant the
old one ends, however long or short that is, and looks nothing like a
script starting another program. The mutex retry stays as a backstop and
goes to forty-five seconds, so it is longer than the wait it exists for
rather than shorter.

And when the old process really has not gone, the message says that
instead of "OpsLog is already running" — after an update the operator did
not start a second copy, and what they need to know is which one to
close.

A test keeps the two spawn sites honest: a relaunch added without
--wait-pid is this bug again.
2026-09-08 09:18:06 +02:00

421 lines
14 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"
// releasesPageURL is the same release, for people rather than for the updater:
// the API address above answers JSON, so it is not something to put in front of
// an operator who followed a link out of a QSL e-mail.
const releasesPageURL = "https://github.com/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")
// THE NEW EXE STARTS ITSELF. No helper, no script.
//
// This used to go through a hidden PowerShell that waited for our process to
// die and then launched the new image — which is, byte for byte, the shape of
// a dropper: an unsigned binary replaces itself on disk, clears the
// mark-of-the-web, and spawns a windowless PowerShell that starts another
// executable. Windows Defender's machine-learning model reads that shape and
// not our intentions, and an operator updating to 0.27.14 had OpsLog removed
// under Trojan:Script/Wacatac.H!ml — the "Script/" being the PowerShell.
//
// The wait it existed for still has to happen — it just happens on the other
// side now. The new instance is told OUR pid and waits for this process to
// end before taking the single-instance mutex.
//
// Waiting on the mutex alone was not enough, and that is the bug this line
// fixes: shutting down is allowed thirty seconds here (armExitWatchdog),
// because it closes a remote logbook, a CAT session and sometimes a backup,
// while the new instance was only patient for twenty. On a station where
// that ran long, the new process gave up and exited — leaving the old one
// still running and no new window, which is precisely what the PowerShell
// helper never did: it waited for the pid, however long it took.
cmd := exec.Command(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
cmd.Dir = dir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule relaunch: %w", err)
}
// Released rather than waited on: this process is about to exit, and a child
// that outlives its parent must not be left as a zombie handle.
_ = cmd.Process.Release()
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.
// The LAST resort still needs a helper that outlives this process: nothing else
// can move a file over an image that is still running. It stays PowerShell —
// there is no smaller tool on a stock Windows that can wait for a pid and then
// move a file — but it is reached only when the rename above failed, which is
// rare, and never on the ordinary update path (see the relaunch there for why
// that matters to Defender).
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-<nanos>" 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
}