diff --git a/BUILDING-LINUX.md b/BUILDING-LINUX.md new file mode 100644 index 0000000..c26fe5d --- /dev/null +++ b/BUILDING-LINUX.md @@ -0,0 +1,112 @@ +# Building OpsLog on Linux + +OpsLog is developed on Windows. The Linux build shares every line of the +frontend and all but a handful of Go files; what differs is listed at the bottom +of this page. + +**It cannot be cross-compiled from Windows.** Wails links against the system +WebKit on Linux, which needs cgo and the GTK/WebKit headers, so the binary has +to be produced on a Linux machine (or a container). What *can* be checked from +Windows — and is, at every release — is that the Go half still compiles: + +```bash +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./... +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go vet ./... +``` + +## The short way + +```bash +./scripts/linux-setup.sh +``` + +It checks everything below, prints the one install command your distribution +needs if something is missing, and builds when nothing is. The rest of this page +is what it checks, for when you would rather do it by hand. + +## Dependencies + +```bash +# Debian / Ubuntu +sudo apt install build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev nodejs npm + +# Fedora +sudo dnf install gcc-c++ pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel nodejs npm + +# Arch +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: + +```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 +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 +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install nodejs +``` + +Then the Wails CLI: + +```bash +go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 +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. + +## Build + +```bash +wails build # → build/bin/OpsLog +./build/bin/OpsLog +``` + +`wails dev` works the same as on Windows. + +## Runtime requirements + +- **PulseAudio or PipeWire** for the voice keyer, the QSO recorder and the CW + decoder. PipeWire is fine — OpsLog speaks the PulseAudio protocol, which + `pipewire-pulse` answers. Without a sound server those three features report + "cannot reach the sound server" and everything else works normally. +- **Serial port access** for CAT, keyers, rotators and amplifiers. Ports appear + as `/dev/ttyUSB0`, `/dev/ttyACM0`… and on most distributions belong to the + `dialout` group: + + ```bash + sudo usermod -aG dialout $USER # log out and back in + ``` + + This is the single most common reason a rig that works in WSJT-X shows + "permission denied" in OpsLog. +- **TrustedQSL** (`tqsl`) for LoTW uploads, from your package manager. OpsLog + finds it on `PATH`. + +## Where OpsLog keeps its data + +Next to the binary, in `data/` — the same portable layout as on Windows, so a +folder in your home directory carries the logbook with it. + +If the binary sits somewhere you cannot write (`/usr/bin`, `/opt`), OpsLog uses +`~/.local/share/OpsLog/data` instead and says so in `startup.log`. The startup +log itself lives in `~/.cache/OpsLog/startup.log`. + +## What is different from the Windows build + +| | | +|---|---| +| **OmniRig** | Not available — it is Windows COM automation. Use a native backend instead: Icom CI-V (USB and network), Yaesu, Kenwood/Elecraft, FlexRadio, TCI, Xiegu. | +| **Denkovi USB relay** | Not available — it needs FTDI's `ftd2xx.dll`. The other relay backends work. | +| **Audio** | PulseAudio/PipeWire instead of WASAPI. Same devices, same fixed 16 kHz mono format. | +| **Auto-update** | Works, and is simpler: Linux lets a running binary be replaced, so none of the Windows deferred-swap machinery is needed. | +| **Window placement** | OpsLog cannot read the monitor layout, so a saved window position is always trusted rather than clamped onto a visible screen. | +| **Single instance** | An `flock` on `$XDG_RUNTIME_DIR/OpsLog/instance.lock` instead of a named mutex. It cannot raise the existing window, only refuse to start a second one. | diff --git a/app.go b/app.go index 221942e..2d15511 100644 --- a/app.go +++ b/app.go @@ -2035,15 +2035,24 @@ func (a *App) shutdown(ctx context.Context) { applog.Printf("shutdown: teardown done") } -// userDataDir returns the OpsLog data directory: always "/data". +// userDataDir returns the OpsLog data directory: "/data". // All data (database, settings, cty.dat, logs) travels with the executable, // making OpsLog fully portable for USB sticks and PC migrations. +// +// systemInstallDataDir is the one exception, and it never fires on Windows: a +// Linux build installed system-wide (/usr/bin, /opt) sits in a folder no user +// may write, so there the data moves to ~/.local/share/OpsLog. See +// datadir_linux.go for why that is decided by trying rather than by path. func userDataDir() (string, error) { exe, err := os.Executable() if err != nil { return "", fmt.Errorf("cannot locate executable: %w", err) } - return filepath.Join(filepath.Dir(exe), "data"), nil + beside := filepath.Join(filepath.Dir(exe), "data") + if alt, ok := systemInstallDataDir(beside); ok { + return alt, nil + } + return beside, nil } // fileExists reports whether path exists and is a regular file. @@ -19893,7 +19902,7 @@ func tidySerialPorts(ports []string) []string { } key := strings.ToUpper(name) if seen[key] { - applog.Printf("serial: %s is claimed by more than one device in the Windows port map — listing it once", name) + applog.Printf("serial: %s is claimed by more than one device in the system port map — listing it once", name) continue } seen[key] = true @@ -19910,11 +19919,41 @@ func tidySerialPorts(ports []string) []string { case okj: return false } - return out[i] < out[j] + return naturalLess(out[i], out[j]) }) return out } +// naturalLess orders names that end in a number by that number, so +// /dev/ttyUSB9 comes before /dev/ttyUSB10. The same trap as COM4/COM10 above, +// met on Linux where the port names are paths rather than COMn — a rig on +// ttyUSB10 listed between ttyUSB1 and ttyUSB2 is a rig the operator scrolls +// past. +func naturalLess(a, b string) bool { + pa, na, oka := trailingNumber(a) + pb, nb, okb := trailingNumber(b) + if oka && okb && pa == pb { + return na < nb + } + return a < b +} + +// trailingNumber splits "name123" into "name" and 123. +func trailingNumber(s string) (string, int, bool) { + i := len(s) + for i > 0 && s[i-1] >= '0' && s[i-1] <= '9' { + i-- + } + if i == len(s) || i == 0 { + return s, 0, false + } + n, err := strconv.Atoi(s[i:]) + if err != nil { + return s, 0, false + } + return s[:i], n, true +} + // comPortNumber extracts n from "COMn", false for any other shape. func comPortNumber(s string) (int, bool) { if len(s) <= 3 || !strings.EqualFold(s[:3], "COM") { diff --git a/autostart.go b/autostart.go index 4486037..1087e17 100644 --- a/autostart.go +++ b/autostart.go @@ -6,10 +6,8 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "sync" - "syscall" "hamlog/internal/applog" @@ -75,11 +73,8 @@ func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error { // BrowseExecutable opens a native file picker for choosing a program to launch. func (a *App) BrowseExecutable() (string, error) { return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{ - Title: "Choose a program to launch on startup", - Filters: []wruntime.FileFilter{ - {DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"}, - {DisplayName: "All files (*.*)", Pattern: "*.*"}, - }, + Title: "Choose a program to launch on startup", + Filters: executableFilters(), }) } @@ -150,9 +145,7 @@ func (a *App) CloseAutostartPrograms() { if name == "" { name = filepath.Base(p.Path) } - cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid)) - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} - if out, err := cmd.CombinedOutput(); err != nil { + if out, err := closeProcess(pid); err != nil { applog.Printf("autostart: could not close %s (pid %d): %v — %s", name, pid, err, strings.TrimSpace(string(out))) continue } @@ -227,33 +220,3 @@ func splitArgs(s string) []string { } return args } - -// runningProcessNames returns the set of lowercase executable names currently -// running, via the Windows `tasklist`. Best effort — on failure the set is -// empty (we then just attempt to launch, which is acceptable). -func runningProcessNames() map[string]bool { - out := map[string]bool{} - cmd := exec.Command("tasklist", "/FO", "CSV", "/NH") - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW - data, err := cmd.Output() - if err != nil { - applog.Printf("autostart: tasklist failed: %v", err) - return out - } - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - // CSV row: "image.exe","PID",... — take the first quoted field. - field := line - if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") { - field = line[1 : i+1] - } - field = strings.Trim(field, "\"") - if field != "" { - out[strings.ToLower(field)] = true - } - } - return out -} diff --git a/bootlog.go b/bootlog.go index c612359..db3400f 100644 --- a/bootlog.go +++ b/bootlog.go @@ -23,12 +23,9 @@ import ( "time" ) -// bootLogPath is the file, or "" when even LOCALAPPDATA is unavailable. +// bootLogPath is the file, or "" when no writable folder can be found at all. func bootLogPath() string { - dir := os.Getenv("LOCALAPPDATA") - if strings.TrimSpace(dir) == "" { - dir = os.TempDir() - } + dir := bootLogDir() if dir == "" { return "" } @@ -136,11 +133,23 @@ func webviewDataPath() string { // stuckMarkerPath is written before the window is attempted and removed once it // opens, so the NEXT launch can tell that the last one never got there. func stuckMarkerPath() string { - dir := os.Getenv("LOCALAPPDATA") - if strings.TrimSpace(dir) == "" { - dir = os.TempDir() + return filepath.Join(bootLogDir(), "OpsLog", ".launching") +} + +// bootLogDir is where the breadcrumbs live: %LOCALAPPDATA% on Windows, and on +// Linux the XDG cache directory (~/.cache) that os.UserCacheDir resolves to. +// +// The temp directory is the last resort and not the first, because it is the +// one place the evidence does not survive: a station that reboots after a +// failed launch loses exactly the log that would have explained it. +func bootLogDir() string { + if dir := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); dir != "" { + return dir } - return filepath.Join(dir, "OpsLog", ".launching") + if dir, err := os.UserCacheDir(); err == nil && strings.TrimSpace(dir) != "" { + return dir + } + return os.TempDir() } // lastLaunchHung is set at startup from the marker left by the previous run. diff --git a/datadir_linux.go b/datadir_linux.go new file mode 100644 index 0000000..ad0ec8c --- /dev/null +++ b/datadir_linux.go @@ -0,0 +1,74 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "sync" +) + +// systemInstallDataDir keeps the portable "data beside the binary" layout where +// it works, and falls back to the XDG data directory where it cannot. +// +// Both halves are needed on Linux, and only on Linux. A tarball or AppImage +// unpacked into the home directory behaves exactly like the Windows build — +// the folder travels with the program, which is the whole point of the design. +// But the ordinary way software arrives here is a package that installs into +// /usr/bin or /opt, where no user may write, and telling an operator to "move +// the program somewhere writable" is telling them their distribution installed +// it wrong. So when the folder beside the binary is read-only, OpsLog keeps its +// data in ~/.local/share/OpsLog instead and says so in the log. +// +// Detection is by TRYING, not by matching path prefixes: /opt, /usr/local and a +// NFS-mounted home are all writable on some stations and not on others, and the +// only honest test is whether the write succeeds. +func systemInstallDataDir(besideExe string) (string, bool) { + xdgOnce.Do(func() { xdgDir, xdgUsed = resolveDataDir(besideExe) }) + return xdgDir, xdgUsed +} + +var ( + xdgOnce sync.Once + xdgDir string + xdgUsed bool +) + +func resolveDataDir(besideExe string) (string, bool) { + if writable(besideExe) { + return "", false + } + base := os.Getenv("XDG_DATA_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false // nowhere better to go; let the caller report the failure + } + base = filepath.Join(home, ".local", "share") + } + alt := filepath.Join(base, "OpsLog", "data") + if !writable(alt) { + return "", false + } + bootLog("data dir: %s is not writable — keeping the data in %s instead", besideExe, alt) + return alt, true +} + +// writable reports whether dir can be created and written to. The probe file is +// removed again; a leftover in the data folder would be one more thing to +// explain. +func writable(dir string) bool { + if err := os.MkdirAll(dir, 0o755); err != nil { + return false + } + probe := filepath.Join(dir, ".writetest") + if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil { + return false + } + _ = os.Remove(probe) + return true +} + +// dataDirAdvice is what the operator is told when neither location works — a +// full disk, or a home directory that is not writable either. +const dataDirAdvice = "\n\nOpsLog keeps its data next to the program, or in ~/.local/share/OpsLog when that folder belongs to the system. Neither could be written to: check the disk is not full and that your home directory is writable." diff --git a/datadir_windows.go b/datadir_windows.go new file mode 100644 index 0000000..ae619b2 --- /dev/null +++ b/datadir_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package main + +// systemInstallDataDir never diverts on Windows: the data folder is beside the +// executable, full stop. That is what makes an OpsLog on a USB stick carry its +// logbook with it, and an operator who copies the folder to a new PC find +// everything already there. +// +// A copy dropped into Program Files is refused the write and told so (see +// checkDataDirWritable) rather than quietly logging somewhere else, because +// "where are my QSOs?" is a far worse afternoon than "move this folder". +func systemInstallDataDir(besideExe string) (string, bool) { return "", false } + +// dataDirAdvice is what the operator is told when that folder cannot be written. +const dataDirAdvice = "\n\nMove OpsLog.exe somewhere your account can write — a folder in Documents, or the desktop — and start it again. Program Files is refused to anything not running as administrator." diff --git a/go.mod b/go.mod index d70b9e2..a083bde 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-ole/go-ole v1.3.0 github.com/go-sql-driver/mysql v1.10.0 github.com/gorilla/websocket v1.5.3 + github.com/jfreymuth/pulse v0.1.3 github.com/jlaffaye/ftp v0.2.2 github.com/moutend/go-wca v0.3.0 github.com/wailsapp/wails/v2 v2.11.0 diff --git a/go.sum b/go.sum index 1a1d46b..1bfb1cc 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14= +github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= github.com/jlaffaye/ftp v0.2.2 h1:JwjrXCAIjN9ZYrF1/8qlmHFXDteh9MHYaiEIh/Oqtd8= github.com/jlaffaye/ftp v0.2.2/go.mod h1:zuLAKdqFqFvNgkCrH0SC7K1XyUiydS7BFCmmoHUWWg0= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= diff --git a/internal/audio/device.go b/internal/audio/device.go new file mode 100644 index 0000000..d940e10 --- /dev/null +++ b/internal/audio/device.go @@ -0,0 +1,39 @@ +package audio + +// Device is one audio endpoint (a capture input or a render output). +// +// ID is whatever the platform calls the endpoint and is PERSISTED in settings: +// a WASAPI endpoint id on Windows, a PulseAudio source/sink name on Linux. +// It is opaque to everything above this package, which only ever hands it back. +type Device struct { + ID string `json:"id"` // opaque platform endpoint id (persisted) + Name string `json:"name"` // friendly name shown in dropdowns + Default bool `json:"default"` // is this the system default endpoint +} + +// DeviceName resolves an endpoint id to its friendly name. +// +// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator +// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they +// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points +// straight at the DAX panel. +// +// Falls back to the id when the endpoint cannot be found, which is itself worth +// seeing: a device that has disappeared explains an empty recording too. +func DeviceName(id string) string { + if id == "" { + return "(none)" + } + for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} { + devs, err := list() + if err != nil { + continue + } + for _, d := range devs { + if d.ID == id { + return d.Name + } + } + } + return id +} diff --git a/internal/audio/devices.go b/internal/audio/devices.go index f7d9cef..28b66b8 100644 --- a/internal/audio/devices.go +++ b/internal/audio/devices.go @@ -15,13 +15,6 @@ import ( "github.com/moutend/go-wca/pkg/wca" ) -// Device is one audio endpoint (a capture input or a render output). -type Device struct { - ID string `json:"id"` // stable WASAPI endpoint id (persisted) - Name string `json:"name"` // friendly name shown in dropdowns - Default bool `json:"default"` // is this the system default endpoint -} - // ListInputDevices returns the active capture endpoints — microphones, // line-in, and the soundcard input wired to the rig's audio out ("From Radio"). func ListInputDevices() ([]Device, error) { return listEndpoints(wca.ECapture) } @@ -101,30 +94,3 @@ func endpointName(dev *wca.IMMDevice, fallback string) string { } return fallback } - -// DeviceName resolves an endpoint id to its friendly name. -// -// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator -// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they -// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points -// straight at the DAX panel. -// -// Falls back to the id when the endpoint cannot be found, which is itself worth -// seeing: a device that has disappeared explains an empty recording too. -func DeviceName(id string) string { - if id == "" { - return "(none)" - } - for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} { - devs, err := list() - if err != nil { - continue - } - for _, d := range devs { - if d.ID == id { - return d.Name - } - } - } - return id -} diff --git a/internal/audio/devices_linux.go b/internal/audio/devices_linux.go new file mode 100644 index 0000000..4036d08 --- /dev/null +++ b/internal/audio/devices_linux.go @@ -0,0 +1,99 @@ +//go:build linux + +package audio + +// devices_linux.go — audio endpoints on Linux, through PulseAudio. +// +// PulseAudio and not ALSA, for two reasons that both matter here. ALSA's C +// library needs cgo, and OpsLog is a pure-Go build; and PulseAudio is the API +// that is actually present on a ham's desktop — PipeWire, which most current +// distributions ship, answers the PulseAudio protocol through pipewire-pulse, +// so one client speaks to both. github.com/jfreymuth/pulse implements that +// protocol in Go over the server's Unix socket, so nothing is linked in. +// +// The endpoint id we persist is the sink/source NAME +// ("alsa_input.usb-Icom_Inc._IC-7610-00.analog-stereo"), never the numeric +// index: the index is assigned at boot in device-arrival order and moves the +// moment a rig is plugged in before a headset. + +import ( + "fmt" + "strings" + + "github.com/jfreymuth/pulse" +) + +// pulseClient opens a short-lived connection to the local sound server. Each +// call gets its own: the connection is a Unix socket to a server that may be +// restarted underneath us (a PipeWire update, a user logging the session out +// and in), and holding one open for the lifetime of the app means every later +// call fails until OpsLog itself restarts. +func pulseClient() (*pulse.Client, error) { + c, err := pulse.NewClient(pulse.ClientApplicationName("OpsLog")) + if err != nil { + return nil, fmt.Errorf("cannot reach the sound server (is PulseAudio or PipeWire running?): %w", err) + } + return c, nil +} + +// ListInputDevices returns the capture sources. +// +// Monitor sources (".monitor", what a given output is playing) are kept rather +// than filtered out. They look like clutter until you meet the operator whose +// rig audio reaches OpsLog through a virtual cable — on Linux that is a +// null-sink and its monitor, and hiding it would hide the only device that +// works for them. +func ListInputDevices() ([]Device, error) { + c, err := pulseClient() + if err != nil { + return nil, err + } + defer c.Close() + + srcs, err := c.ListSources() + if err != nil { + return nil, err + } + defID := "" + if d, err := c.DefaultSource(); err == nil && d != nil { + defID = d.ID() + } + out := make([]Device, 0, len(srcs)) + for _, s := range srcs { + out = append(out, Device{ID: s.ID(), Name: endpointLabel(s.Name(), s.ID()), Default: s.ID() == defID}) + } + return out, nil +} + +// ListOutputDevices returns the render sinks. +func ListOutputDevices() ([]Device, error) { + c, err := pulseClient() + if err != nil { + return nil, err + } + defer c.Close() + + sinks, err := c.ListSinks() + if err != nil { + return nil, err + } + defID := "" + if d, err := c.DefaultSink(); err == nil && d != nil { + defID = d.ID() + } + out := make([]Device, 0, len(sinks)) + for _, s := range sinks { + out = append(out, Device{ID: s.ID(), Name: endpointLabel(s.Name(), s.ID()), Default: s.ID() == defID}) + } + return out, nil +} + +// endpointLabel prefers the server's human description ("USB Audio CODEC +// Analog Stereo") and falls back to the raw name, which is ugly but still +// identifies the device — an empty entry in the dropdown identifies nothing. +func endpointLabel(desc, id string) string { + if d := strings.TrimSpace(desc); d != "" { + return d + } + return id +} diff --git a/internal/audio/engine.go b/internal/audio/engine.go index 6d20a7e..bf67931 100644 --- a/internal/audio/engine.go +++ b/internal/audio/engine.go @@ -5,7 +5,6 @@ package audio import ( "fmt" "runtime" - "sync" "time" "unsafe" @@ -281,63 +280,6 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct } } -// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live -// render stream. Producers (a USB-codec capture, or a decoded network audio -// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared -// hand-off point between "where the audio comes from" (USB device / UDP 50003) -// and "where it's heard" (any WASAPI output) — so the transport can be swapped -// without touching the render side, mirroring the civTransport split on the CAT -// side. On overflow the oldest audio is dropped to keep latency bounded; on -// underrun Pull simply returns short and the render loop pads with silence. -type pcmRing struct { - mu sync.Mutex - buf []byte - max int // hard cap in bytes (drops oldest beyond this → bounded latency) -} - -// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the -// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s. -func newPCMRing(maxBytes int) *pcmRing { - if maxBytes <= 0 { - maxBytes = bytesPerSec // 1 s default - } - return &pcmRing{max: maxBytes} -} - -// Push appends samples, dropping the oldest audio if the backlog would exceed -// the cap (a slow/absent consumer never makes the producer block or grow without -// bound). A short glitch beats runaway latency for live monitoring. -func (r *pcmRing) Push(p []byte) { - if len(p) == 0 { - return - } - r.mu.Lock() - r.buf = append(r.buf, p...) - if len(r.buf) > r.max { - drop := len(r.buf) - r.max - r.buf = append(r.buf[:0], r.buf[drop:]...) - } - r.mu.Unlock() -} - -// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil -// when empty. The render loop pads any shortfall with silence. -func (r *pcmRing) pull(maxBytes int) []byte { - r.mu.Lock() - defer r.mu.Unlock() - if len(r.buf) == 0 || maxBytes <= 0 { - return nil - } - n := maxBytes - if n > len(r.buf) { - n = len(r.buf) - } - out := make([]byte, n) - copy(out, r.buf[:n]) - r.buf = append(r.buf[:0], r.buf[n:]...) - return out -} - // renderStream continuously renders PCM pulled from src to a device until stop // closes — the streaming counterpart to playPCM's fixed buffer. On underrun it // writes silence rather than glitching, keeping the WASAPI clock steady so live diff --git a/internal/audio/engine_linux.go b/internal/audio/engine_linux.go new file mode 100644 index 0000000..fe55e0a --- /dev/null +++ b/internal/audio/engine_linux.go @@ -0,0 +1,275 @@ +//go:build linux + +package audio + +// engine_linux.go — the four calls the rest of the package makes into the sound +// card, implemented on PulseAudio. The Windows half of this pair is engine.go +// (WASAPI); nothing above these functions knows which one it is talking to. +// +// Capture is fixed at 16 kHz mono 16-bit, the format the DVK, the recorder and +// the CW tap all share (see wav.go). We ask the server for it and let the +// server resample from whatever the device really runs at — the same division +// of labour as WASAPI's AUTOCONVERTPCM, and for the same reason: a rig codec +// that only does 48 kHz must still feed a 16 kHz pipeline, and the sound +// server's converter filters before it decimates, where a naive one folds the +// receiver hiss above 8 kHz straight back on top of the voice. + +import ( + "fmt" + "io" + "time" + + "github.com/jfreymuth/pulse" + "github.com/jfreymuth/pulse/proto" +) + +// chunkFrames is how much audio a playback reader hands over at once (20 ms). +// It bounds how much silence a padded underrun can queue ahead of real audio, +// which is what keeps live monitoring from drifting seconds behind the rig. +const chunkFrames = sampleRate / 50 + +// channelMap describes n channels to the server. Only mono and stereo occur +// here — capture is always mono, and playback follows the WAV being played. +func channelMap(n int) proto.ChannelMap { + if n >= 2 { + return proto.ChannelMap{proto.ChannelLeft, proto.ChannelRight} + } + return proto.ChannelMap{proto.ChannelMono} +} + +// chunkWriter turns the record stream's byte deliveries into onChunk calls. +// The server reuses its buffer between deliveries, so every chunk is copied +// before it leaves: the recorder keeps the slices it is given. +type chunkWriter struct{ onChunk func([]byte) } + +func (w chunkWriter) Write(p []byte) (int, error) { + if len(p) > 0 && w.onChunk != nil { + cp := make([]byte, len(p)) + copy(cp, p) + w.onChunk(cp) + } + return len(p), nil +} + +// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the +// stop channel is closed. +func recordPCM(deviceID string, stop <-chan struct{}) ([]byte, error) { + out := make([]byte, 0, bytesPerSec*4) + err := captureStream(deviceID, stop, func(chunk []byte) { out = append(out, chunk...) }) + return out, err +} + +// captureStream opens a device and calls onChunk with freshly-captured 16 kHz +// mono 16-bit PCM as it arrives, until stop closes. onChunk receives a private +// copy it may retain. +func captureStream(deviceID string, stop <-chan struct{}, onChunk func([]byte)) error { + c, err := pulseClient() + if err != nil { + return err + } + defer c.Close() + + // Rate and channels first, then latency — the latency option sizes its + // buffer from both, so setting it earlier would size it from the defaults. + // + // 50 ms of fragment: the CW decoder is downstream of this and works on the + // chunks as they arrive, so a server-chosen fragment of a quarter of a + // second would make it decide about a dit long after the dit was over. + opts := []pulse.RecordOption{ + pulse.RecordSampleRate(sampleRate), + pulse.RecordChannels(channelMap(channels)), + pulse.RecordLatency(0.05), + pulse.RecordMediaName("OpsLog capture"), + } + // An empty id means "whatever the desktop calls the default", which is also + // what an operator who has never opened the audio settings expects. + if deviceID != "" { + src, err := c.SourceByID(deviceID) + if err != nil { + return fmt.Errorf("no audio input %q: %w", deviceID, err) + } + opts = append(opts, pulse.RecordSource(src)) + } + st, err := c.NewRecord(pulse.NewWriter(chunkWriter{onChunk}, proto.FormatInt16LE), opts...) + if err != nil { + return fmt.Errorf("open capture: %w", err) + } + defer st.Close() + + st.Start() + <-stop + st.Stop() + return st.Error() +} + +// playPCM plays a fixed buffer to a device and returns when it has been heard +// (or when stop closes, which cuts it short). +func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct{}) error { + if len(pcm) == 0 { + return nil + } + format, err := pulseFormat(bits) + if err != nil { + return err + } + frameBytes := ch * bits / 8 + if frameBytes <= 0 || rate <= 0 { + return fmt.Errorf("bad audio format") + } + + c, err := pulseClient() + if err != nil { + return err + } + defer c.Close() + + // The reader hands out the buffer a slice at a time and ends the stream + // with EndOfData — the library's own sentinel. io.EOF would work as an end + // too, but it is recorded as the stream's error, and a message finishing + // normally must not look like a fault in the log. + pos := 0 + read := func(buf []byte) (int, error) { + select { + case <-stop: + return 0, pulse.EndOfData + default: + } + if pos >= len(pcm) { + return 0, pulse.EndOfData + } + n := copy(buf, pcm[pos:]) + n -= n % frameBytes // never hand the server a partial frame + if n == 0 { + return 0, pulse.EndOfData + } + pos += n + return n, nil + } + + opts := []pulse.PlaybackOption{ + pulse.PlaybackSampleRate(rate), + pulse.PlaybackChannels(channelMap(ch)), + pulse.PlaybackLatency(0.1), + pulse.PlaybackMediaName("OpsLog playback"), + } + if deviceID != "" { + sink, err := c.SinkByID(deviceID) + if err != nil { + return fmt.Errorf("no audio output %q: %w", deviceID, err) + } + opts = append(opts, pulse.PlaybackSink(sink)) + } + st, err := c.NewPlayback(pulse.NewReader(readerFunc(read), format), opts...) + if err != nil { + return fmt.Errorf("open playback: %w", err) + } + defer st.Close() + + st.Start() + + // Drain blocks until the server has played everything queued. Waiting on it + // in a goroutine keeps stop responsive: a voice message must cut off the + // instant the operator unkeys, not at the end of the buffer. + drained := make(chan struct{}) + go func() { st.Drain(); close(drained) }() + select { + case <-drained: + case <-stop: + st.Stop() + } + return st.Error() +} + +// renderStream continuously renders PCM pulled from src to a device until stop +// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it +// writes silence rather than glitching, keeping the server's clock steady so +// live monitor audio flows smoothly even when the source stalls briefly. +func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error { + format, err := pulseFormat(bits) + if err != nil { + return err + } + frameBytes := ch * bits / 8 + if frameBytes <= 0 || rate <= 0 || src == nil { + return fmt.Errorf("bad audio format") + } + + c, err := pulseClient() + if err != nil { + return err + } + defer c.Close() + + // Never return 0 bytes without an error: the library's playback loop would + // spin on it. A stalled source therefore yields silence, which is also the + // behaviour that keeps the clock running. + chunk := chunkFrames * frameBytes + read := func(buf []byte) (int, error) { + select { + case <-stop: + return 0, pulse.EndOfData + default: + } + n := len(buf) + if n > chunk { + n = chunk + } + n -= n % frameBytes + if n == 0 { + n = frameBytes + } + got := copy(buf[:n], src.pull(n)) + for i := got; i < n; i++ { + buf[i] = 0 + } + return n, nil + } + + opts := []pulse.PlaybackOption{ + pulse.PlaybackSampleRate(rate), + pulse.PlaybackChannels(channelMap(ch)), + pulse.PlaybackLatency(0.1), + pulse.PlaybackMediaName("OpsLog monitor"), + } + if deviceID != "" { + sink, err := c.SinkByID(deviceID) + if err != nil { + return fmt.Errorf("no audio output %q: %w", deviceID, err) + } + opts = append(opts, pulse.PlaybackSink(sink)) + } + st, err := c.NewPlayback(pulse.NewReader(readerFunc(read), format), opts...) + if err != nil { + return fmt.Errorf("open monitor: %w", err) + } + defer st.Close() + + st.Start() + <-stop + st.Stop() + // Give the server a moment to notice the stream stopped before the client + // socket goes away, so the last fragment is heard instead of clipped. + time.Sleep(20 * time.Millisecond) + return st.Error() +} + +// pulseFormat maps a WAV bit depth onto the server's sample formats. 8 and 16 +// bit cover everything OpsLog produces or reads; anything else is refused by +// name rather than played as noise. +func pulseFormat(bits int) (byte, error) { + switch bits { + case 8: + return proto.FormatUint8, nil + case 16: + return proto.FormatInt16LE, nil + default: + return 0, fmt.Errorf("unsupported sample size %d-bit (8 or 16 expected)", bits) + } +} + +// readerFunc adapts a read closure to io.Reader. +type readerFunc func([]byte) (int, error) + +func (f readerFunc) Read(p []byte) (int, error) { return f(p) } + +var _ io.Reader = readerFunc(nil) diff --git a/internal/audio/manager.go b/internal/audio/manager.go index 2a12108..09189a8 100644 --- a/internal/audio/manager.go +++ b/internal/audio/manager.go @@ -1,5 +1,3 @@ -//go:build windows - package audio import ( diff --git a/internal/audio/mp3.go b/internal/audio/mp3.go index 2ed8f53..09d6cae 100644 --- a/internal/audio/mp3.go +++ b/internal/audio/mp3.go @@ -1,5 +1,3 @@ -//go:build windows - package audio import ( diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index bd831da..f8d71dc 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -1,5 +1,3 @@ -//go:build windows - package audio import ( diff --git a/internal/audio/ring.go b/internal/audio/ring.go new file mode 100644 index 0000000..b598eb6 --- /dev/null +++ b/internal/audio/ring.go @@ -0,0 +1,60 @@ +package audio + +import "sync" + +// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live +// render stream. Producers (a USB-codec capture, or a decoded network audio +// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared +// hand-off point between "where the audio comes from" (USB device / UDP 50003) +// and "where it's heard" (any WASAPI output) — so the transport can be swapped +// without touching the render side, mirroring the civTransport split on the CAT +// side. On overflow the oldest audio is dropped to keep latency bounded; on +// underrun Pull simply returns short and the render loop pads with silence. +type pcmRing struct { + mu sync.Mutex + buf []byte + max int // hard cap in bytes (drops oldest beyond this → bounded latency) +} + +// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the +// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s. +func newPCMRing(maxBytes int) *pcmRing { + if maxBytes <= 0 { + maxBytes = bytesPerSec // 1 s default + } + return &pcmRing{max: maxBytes} +} + +// Push appends samples, dropping the oldest audio if the backlog would exceed +// the cap (a slow/absent consumer never makes the producer block or grow without +// bound). A short glitch beats runaway latency for live monitoring. +func (r *pcmRing) Push(p []byte) { + if len(p) == 0 { + return + } + r.mu.Lock() + r.buf = append(r.buf, p...) + if len(r.buf) > r.max { + drop := len(r.buf) - r.max + r.buf = append(r.buf[:0], r.buf[drop:]...) + } + r.mu.Unlock() +} + +// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil +// when empty. The render loop pads any shortfall with silence. +func (r *pcmRing) pull(maxBytes int) []byte { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.buf) == 0 || maxBytes <= 0 { + return nil + } + n := maxBytes + if n > len(r.buf) { + n = len(r.buf) + } + out := make([]byte, n) + copy(out, r.buf[:n]) + r.buf = append(r.buf[:0], r.buf[n:]...) + return out +} diff --git a/internal/audio/wav.go b/internal/audio/wav.go index 02e7368..019d283 100644 --- a/internal/audio/wav.go +++ b/internal/audio/wav.go @@ -1,5 +1,3 @@ -//go:build windows - package audio import ( diff --git a/internal/cat/flex.go b/internal/cat/flex.go index c194cd8..dfba69a 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -1,5 +1,3 @@ -//go:build windows - package cat import ( diff --git a/internal/cat/flexdiscover.go b/internal/cat/flexdiscover.go index 6a58f07..762cb2e 100644 --- a/internal/cat/flexdiscover.go +++ b/internal/cat/flexdiscover.go @@ -1,5 +1,3 @@ -//go:build windows - package cat import ( @@ -9,8 +7,6 @@ import ( "strconv" "syscall" "time" - - "golang.org/x/sys/windows" ) // FlexRadio is one radio found by discovery. @@ -38,7 +34,7 @@ func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) { Control: func(_, _ string, c syscall.RawConn) error { var serr error _ = c.Control(func(fd uintptr) { - serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1) + serr = setSocketReuse(fd) }) return serr }, diff --git a/internal/cat/omnirig.go b/internal/cat/omnirig.go index cc58ff3..02ade56 100644 --- a/internal/cat/omnirig.go +++ b/internal/cat/omnirig.go @@ -1,3 +1,5 @@ +//go:build windows + package cat import ( diff --git a/internal/cat/omnirig_activate32.go b/internal/cat/omnirig_activate32.go index 5e66974..a02cf97 100644 --- a/internal/cat/omnirig_activate32.go +++ b/internal/cat/omnirig_activate32.go @@ -1,3 +1,5 @@ +//go:build windows + package cat import ( diff --git a/internal/cat/omnirig_elevation_test.go b/internal/cat/omnirig_elevation_test.go index 81ead6f..ab7c019 100644 --- a/internal/cat/omnirig_elevation_test.go +++ b/internal/cat/omnirig_elevation_test.go @@ -1,3 +1,5 @@ +//go:build windows + package cat import ( diff --git a/internal/cat/omnirig_other.go b/internal/cat/omnirig_other.go new file mode 100644 index 0000000..fa24328 --- /dev/null +++ b/internal/cat/omnirig_other.go @@ -0,0 +1,34 @@ +//go:build !windows + +package cat + +import "errors" + +// OmniRig is COM automation against a Windows-only application, so off Windows +// there is nothing to talk to. The backend still EXISTS here rather than being +// compiled out of app.go, because a settings database is portable: an operator +// who moves a profile from Windows to Linux keeps "omnirig" as their saved CAT +// backend, and OpsLog must start and say why the rig is silent instead of +// failing to build or panicking on a nil backend. +// +// The fix for those operators is a native backend (Icom, Yaesu, Kenwood/ +// Elecraft, Flex, TCI, Xiegu all speak to the radio directly) or Hamlib. +type OmniRig struct{ CWLower bool } + +var errOmniRigWindowsOnly = errors.New("OmniRig runs only on Windows — pick a native CAT backend (Icom, Yaesu, Kenwood/Elecraft, FlexRadio, TCI, Xiegu) in Settings ▸ CAT") + +func NewOmniRig(rigNum int, forceVFO string, cwLower bool) *OmniRig { + return &OmniRig{CWLower: cwLower} +} + +func (o *OmniRig) Name() string { return "omnirig" } +func (o *OmniRig) Connect() error { return errOmniRigWindowsOnly } +func (o *OmniRig) Disconnect() {} +func (o *OmniRig) ReadState() (RigState, error) { return RigState{}, errOmniRigWindowsOnly } +func (o *OmniRig) SetFrequency(hz int64) error { return errOmniRigWindowsOnly } +func (o *OmniRig) SetMode(mode string) error { return errOmniRigWindowsOnly } +func (o *OmniRig) SetPTT(on bool) error { return errOmniRigWindowsOnly } + +// SetCWLower satisfies OmniRigController so the preference push at startup is a +// no-op here rather than a "backend does not support this" error in the log. +func (o *OmniRig) SetCWLower(on bool) { o.CWLower = on } diff --git a/internal/cat/omnirig_vfo_test.go b/internal/cat/omnirig_vfo_test.go index 671d4a8..ce054eb 100644 --- a/internal/cat/omnirig_vfo_test.go +++ b/internal/cat/omnirig_vfo_test.go @@ -1,3 +1,5 @@ +//go:build windows + package cat import "testing" diff --git a/internal/cat/sockreuse_unix.go b/internal/cat/sockreuse_unix.go new file mode 100644 index 0000000..0f16616 --- /dev/null +++ b/internal/cat/sockreuse_unix.go @@ -0,0 +1,16 @@ +//go:build !windows + +package cat + +import "golang.org/x/sys/unix" + +// setSocketReuse is the Unix half of the Windows SO_REUSEADDR above. Linux +// wants SO_REUSEPORT as well before two processes may share a bound UDP port; +// it is not defined on every Unix, so a refusal there is ignored. +func setSocketReuse(fd uintptr) error { + if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil { + return err + } + _ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1) + return nil +} diff --git a/internal/cat/sockreuse_windows.go b/internal/cat/sockreuse_windows.go new file mode 100644 index 0000000..a6b69f9 --- /dev/null +++ b/internal/cat/sockreuse_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package cat + +import "golang.org/x/sys/windows" + +// setSocketReuse enables SO_REUSEADDR before bind, so discovery can listen on +// :4992 while SmartSDR is already listening for the same radio broadcast. +// Without it the second bind fails with WSAEADDRINUSE and the operator has to +// type the radio's IP by hand. +func setSocketReuse(fd uintptr) error { + return windows.SetsockoptInt(windows.Handle(fd), + windows.SOL_SOCKET, windows.SO_REUSEADDR, 1) +} diff --git a/internal/cat/tci.go b/internal/cat/tci.go index 09b6b9b..b6cad58 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -1,5 +1,3 @@ -//go:build windows - package cat import ( diff --git a/internal/cat/tci_audio.go b/internal/cat/tci_audio.go index 662117f..5490cf5 100644 --- a/internal/cat/tci_audio.go +++ b/internal/cat/tci_audio.go @@ -1,5 +1,3 @@ -//go:build windows - package cat // TCI audio — receiving the radio's audio over the same WebSocket that carries diff --git a/internal/cat/tci_manager.go b/internal/cat/tci_manager.go index 36273dc..d69807c 100644 --- a/internal/cat/tci_manager.go +++ b/internal/cat/tci_manager.go @@ -1,5 +1,3 @@ -//go:build windows - package cat import "fmt" diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index 06e2160..1a1d877 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -1,5 +1,3 @@ -//go:build windows - package cat // The TCI control panel: what the radio already tells us, gathered up. diff --git a/internal/cat/tci_ptt_test.go b/internal/cat/tci_ptt_test.go index b338d30..56b2718 100644 --- a/internal/cat/tci_ptt_test.go +++ b/internal/cat/tci_ptt_test.go @@ -1,5 +1,3 @@ -//go:build windows - package cat import "testing" diff --git a/internal/cat/tci_spot_test.go b/internal/cat/tci_spot_test.go index f716399..71b9c88 100644 --- a/internal/cat/tci_spot_test.go +++ b/internal/cat/tci_spot_test.go @@ -1,5 +1,3 @@ -//go:build windows - package cat import "testing" diff --git a/internal/extsvc/configured.go b/internal/extsvc/configured.go index b35c23a..2cdee08 100644 --- a/internal/extsvc/configured.go +++ b/internal/extsvc/configured.go @@ -69,7 +69,7 @@ func Configured(svc Service, cfg ExternalServices) error { return missing("Cloudlog / Wavelog", need...) } case ServiceLoTW: - add(set(cfg.LoTW.TQSLPath), "the path to tqsl.exe") + add(set(cfg.LoTW.TQSLPath), "the path to TQSL") add(set(cfg.LoTW.StationLocation), "the TQSL station location") if len(need) > 0 { return missing("LoTW", need...) diff --git a/internal/extsvc/lotw.go b/internal/extsvc/lotw.go index 5113f79..b7b1a24 100644 --- a/internal/extsvc/lotw.go +++ b/internal/extsvc/lotw.go @@ -297,29 +297,6 @@ func ListStationLocations(stationDataPath string) ([]StationLocation, error) { return out, nil } -// DefaultTQSLPath returns the usual tqsl.exe install path on Windows, or "" -// if not found. -func DefaultTQSLPath() string { - for _, p := range []string{ - `C:\Program Files (x86)\TrustedQSL\tqsl.exe`, - `C:\Program Files\TrustedQSL\tqsl.exe`, - } { - if fileExists(p) { - return p - } - } - return "" -} - -// DefaultStationDataPath returns TQSL's station_data location (%APPDATA%\ -// TrustedQSL\station_data on Windows), or "" if APPDATA isn't set. -func DefaultStationDataPath() string { - if appData := os.Getenv("APPDATA"); appData != "" { - return filepath.Join(appData, "TrustedQSL", "station_data") - } - return "" -} - func fileExists(p string) bool { info, err := os.Stat(p) return err == nil && !info.IsDir() @@ -375,7 +352,7 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri case tqsl == "": return UploadResult{}, fmt.Errorf("lotw: TQSL path not set") case !fileExists(tqsl): - return UploadResult{}, fmt.Errorf("lotw: tqsl.exe not found at %q", tqsl) + return UploadResult{}, fmt.Errorf("lotw: TQSL not found at %q", tqsl) case loc == "": return UploadResult{}, fmt.Errorf("lotw: station location not set") case strings.TrimSpace(adifRecord) == "": @@ -515,7 +492,7 @@ func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) { tqsl := strings.TrimSpace(cfg.TQSLPath) loc := strings.TrimSpace(cfg.StationLocation) if tqsl == "" || !fileExists(tqsl) { - return "", fmt.Errorf("lotw: tqsl.exe not found (set the TQSL path)") + return "", fmt.Errorf("lotw: TQSL not found (set the TQSL path)") } if loc == "" { return "", fmt.Errorf("lotw: pick a station location") diff --git a/internal/extsvc/tqslpath_unix.go b/internal/extsvc/tqslpath_unix.go new file mode 100644 index 0000000..6c5b67b --- /dev/null +++ b/internal/extsvc/tqslpath_unix.go @@ -0,0 +1,66 @@ +//go:build !windows + +package extsvc + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// DefaultTQSLPath finds TrustedQSL, or returns "" so the operator can point at +// it by hand. +// +// PATH is asked FIRST, unlike the Windows side where two fixed install folders +// are the whole story. On Linux tqsl comes from the distribution's package +// manager, a Flatpak or a self-built copy, and each puts it somewhere +// different; whichever one the operator installed is the one their shell finds. +// The fixed list below is only for a desktop session that started without a +// useful PATH. +func DefaultTQSLPath() string { + if p, err := exec.LookPath("tqsl"); err == nil && fileExists(p) { + return p + } + candidates := []string{ + "/usr/bin/tqsl", + "/usr/local/bin/tqsl", + "/var/lib/flatpak/exports/bin/org.arrl.tqsl", + filepath.Join(os.Getenv("HOME"), ".local/share/flatpak/exports/bin/org.arrl.tqsl"), + } + if runtime.GOOS == "darwin" { + candidates = append(candidates, "/Applications/TrustedQSL/tqsl.app/Contents/MacOS/tqsl") + } + for _, p := range candidates { + if fileExists(p) { + return p + } + } + return "" +} + +// DefaultStationDataPath returns TQSL's station_data location. +// +// ~/.tqsl is where the Unix build of TrustedQSL keeps its configuration. A +// Flatpak install redirects it into the sandbox +// (~/.var/app/org.arrl.tqsl/data/tqsl), so that is tried too — an operator on a +// Flatpak TQSL otherwise sees an empty station-location list with nothing to +// explain it. +func DefaultStationDataPath() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + for _, p := range []string{ + filepath.Join(home, ".tqsl", "station_data"), + filepath.Join(home, ".var", "app", "org.arrl.tqsl", "data", "tqsl", "station_data"), + } { + if fileExists(p) { + return p + } + } + // Nothing found: name the ordinary location anyway. The settings field then + // shows the path TQSL would create on its first run, which is a better + // starting point for the operator than an empty box. + return filepath.Join(home, ".tqsl", "station_data") +} diff --git a/internal/extsvc/tqslpath_windows.go b/internal/extsvc/tqslpath_windows.go new file mode 100644 index 0000000..f3e6940 --- /dev/null +++ b/internal/extsvc/tqslpath_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package extsvc + +import ( + "os" + "path/filepath" +) + +// DefaultTQSLPath returns the usual tqsl.exe install path, or "" if not found. +func DefaultTQSLPath() string { + for _, p := range []string{ + `C:\Program Files (x86)\TrustedQSL\tqsl.exe`, + `C:\Program Files\TrustedQSL\tqsl.exe`, + } { + if fileExists(p) { + return p + } + } + return "" +} + +// DefaultStationDataPath returns TQSL's station_data location +// (%APPDATA%\TrustedQSL\station_data), or "" if APPDATA isn't set. +func DefaultStationDataPath() string { + if appData := os.Getenv("APPDATA"); appData != "" { + return filepath.Join(appData, "TrustedQSL", "station_data") + } + return "" +} diff --git a/main.go b/main.go index 87b326b..56e3117 100644 --- a/main.go +++ b/main.go @@ -162,8 +162,7 @@ func main() { // OpsLog had was inside the folder it could not create. if err := checkDataDirWritable(); err != nil { bootLog("FATAL %v", err) - fatalBox("OpsLog", "OpsLog cannot write next to its own program file.\n\n"+err.Error()+ - "\n\nMove OpsLog.exe somewhere your account can write — a folder in Documents, or the desktop — and start it again. Program Files is refused to anything not running as administrator.") + fatalBox("OpsLog", "OpsLog cannot write next to its own program file.\n\n"+err.Error()+dataDirAdvice) return } if postUpdate { diff --git a/proc_linux.go b/proc_linux.go new file mode 100644 index 0000000..f99227d --- /dev/null +++ b/proc_linux.go @@ -0,0 +1,79 @@ +//go:build linux + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + + "hamlog/internal/applog" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// hideConsole has nothing to hide on Linux: a child started from a GUI process +// inherits no console, so no window can flash. +func hideConsole(cmd *exec.Cmd) {} + +// runningProcessNames returns the set of lowercase executable names currently +// running, read straight from /proc rather than by shelling out to `ps` — the +// output format of `ps` varies between distributions and busybox, /proc does +// not. +// +// Two names are recorded per process: the kernel's comm (truncated to 15 +// characters, which is why it cannot be the only one — "gridtracker-bin" and +// "wsjtx-improved" both hit the limit) and the basename of the real executable +// behind /proc//exe. Either may be what the operator configured. +func runningProcessNames() map[string]bool { + out := map[string]bool{} + entries, err := os.ReadDir("/proc") + if err != nil { + applog.Printf("autostart: cannot read /proc: %v", err) + return out + } + for _, e := range entries { + if !e.IsDir() { + continue + } + pid := e.Name() + if _, err := strconv.Atoi(pid); err != nil { + continue // not a process directory + } + if comm, err := os.ReadFile("/proc/" + pid + "/comm"); err == nil { + if n := strings.ToLower(strings.TrimSpace(string(comm))); n != "" { + out[n] = true + } + } + // The exe symlink is unreadable for processes owned by another user; + // that is expected and not worth a log line. + if exe, err := os.Readlink("/proc/" + pid + "/exe"); err == nil { + if n := strings.ToLower(filepath.Base(strings.TrimSuffix(exe, " (deleted)"))); n != "" { + out[n] = true + } + } + } + return out +} + +// closeProcess asks the process to close. SIGTERM is the Unix equivalent of the +// polite WM_CLOSE used on Windows: WSJT-X and friends get to save their state +// instead of being shot with SIGKILL. +func closeProcess(pid int) ([]byte, error) { + p, err := os.FindProcess(pid) + if err != nil { + return nil, err + } + return nil, p.Signal(syscall.SIGTERM) +} + +// executableFilters is what the "choose a program" dialog offers. Nothing to +// filter on here: a Linux program is a file with the executable bit, and its +// name carries no extension — wsjtx, gridtracker, flrig. An *.exe filter would +// show the operator an empty folder. +func executableFilters() []wruntime.FileFilter { + return []wruntime.FileFilter{{DisplayName: "All files", Pattern: "*"}} +} diff --git a/proc_windows.go b/proc_windows.go new file mode 100644 index 0000000..f68ad96 --- /dev/null +++ b/proc_windows.go @@ -0,0 +1,68 @@ +//go:build windows + +package main + +import ( + "os/exec" + "strconv" + "strings" + "syscall" + + "hamlog/internal/applog" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// hideConsole keeps a helper process from flashing a console window. OpsLog is +// a GUI application, and every `tasklist`/`taskkill`/`powershell` it runs would +// otherwise blink a black box in front of the operator mid-QSO. +func hideConsole(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW +} + +// runningProcessNames returns the set of lowercase executable names currently +// running, via the Windows `tasklist`. Best effort — on failure the set is +// empty (we then just attempt to launch, which is acceptable). +func runningProcessNames() map[string]bool { + out := map[string]bool{} + cmd := exec.Command("tasklist", "/FO", "CSV", "/NH") + hideConsole(cmd) + data, err := cmd.Output() + if err != nil { + applog.Printf("autostart: tasklist failed: %v", err) + return out + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // CSV row: "image.exe","PID",... — take the first quoted field. + field := line + if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") { + field = line[1 : i+1] + } + field = strings.Trim(field, "\"") + if field != "" { + out[strings.ToLower(field)] = true + } + } + return out +} + +// closeProcess asks the process to close. `taskkill` without /F sends WM_CLOSE, +// so WSJT-X and friends get to save their state instead of being shot. +func closeProcess(pid int) ([]byte, error) { + cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid)) + hideConsole(cmd) + return cmd.CombinedOutput() +} + +// executableFilters is what the "choose a program" dialog offers. Windows knows +// a program by its extension. +func executableFilters() []wruntime.FileFilter { + return []wruntime.FileFilter{ + {DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"}, + {DisplayName: "All files (*.*)", Pattern: "*.*"}, + } +} diff --git a/screenbounds_other.go b/screenbounds_other.go index 6f0e2ef..16fa0fd 100644 --- a/screenbounds_other.go +++ b/screenbounds_other.go @@ -12,3 +12,16 @@ func onSomeMonitorImpl(x, y, w, h int) bool { return true } func clampToVisible(x, y, w, h int) (int, int, bool) { return x, y, false } func logMonitorLayout() {} + +// monitorRects cannot be answered off Windows: Wails exposes no monitor +// geometry, and X11/Wayland disagree about whether a client may even ask. The +// empty list is the "cannot tell" the callers above already handle by trusting +// the operator's saved position. +func monitorRects() []screenRect { return nil } + +// describeMonitors still has to say something in the log — a window nobody can +// see is reported as "OpsLog did not start", and the log line is the first +// thing looked at. +func describeMonitors(rects []screenRect) string { + return "monitor layout unavailable on this platform" +} diff --git a/scripts/linux-setup.sh b/scripts/linux-setup.sh new file mode 100755 index 0000000..9864f48 --- /dev/null +++ b/scripts/linux-setup.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# linux-setup.sh — check what a Linux build of OpsLog needs, then build it. +# +# Run it from the repository root: ./scripts/linux-setup.sh +# +# It never installs anything itself. It prints the one command your distribution +# needs and stops, because a script that runs sudo on somebody else's machine is +# a script nobody should run. Once the dependencies are in, run it again and it +# builds. +set -u + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +grn() { printf '\033[32m%s\033[0m\n' "$*"; } +ylw() { printf '\033[33m%s\033[0m\n' "$*"; } +head_() { printf '\n\033[1m== %s\033[0m\n' "$*"; } + +missing=0 +note() { red " MISSING: $*"; missing=1; } +ok() { grn " ok: $*"; } + +head_ "Distribution" +if [ -r /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + echo " ${PRETTY_NAME:-unknown}" + family="${ID_LIKE:-$ID}" +else + echo " unknown (no /etc/os-release)" + family="" +fi + +head_ "Build dependencies" +have() { command -v "$1" >/dev/null 2>&1; } +pkg() { pkg-config --exists "$1" 2>/dev/null; } + +have gcc || have cc || note "a C compiler (Wails links against the system WebKit, so cgo is required here)" +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. +webkit_tags="" +if pkg webkit2gtk-4.1; then + ok "webkit2gtk-4.1" +elif pkg webkit2gtk-4.0; then + ok "webkit2gtk-4.0 (older — will build with -tags webkit2_40)" + webkit_tags="-tags webkit2_40" +else + note "webkit2gtk development headers (4.1 preferred, 4.0 accepted)" +fi + +# Node, and its VERSION — this is the trap on an LTS base. Ubuntu 22.04 (so +# Linux Mint 21) ships node 12, and Vite needs 18. The build fails deep inside +# the frontend with a syntax error that says nothing about the real cause, so +# catch it here where it can be named. +if have node; then + nodemaj=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0) + if [ "${nodemaj:-0}" -ge 18 ]; then + ok "node $(node -v)" + else + note "node 18 or newer (you have $(node -v) — the distribution package is too old for Vite)" + ylw " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install nodejs" + ylw " or use nvm: https://github.com/nvm-sh/nvm" + fi +else + note "node 18+" +fi +have npm || note "npm" + +head_ "Go" +if have go; then + gov=$(go version | awk '{print $3}') + ok "$gov" + # 1.25 is what go.mod asks for. No distribution ships it yet — Ubuntu 22.04 + # (Mint 21) has 1.18, Ubuntu 24.04 (Mint 22) has 1.22 — so `apt install + # 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 " echo 'export PATH=/usr/local/go/bin:\$HOME/go/bin:\$PATH' >> ~/.profile # then log out and back in" + fi +else + note "go 1.25+ — from https://go.dev/dl/, NOT from apt (no distribution ships 1.25 yet)" +fi + +head_ "Wails CLI" +WAILS="$(command -v wails || true)" +[ -z "$WAILS" ] && [ -x "$HOME/go/bin/wails" ] && WAILS="$HOME/go/bin/wails" +if [ -n "$WAILS" ]; then + ok "$($WAILS version 2>/dev/null | head -1)" +else + note "the wails CLI: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0" + ylw " (then make sure ~/go/bin is on your PATH)" +fi + +if [ "$missing" -ne 0 ]; then + head_ "Install these first" + case "$family" in + *debian*|*ubuntu*) + echo " sudo apt install build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev" + echo " (on Debian 12 and older: libwebkit2gtk-4.0-dev)" + echo + echo " Go and node are NOT in that line on purpose — the apt versions are too" + echo " old on every current Ubuntu/Mint base. See the notes above for both." ;; + *fedora*|*rhel*) + echo " sudo dnf install gcc-c++ pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel nodejs npm" ;; + *arch*) + echo " sudo pacman -S base-devel pkgconf gtk3 webkit2gtk-4.1 nodejs npm" ;; + *suse*) + echo " sudo zypper install -t pattern devel_basis && sudo zypper install pkg-config gtk3-devel webkit2gtk3-soup2-devel nodejs npm" ;; + *) + echo " a C compiler, pkg-config, GTK 3 and webkit2gtk development packages, node and npm" ;; + esac + echo + echo "Then run this script again." + exit 1 +fi + +head_ "Serial ports" +# The single most common reason a rig that works in WSJT-X refuses to open here. +if id -nG | tr ' ' '\n' | grep -qx 'dialout\|uucp'; then + ok "you are in the serial group" +else + ylw " You are NOT in the 'dialout' group (or 'uucp' on Arch/Fedora)." + ylw " CAT, keyers, rotators and amplifiers will fail with \"permission denied\"." + ylw " sudo usermod -aG dialout \$USER # then log out and back in" +fi + +head_ "Sound server" +if have pactl && pactl info >/dev/null 2>&1; then + ok "$(pactl info | sed -n 's/^Server Name: //p')" +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." +fi + +head_ "Building" +echo " wails build $webkit_tags" +# shellcheck disable=SC2086 +"$WAILS" build $webkit_tags || { red "Build failed."; exit 1; } + +head_ "Done" +grn " ./build/bin/OpsLog" +echo +echo " First run creates build/bin/data/ beside the binary — a fresh, empty" +echo " logbook. Do NOT copy config.json over from Windows: it holds a Windows" +echo " path. Point OpsLog at your real logbook from Settings ▸ Database." +echo +echo " If the window never opens, look at:" +echo " ~/.cache/OpsLog/startup.log (before the window)" +echo " build/bin/data/opslog.log (after it)" diff --git a/serialports_test.go b/serialports_test.go index f64010e..760413a 100644 --- a/serialports_test.go +++ b/serialports_test.go @@ -44,3 +44,13 @@ func TestTidySerialPortsIgnoresCase(t *testing.T) { t.Errorf("got %v, want [COM3]", got) } } + +// On Linux the ports are paths, and the same trap is waiting there: +// /dev/ttyUSB10 sorted lexically lands between USB1 and USB2. +func TestTidySerialPortsSortsUnixNamesNaturally(t *testing.T) { + got := tidySerialPorts([]string{"/dev/ttyUSB10", "/dev/ttyUSB2", "/dev/ttyACM0", "/dev/ttyUSB1"}) + want := []string{"/dev/ttyACM0", "/dev/ttyUSB1", "/dev/ttyUSB2", "/dev/ttyUSB10"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("got %v, want %v", got, want) + } +} diff --git a/singleinstance_linux.go b/singleinstance_linux.go new file mode 100644 index 0000000..4afe8a6 --- /dev/null +++ b/singleinstance_linux.go @@ -0,0 +1,99 @@ +//go:build linux && !bindings + +// NB the !bindings tag: Wails generates the TypeScript bindings by BUILDING AND +// RUNNING this binary. With the guard active, a normal OpsLog already running on +// the dev machine holds the lock, the generator's process exits instantly, and +// no bindings are produced. Excluding the guard from that build keeps generation +// working while shipping builds still get it. + +package main + +import ( + "os" + "path/filepath" + "strconv" + "syscall" + "time" + + "hamlog/internal/applog" +) + +// The Linux half of the single-instance guard. Windows uses a named mutex; here +// it is an advisory lock (flock) held on a file for as long as the process +// lives. +// +// A lock and not a pid file, because a pid file is wrong exactly when it +// matters: OpsLog killed by the OOM killer, or crashing on a bad rig response, +// leaves its pid behind and every later launch refuses to start. The kernel +// drops an flock when the process ends however it ends, so there is no stale +// state to clean up and no "delete this file to start again" for the operator +// to discover. +var instanceLock *os.File + +// instanceLockPath prefers XDG_RUNTIME_DIR (/run/user/1000) — per user, and +// emptied when the session ends, which is what a runtime lock wants. The cache +// directory is the fallback for the sessions that do not set it (a bare TTY, an +// ssh -X login). +func instanceLockPath() string { + dir := os.Getenv("XDG_RUNTIME_DIR") + if dir == "" { + dir = bootLogDir() + } + dir = filepath.Join(dir, "OpsLog") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "" + } + return filepath.Join(dir, "instance.lock") +} + +// acquireSingleInstance reports whether this process now owns the instance +// lock. Safe to call repeatedly: the retry loop in acquireInstance does, and a +// second flock on a second descriptor of the same file would conflict with the +// one we already hold. +func acquireSingleInstance() bool { + if instanceLock != nil { + return true + } + path := instanceLockPath() + if path == "" { + applog.Printf("single-instance: no writable folder for the lock — the guard is off for this run") + return true // fail open: refusing to start is worse than a possible duplicate + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + applog.Printf("single-instance: cannot open %s (%v) — the guard is off for this run", path, err) + return true + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + return false // another OpsLog holds it + } + // The pid is written for the operator's benefit, not ours — it is what a + // "which process is holding this?" question needs. The lock itself is the + // kernel's, and does not depend on the contents. + _ = f.Truncate(0) + _, _ = f.WriteString(strconv.Itoa(os.Getpid()) + "\n") + _ = f.Sync() + instanceLock = f // deliberately never closed: closing releases the lock + return true +} + +// waitForProcessExit waits for pid to disappear, up to timeout, and reports +// whether it did. Signal 0 asks the kernel "does this process exist?" without +// touching it. +// +// EPERM means it exists and belongs to somebody else — still running, as far as +// the caller is concerned. Only ESRCH is gone. +func waitForProcessExit(pid int, timeout time.Duration) bool { + if pid <= 0 { + return true + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if err := syscall.Kill(pid, 0); err == syscall.ESRCH { + return true + } + time.Sleep(100 * time.Millisecond) + } + return syscall.Kill(pid, 0) == syscall.ESRCH +} diff --git a/singleinstance_other.go b/singleinstance_other.go index 027866d..ef27fa1 100644 --- a/singleinstance_other.go +++ b/singleinstance_other.go @@ -1,4 +1,4 @@ -//go:build !windows || bindings +//go:build (!windows && !linux) || bindings package main diff --git a/update.go b/update.go index ce89e5e..7a4d338 100644 --- a/update.go +++ b/update.go @@ -11,7 +11,6 @@ import ( "path/filepath" "strconv" "strings" - "syscall" "time" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" @@ -209,8 +208,11 @@ func (a *App) DownloadAndApplyUpdate(url string) error { // 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") + 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") // THE NEW EXE STARTS ITSELF. No helper, no script. // @@ -235,7 +237,7 @@ func (a *App) DownloadAndApplyUpdate(url string) error { // 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 + hideConsole(cmd) if err := cmd.Start(); err != nil { return fmt.Errorf("schedule relaunch: %w", err) } @@ -250,52 +252,6 @@ func (a *App) DownloadAndApplyUpdate(url string) error { 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} diff --git a/updateswap_linux.go b/updateswap_linux.go new file mode 100644 index 0000000..646acc3 --- /dev/null +++ b/updateswap_linux.go @@ -0,0 +1,42 @@ +//go:build linux + +package main + +import ( + "fmt" + "os" + "path/filepath" + + "hamlog/internal/applog" +) + +// clearDownloadMark has nothing to clear on Linux: there is no +// mark-of-the-web, and no SmartScreen to refuse a programmatic launch. +func clearDownloadMark(path string) {} + +// makeExecutable restores the executable bit. A binary downloaded over HTTP +// arrives 0644 — on Windows the extension decides and this is a no-op, but here +// a freshly installed OpsLog that nothing can exec is a dead station. +func makeExecutable(path string) error { return os.Chmod(path, 0o755) } + +// scheduleDeferredSwap is the fallback for when the running binary could not be +// renamed out of the way — the path Windows needs a detached PowerShell helper +// for, because nothing there can move a file over a running image. +// +// On Linux it should never be reached. A rename only touches the directory +// entry, and the running process holds the inode, so replacing the binary of a +// live process is ordinary and the staging rename in DownloadAndApplyUpdate +// succeeds. If it did fail, the cause was the filesystem (read-only mount, no +// write permission on the directory, a full disk) and no helper would get past +// it either — so do the honest thing: try the move once more now, and say +// plainly what is wrong if it still refuses. +func (a *App) scheduleDeferredSwap(exe, pending string) error { + if err := os.Rename(pending, exe); err != nil { + return fmt.Errorf("install the new build: %w (the folder %s must be writable)", err, filepath.Dir(exe)) + } + if err := makeExecutable(exe); err != nil { + return fmt.Errorf("make the new build executable: %w", err) + } + applog.Printf("update: installed %s over %s after the staging rename failed", filepath.Base(pending), filepath.Base(exe)) + return nil +} diff --git a/updateswap_windows.go b/updateswap_windows.go new file mode 100644 index 0000000..03ac254 --- /dev/null +++ b/updateswap_windows.go @@ -0,0 +1,69 @@ +//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 } + +// 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 +}