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