Restored verbatim from before 0430aab:
Wait-Process -Id <pid>; Start-Sleep -Milliseconds 400;
Start-Process -FilePath <exe> -ArgumentList '--post-update'
Two rewrites tried to start the new exe from inside the dying process
and both failed on real stations — 0.27.23 and again after the SW_HIDE
fix. The original's own comment had already said why: "Launching the new
exe directly while we're still alive raced the mutex and often left
nothing running." Telling the new instance our pid so it could wait on
the other side looked equivalent and is not. What the helper has that
neither rewrite did is that it OUTLIVES us — the launch happens once
this process is completely gone, from a process that was never our
child.
The Defender cost is known and accepted: an unsigned binary that
replaces itself, clears the mark-of-the-web and spawns a windowless
script to start another executable has the shape of a dropper, and
0.27.14 was removed from a station as Trojan:Script/Wacatac.H!ml. The
operator's decision is that whitelisting OpsLog beats an updater that
leaves people with no running program, and the changelog says so.
HideWindow stays on the PowerShell and is not the bug that made the
updated OpsLog invisible: it hides the helper's console, which is the
point, while Start-Process shows the new window normally.
relaunchCmd and its detachment stay for RestartApp, the database-switch
relaunch, which has never been reported broken.
Two tests kept honest: the update path must contain Wait-Process and
Start-Process and must not spawn the exe directly again, and
TestEveryRelaunchPassesItsPid had quietly stopped matching anything when
the spawn sites changed spelling — its pattern now covers relaunchCmd
too, so it guards RestartApp instead of passing vacuously.
392 lines
13 KiB
Go
392 lines
13 KiB
Go
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"
|
|
|
|
// 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.
|
|
clearDownloadMark(exe)
|
|
if err := makeExecutable(exe); err != nil {
|
|
applog.Printf("update: could not restore the executable bit on %s: %v", filepath.Base(exe), err)
|
|
}
|
|
applog.Printf("update: installed new build, scheduling relaunch")
|
|
|
|
// A DETACHED, HIDDEN POWERSHELL waits for this process to exit and THEN
|
|
// starts the new exe. Restored, verbatim, from before 0430aab.
|
|
//
|
|
// Two rewrites tried to do without it and both failed on real stations.
|
|
// Starting the new exe from here means launching it while this process is
|
|
// still alive — and the comment on the original said exactly what that
|
|
// costs: "Launching the new exe directly while we're still alive raced the
|
|
// mutex and often left nothing running". Telling the new instance our pid so
|
|
// it could wait on the other side looked equivalent and was not; operators
|
|
// kept reporting no window after an update, on 0.27.23 and again after.
|
|
// What the helper has that neither rewrite did is that it OUTLIVES us: the
|
|
// launch happens after this process is completely gone, from a process that
|
|
// was never our child.
|
|
//
|
|
// The cost is known and accepted. Windows Defender removed 0.27.14 from a
|
|
// station as Trojan:Script/Wacatac.H!ml: an unsigned binary that replaces
|
|
// itself, clears the mark-of-the-web and spawns a windowless script to start
|
|
// another executable has the shape of a dropper, and the model reads shapes,
|
|
// not intentions. The operator's answer is to allow OpsLog in Defender. An
|
|
// updater that works and occasionally needs whitelisting beats one that
|
|
// leaves people with no running program.
|
|
//
|
|
// HideWindow here is right and is NOT the bug that made the updated OpsLog
|
|
// invisible: it hides POWERSHELL's console, which is the whole point. The
|
|
// new OpsLog is started by Start-Process, with a normal show.
|
|
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.Dir = dir
|
|
hideConsole(cmd)
|
|
if err := cmd.Start(); err != nil {
|
|
applog.Printf("update: the relaunch could not be started: %v", err)
|
|
return fmt.Errorf("schedule relaunch: %w", err)
|
|
}
|
|
// The HELPER's pid, so the log says the launcher was started and not just
|
|
// that we meant to. The new OpsLog logs its own arrival in startup.log; the
|
|
// two together tell "the helper never ran" from "it ran and the exe did not
|
|
// start", which have different causes.
|
|
applog.Printf("update: relaunch helper started as pid %d — it waits for this process (pid %d) to exit, then starts %s",
|
|
cmd.Process.Pid, os.Getpid(), filepath.Base(exe))
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|