Files
OpsLog/updateswap_windows.go
T
rouggy f8d47cd3b5 fix(linux): the update relaunch is per-platform again
Restoring the PowerShell helper put it in update.go, which is shared —
so a Linux build would have tried to run `powershell` to relaunch
itself. It compiled and vetted cleanly for linux/amd64, which is exactly
why it needed catching before somebody built it: the fault only shows on
a real update, on a machine that has no PowerShell.

scheduleRelaunch now lives in the platform files. Windows keeps the
helper. Linux starts the new binary directly, which is right there and
not a compromise: nothing holds an executable open while it runs, so the
swap has already succeeded, and there is no mutex to race — the
single-instance guard is an flock the dying process releases as it
exits, and the new one waits for our pid first.

The two guards were looking at the old location and had to follow: the
Wait-Process/Start-Process check moves into relaunch_windows_test.go
where it belongs, and TestEveryRelaunchPassesItsPid now scans
updateswap_linux.go too — the direct spawn moved there, and without it
the test would have gone quiet again.

Checked from Windows, as BUILDING-LINUX.md says is done at every
release: GOOS=linux go build ./... and go vet ./... both clean.
2026-09-11 09:28:55 +02:00

113 lines
5.4 KiB
Go

//go:build windows
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"hamlog/internal/applog"
)
// clearDownloadMark strips the NTFS "downloaded from the internet" 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
// used to fail after an update.
func clearDownloadMark(path string) { _ = os.Remove(path + ":Zone.Identifier") }
// makeExecutable is a no-op on Windows, where the extension decides.
func makeExecutable(path string) error { return nil }
// scheduleRelaunch starts the new build once THIS process is gone.
//
// A detached, hidden PowerShell waits for our pid and then launches the exe.
// Restored verbatim from before 0430aab, after two rewrites that started the
// new exe from inside the dying process both failed on real stations — 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 was not. What the helper has that neither
// rewrite did is that it OUTLIVES us.
//
// The cost is known and accepted: Windows Defender removed 0.27.14 from a
// station as Trojan:Script/Wacatac.H!ml, because 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. Allowing OpsLog in
// Defender beats an updater that leaves people with no running program.
//
// HideWindow is right here and is NOT the bug that made the updated OpsLog
// invisible: it hides POWERSHELL's console, which is the point, while
// Start-Process shows the new window normally.
func (a *App) scheduleRelaunch(exe, dir string) error {
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 {
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()
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)
hideConsole(cmd)
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
}