Compare commits

..
4 Commits
Author SHA1 Message Date
rouggy bb596c8aa9 fix(linux): a refusal to start is shown, not written to nobody
fatalBox off Windows was a single println. That goes to stderr, and a
binary started from a file manager or a .desktop launcher has no stderr
anyone will read — so a carefully worded refusal ("the folder OpsLog
keeps everything in cannot be written to…") reached the operator as "I
click it and nothing happens", which is the least useful thing a program
can say.

It now opens a dialog through zenity or kdialog when either is present —
both ship with every distribution's desktop task — and writes to stderr
either way. Neither is required: the startup log already carries the
same text, and nothing here may stop OpsLog from exiting.
2026-09-11 10:41:40 +02:00
rouggy f22058355b fix(linux): the webkit build tag was inverted
Wails v2.11 defaults to webkit2gtk-4.0 and takes 4.1 only when told —

  #cgo !webkit2_41 pkg-config: webkit2gtk-4.0
  #cgo  webkit2_41 pkg-config: webkit2gtk-4.1

— and both the script and the doc had it the other way round: they
treated 4.1 as the default and offered a -tags webkit2_40 that does not
select anything. On a Debian 13 box, which ships only 4.1, the script
therefore detected 4.1, reported it as fine, passed no tag, ran npm for
several minutes and then stopped in cgo with "Package webkit2gtk-4.0 was
not found in the pkg-config search path".

Written from the Wails source rather than from memory this time, which
is where it should have come from in the first place.

libsoup is checked alongside, since it travels with the choice: 4.0
pairs with libsoup-2.4 and 4.1 with libsoup-3.0, and a missing one fails
the same way at the same late moment.
2026-09-11 09:52:47 +02:00
rouggy 13fd2367bd docs(linux): name the two things that bit a first Debian 13 build
Both found on a real trixie box, which is the first time any of this has
run on Linux.

Go can be too NEW, and that is the harder failure to meet cold. The
Wails CLI parses this package with the golang.org/x/tools its own go.mod
pins, and that release cannot read the export data a newer compiler
writes. With go1.27 — which is simply what go.dev offers as current —
the build dies with

  internal error: package "math" without types was imported from
  "hamlog/internal/geo"

naming neither Go nor Wails nor anything an operator could act on. The
setup script now says so when it sees 1.27 or later, and the doc tells
people to take 1.26 rather than the latest. Every version in both is
1.26.3, which is what the Windows build uses.

And the sound-server check was asking the wrong question. It ran `pactl
info`, but pactl comes from pulseaudio-utils, a package that has nothing
to do with whether a server is running — so a Debian 13 desktop with
PipeWire working perfectly was told it had no sound server. OpsLog
speaks the PulseAudio protocol itself and connects to the native socket,
so the socket is what the check looks for now, with pactl used only to
print the server's name when it happens to be there. It also says to run
from the desktop session rather than over SSH, the socket belonging to
the logged-in session.
2026-09-11 09:46:01 +02:00
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
9 changed files with 222 additions and 96 deletions
+31 -8
View File
@@ -38,15 +38,28 @@ sudo pacman -S base-devel pkgconf gtk3 webkit2gtk-4.1 nodejs npm
```
**Go and node do not come from the package manager.** No current distribution
ships a Go new enough for `go.mod` (Ubuntu 24.04 / Mint 22 have 1.22, Ubuntu
22.04 / Mint 21 have 1.18), and Ubuntu 22.04 / Mint 21 ship node 12 where Vite
needs 18. Both are the usual reason a first build fails with an error that
points somewhere else entirely:
ships a Go new enough for `go.mod` (Debian 12 has 1.19, Debian 13 has 1.24,
Ubuntu 24.04 / Mint 22 have 1.22), and Ubuntu 22.04 / Mint 21 ship node 12 where
Vite needs 18. Both are the usual reason a first build fails with an error that
points somewhere else entirely.
**Take 1.26, not the latest.** Go can also be too NEW. The Wails CLI parses this
package with the `golang.org/x/tools` its own `go.mod` pins, and that release
cannot read the export data a newer compiler writes — so a fresh `go1.27` fails
the build with something that names neither Go nor Wails:
```
internal error: package "math" without types was imported from "hamlog/internal/geo"
```
Met on Debian 13, where go.dev simply offers 1.27 as the current release. 1.25
and 1.26 are what this repository is built with. If you already installed a
newer one, replace it and reinstall the CLI so it is rebuilt:
```bash
# Go, from go.dev
wget https://go.dev/dl/go1.25.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz
wget https://go.dev/dl/go1.26.3.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.3.linux-amd64.tar.gz
echo 'export PATH=/usr/local/go/bin:$HOME/go/bin:$PATH' >> ~/.profile # log out and back in
# node 20, only if `node -v` is below 18
@@ -60,8 +73,18 @@ go install github.com/wailsapp/wails/v2/cmd/[email protected]
wails doctor # says what is still missing
```
`libwebkit2gtk-4.0` also works; pass `-tags webkit2_40` to `wails build` if your
distribution only has the older one.
**The webkit version decides a build tag.** Wails v2.11 defaults to
webkit2gtk-**4.0**; to build against **4.1** — which is all Debian 13 ships —
it has to be told:
```bash
wails build -tags webkit2_41 # webkit 4.1 (Debian 13, Fedora, Arch)
wails build # webkit 4.0 (Debian 12 and older)
```
Without the tag on a 4.1-only machine the frontend builds, npm runs, and then
cgo stops with `Package webkit2gtk-4.0 was not found in the pkg-config search
path`. `linux-setup.sh` picks the tag for you.
## Build
+35 -2
View File
@@ -2,5 +2,38 @@
package main
// fatalBox is Windows-only; elsewhere the terminal carries the message.
func fatalBox(title, text string) { println(title + ": " + text) }
import (
"os"
"os/exec"
)
// fatalBox says why OpsLog is not starting, as visibly as the desktop allows.
//
// It used to be one println. That goes to stderr, and a binary started from a
// file manager or a .desktop launcher has no stderr anybody will ever read — so
// the refusal that was carefully worded arrived as "I click it and nothing
// happens", which is the least useful thing a program can say.
//
// So: a real dialog when the desktop has one of the two tools that every
// distribution ships with its desktop task, and stderr regardless. Neither is
// required — the startup log has the same text, and nothing here is allowed to
// stop OpsLog from exiting.
func fatalBox(title, text string) {
os.Stderr.WriteString(title + ": " + text + "\n")
for _, c := range [][]string{
{"zenity", "--error", "--no-wrap", "--title", title, "--text", text},
{"kdialog", "--title", title, "--error", text},
// Neither is a hard requirement, and on a headless box there is nothing
// to show a dialog on anyway.
} {
if _, err := exec.LookPath(c[0]); err != nil {
continue
}
cmd := exec.Command(c[0], c[1:]...)
if err := cmd.Start(); err != nil {
continue
}
_ = cmd.Wait()
return
}
}
-27
View File
@@ -1,9 +1,7 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -31,28 +29,3 @@ func TestRelaunchCmdPassesItsArgumentsAndFolder(t *testing.T) {
t.Errorf("Dir = %q, want the executable's folder", cmd.Dir)
}
}
// The update relaunch goes through a helper that OUTLIVES this process.
//
// Two rewrites started the new exe from here instead, and both left operators
// with no window after an update: the launch then happens while this process is
// still alive and still holds the mutex. The helper waits for our pid first.
// Restored from before 0430aab and pinned here so a third rewrite has to argue
// with the two reports rather than rediscover them.
func TestUpdateRelaunchWaitsForUsFromOutside(t *testing.T) {
src, err := os.ReadFile("update.go")
if err != nil {
t.Fatalf("read update.go: %v", err)
}
got := string(src)
for _, want := range []string{"Wait-Process -Id", "Start-Process -FilePath"} {
if !strings.Contains(got, want) {
t.Errorf("update.go no longer contains %q — the relaunch must wait for this process from outside it", want)
}
}
// Start-Process shows the new window normally. A direct exec.Command(exe…)
// here is the shape that broke it, twice.
if strings.Contains(got, "exec.Command(exe") {
t.Error("update.go starts the new exe directly again")
}
}
+27
View File
@@ -3,7 +3,9 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -48,3 +50,28 @@ func TestRelaunchIsDetached(t *testing.T) {
t.Error("CREATE_NEW_PROCESS_GROUP is missing: a cleanup aimed at the old instance can reach the new one")
}
}
// The update relaunch goes through a helper that OUTLIVES this process.
//
// Two rewrites started the new exe from inside the dying one instead, and both
// left operators with no window after an update: the launch then happens while
// this process is still alive and still holds the mutex. The helper waits for
// our pid first. Restored from before 0430aab and pinned here so a third
// rewrite has to argue with the two reports rather than rediscover them.
func TestWindowsUpdateRelaunchWaitsForUsFromOutside(t *testing.T) {
src, err := os.ReadFile("updateswap_windows.go")
if err != nil {
t.Fatalf("read updateswap_windows.go: %v", err)
}
got := string(src)
for _, want := range []string{"Wait-Process -Id", "Start-Process -FilePath"} {
if !strings.Contains(got, want) {
t.Errorf("the relaunch no longer contains %q — it must wait for this process from outside it", want)
}
}
// Start-Process shows the new window normally. A direct exec.Command(exe…)
// is the shape that broke it, twice.
if strings.Contains(got, "exec.Command(exe") {
t.Error("the Windows relaunch starts the new exe directly again")
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func TestEveryRelaunchPassesItsPid(t *testing.T) {
// quietly matched nothing at all — a guard that passes because it looks
// nowhere.
spawn := regexp.MustCompile(`(exec\.Command|relaunchCmd)\(exe, "--(post-update|relaunch)"[^)]*\)`)
for _, file := range []string{"update.go", "app.go"} {
for _, file := range []string{"update.go", "app.go", "updateswap_linux.go"} {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
+58 -14
View File
@@ -37,17 +37,26 @@ have gcc || have cc || note "a C compiler (Wails links against the system WebKit
have pkg-config || note "pkg-config"
pkg gtk+-3.0 || note "GTK 3 development headers"
# Wails 2.10+ builds against webkit2gtk-4.1; Debian 12 and older still ship 4.0,
# which works with an extra build tag. Detect which one is present rather than
# telling the operator to guess.
# Which webkit, and therefore which build tag.
#
# Wails v2.11 defaults to webkit2gtk-4.0 and takes 4.1 only when told:
# `#cgo !webkit2_41 pkg-config: webkit2gtk-4.0` against
# `#cgo webkit2_41 pkg-config: webkit2gtk-4.1`. This script had it the other
# way round and passed no tag on a Debian 13 box that has only 4.1, so the
# frontend built and then cgo stopped with "Package webkit2gtk-4.0 was not
# found in the pkg-config search path" — after several minutes of npm.
#
# libsoup travels with it: 4.0 pairs with libsoup-2.4, 4.1 with libsoup-3.0.
webkit_tags=""
if pkg webkit2gtk-4.1; then
ok "webkit2gtk-4.1"
ok "webkit2gtk-4.1 (building with -tags webkit2_41)"
webkit_tags="-tags webkit2_41"
pkg libsoup-3.0 || note "libsoup 3 development headers (webkit 4.1 links against them)"
elif pkg webkit2gtk-4.0; then
ok "webkit2gtk-4.0 (older — will build with -tags webkit2_40)"
webkit_tags="-tags webkit2_40"
ok "webkit2gtk-4.0 (the default — no tag needed)"
pkg libsoup-2.4 || note "libsoup 2.4 development headers (webkit 4.0 links against them)"
else
note "webkit2gtk development headers (4.1 preferred, 4.0 accepted)"
note "webkit2gtk development headers (4.1 or 4.0)"
fi
# Node, and its VERSION — this is the trap on an LTS base. Ubuntu 22.04 (so
@@ -77,10 +86,28 @@ if have go; then
# golang-go` gets you a Go that cannot build this repository at all.
if ! go version | grep -Eq 'go1\.(2[5-9]|[3-9][0-9])'; then
note "go 1.25+ — $gov is too old for go.mod, and no apt package is new enough"
ylw " wget https://go.dev/dl/go1.25.1.linux-amd64.tar.gz"
ylw " sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz"
ylw " wget https://go.dev/dl/go1.26.3.linux-amd64.tar.gz"
ylw " sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.3.linux-amd64.tar.gz"
ylw " echo 'export PATH=/usr/local/go/bin:\$HOME/go/bin:\$PATH' >> ~/.profile # then log out and back in"
fi
# And TOO NEW breaks too, which is the harder one to meet cold. The Wails
# CLI parses this package with the golang.org/x/tools its own go.mod pins,
# and that release cannot read the export data a newer compiler writes. The
# failure names neither Go nor Wails:
#
# internal error: package "math" without types was imported from
# "hamlog/internal/geo"
#
# Met on Debian 13 with go1.27.1, which is simply what go.dev offers as
# latest. 1.25 and 1.26 are the versions this repository is built with.
if go version | grep -Eq 'go1\.(2[7-9]|[3-9][0-9])'; then
ylw " $gov is NEWER than the Wails CLI can parse this package with."
ylw " The build fails with: internal error: package \"math\" without types."
ylw " Install 1.26 (or 1.25) and rebuild the CLI with it:"
ylw " wget https://go.dev/dl/go1.26.3.linux-amd64.tar.gz"
ylw " sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.3.linux-amd64.tar.gz"
ylw " go install github.com/wailsapp/wails/v2/cmd/[email protected]"
fi
else
note "go 1.25+ — from https://go.dev/dl/, NOT from apt (no distribution ships 1.25 yet)"
fi
@@ -129,12 +156,29 @@ else
fi
head_ "Sound server"
if have pactl && pactl info >/dev/null 2>&1; then
ok "$(pactl info | sed -n 's/^Server Name: //p')"
# OpsLog speaks the PulseAudio protocol itself (jfreymuth/pulse) and connects to
# the NATIVE SOCKET. That socket is what decides whether audio works — not
# whether pactl is installed, which is a separate package (pulseaudio-utils).
# This check used to ask for pactl and told a Debian 13 desktop running PipeWire
# perfectly well that it had no sound server at all.
sock="${PULSE_SERVER:-}"
sock="${sock#unix:}"
if [ -z "$sock" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then
sock="$XDG_RUNTIME_DIR/pulse/native"
fi
if [ -n "$sock" ] && [ -S "$sock" ]; then
if have pactl && pactl info >/dev/null 2>&1; then
ok "$(pactl info | sed -n 's/^Server Name: //p')"
else
ok "socket at $sock (install pulseaudio-utils if you want its name)"
fi
else
ylw " No PulseAudio/PipeWire server answered. The voice keyer, the QSO"
ylw " recorder and the CW decoder will report they cannot reach it."
ylw " Everything else works without one."
ylw " No PulseAudio/PipeWire socket found."
ylw " The voice keyer, the QSO recorder and the CW decoder will report they"
ylw " cannot reach it. Everything else works without one."
ylw " sudo apt install pipewire-pulse wireplumber # Debian/Ubuntu"
ylw " And run this from the DESKTOP session: the sound server belongs to the"
ylw " logged-in session, so a remote shell may not see it."
fi
head_ "Building"
+6 -44
View File
@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -214,51 +213,14 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
}
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 {
// How the new build gets started differs by platform — see
// scheduleRelaunch in updateswap_windows.go and updateswap_linux.go. On
// Windows it is a helper that outlives us; on Linux it is simply the new
// binary, because nothing there holds an image open.
if err := a.scheduleRelaunch(exe, dir); err != nil {
applog.Printf("update: the relaunch could not be started: %v", err)
return fmt.Errorf("schedule relaunch: %w", err)
return 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 {
+21
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"hamlog/internal/applog"
)
@@ -40,3 +41,23 @@ func (a *App) scheduleDeferredSwap(exe, pending string) error {
applog.Printf("update: installed %s over %s after the staging rename failed", filepath.Base(pending), filepath.Base(exe))
return nil
}
// scheduleRelaunch starts the new build. On Linux that is simply the new binary.
//
// None of the Windows machinery applies: nothing holds an executable open while
// it runs, so the swap above 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 before trying it. No helper, no script, and
// nothing that needs to outlive us.
func (a *App) scheduleRelaunch(exe, dir string) error {
cmd := relaunchCmd(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule relaunch: %w", err)
}
applog.Printf("update: relaunch started as pid %d — it waits for this process (pid %d) to exit",
cmd.Process.Pid, os.Getpid())
// 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.
_ = cmd.Process.Release()
return nil
}
+43
View File
@@ -22,6 +22,49 @@ 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.
//