feat(linux): the Go half of OpsLog builds for Linux
Measured rather than guessed: the whole repository was cross-compiled for
linux/amd64 and the gaps closed one by one. There were fewer than expected.
Flex and TCI were never Windows-specific — they carried //go:build windows by
inheritance and import nothing but net and gorilla/websocket. Untagged, no code
change. The two backends a Linux operator is most likely to own were already
portable.
Audio was 560 lines, not 2287: only devices.go and engine.go touch WASAPI, while
manager.go, recorder.go, wav.go and mp3.go were pure Go wearing the tag by
association. The whole platform surface is seven functions, now implemented a
second time on PulseAudio through github.com/jfreymuth/pulse — pure Go over the
server socket, so the no-cgo rule survives, and PipeWire answers the same
protocol. The fixed 16 kHz mono format and the server-side resampling mirror
what AUTOCONVERTPCM does on Windows, for the same reason.
OmniRig is the only real loss, and its backend still EXISTS off Windows rather
than being compiled out of app.go: a settings database is portable, so an
operator moving a profile across keeps "omnirig" saved and must be told to pick
a native backend instead of meeting a nil one.
The parts where Linux is not Windows, and where a compile-only stub would have
been a silent bug:
- data dir: still beside the binary, but ~/.local/share/OpsLog/data when that
folder belongs to the system — decided by trying the write, because /opt and
/usr/local are writable on some stations and not others.
- single instance: an flock, not a pid file. The kernel drops it however the
process dies, so a crash leaves nothing to delete by hand. This is the guard
that stops two instances fighting over the rig frequency.
- update: simpler here. Unix renames over a running binary, so the deferred
swap the Windows path needs a detached helper for is unreachable.
- tasklist/taskkill become /proc and SIGTERM; the boot log moves out of /tmp,
which is wiped exactly when the evidence is wanted.
- serial ports sorted naturally: /dev/ttyUSB10 was landing between USB1 and
USB2, the same trap COM10 fell into.
release.ps1 now cross-builds and vets for linux before it builds the exe, and
refuses the release if that fails — a port rots one unguarded x/sys/windows call
at a time.
Nothing has been executed on Linux yet: Wails needs webkit2gtk and cgo there, so
the binary must be built on Linux. scripts/linux-setup.sh checks the machine and
does it; BUILDING-LINUX.md is the manual version.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -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/[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.
|
||||
|
||||
## 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. |
|
||||
@@ -2035,15 +2035,24 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
applog.Printf("shutdown: teardown done")
|
||||
}
|
||||
|
||||
// userDataDir returns the OpsLog data directory: always "<exe dir>/data".
|
||||
// userDataDir returns the OpsLog data directory: "<exe dir>/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") {
|
||||
|
||||
+3
-40
@@ -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
|
||||
}
|
||||
|
||||
+18
-9
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import (
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import (
|
||||
|
||||
@@ -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 }
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
// TCI audio — receiving the radio's audio over the same WebSocket that carries
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import "fmt"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
// The TCI control panel: what the radio already tells us, gathered up.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -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...)
|
||||
|
||||
+2
-25
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/<pid>/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: "*"}}
|
||||
}
|
||||
@@ -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: "*.*"},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Executable
+154
@@ -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/[email protected]"
|
||||
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)"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows || bindings
|
||||
//go:build (!windows && !linux) || bindings
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user