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]>
223 lines
6.9 KiB
Go
223 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"hamlog/internal/applog"
|
|
|
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// keyAutostartPrograms holds the per-profile list of external programs OpsLog
|
|
// launches on startup (JSON array of AutostartProgram).
|
|
const keyAutostartPrograms = "autostart.programs"
|
|
|
|
// AutostartProgram is one external application OpsLog can launch when it starts
|
|
// — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile.
|
|
type AutostartProgram struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Args string `json:"args"`
|
|
Enabled bool `json:"enabled"`
|
|
// CloseOnExit asks for this program to be closed when OpsLog closes.
|
|
//
|
|
// Per program and not one switch for the list, because the answer differs
|
|
// inside one station: an operator wants WSJT-X gone with the logger and the
|
|
// rotator controller left running.
|
|
CloseOnExit bool `json:"close_on_exit,omitempty"`
|
|
}
|
|
|
|
// AutostartLaunchResult reports what happened for one program when launching.
|
|
type AutostartLaunchResult struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"` // launched | already_running | missing | disabled | error
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// GetAutostartPrograms returns the active profile's autostart list.
|
|
func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) {
|
|
out := []AutostartProgram{}
|
|
if a.settings == nil {
|
|
return out, nil
|
|
}
|
|
s, _ := a.settings.Get(a.ctx, keyAutostartPrograms)
|
|
if strings.TrimSpace(s) == "" {
|
|
return out, nil
|
|
}
|
|
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
|
return []AutostartProgram{}, nil
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SaveAutostartPrograms persists the autostart list for the active profile.
|
|
func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
|
|
if a.settings == nil {
|
|
return fmt.Errorf("db not initialized")
|
|
}
|
|
b, err := json.Marshal(progs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.settings.Set(a.ctx, keyAutostartPrograms, string(b))
|
|
}
|
|
|
|
// 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: executableFilters(),
|
|
})
|
|
}
|
|
|
|
// LaunchAutostartPrograms starts every enabled program that isn't already
|
|
// running, returning a per-program result. Used at startup (best effort) and by
|
|
// the "Launch now" button in settings.
|
|
func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult {
|
|
progs, _ := a.GetAutostartPrograms()
|
|
running := runningProcessNames()
|
|
out := make([]AutostartLaunchResult, 0, len(progs))
|
|
for _, p := range progs {
|
|
if !p.Enabled {
|
|
continue
|
|
}
|
|
out = append(out, launchProgram(p, running))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// LaunchAutostartProgram launches a single program by id on demand (the per-row
|
|
// "launch now" action), regardless of its enabled flag.
|
|
func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
|
|
progs, err := a.GetAutostartPrograms()
|
|
if err != nil {
|
|
return AutostartLaunchResult{}, err
|
|
}
|
|
for _, p := range progs {
|
|
if p.ID == id {
|
|
return launchProgram(p, runningProcessNames()), nil
|
|
}
|
|
}
|
|
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
|
|
}
|
|
|
|
// launched remembers the process id of every program OPSLOG started, keyed by
|
|
// program id.
|
|
//
|
|
// Only what we started is ever closed. A copy of WSJT-X the operator opened
|
|
// themselves — before OpsLog, for something else entirely — is theirs, and
|
|
// closing it because a logger happened to quit would be taking a decision that
|
|
// was never asked for. That is also why "already running" stores nothing.
|
|
var launched = struct {
|
|
sync.Mutex
|
|
pid map[string]int
|
|
}{pid: map[string]int{}}
|
|
|
|
// CloseAutostartPrograms closes the programs marked "close with OpsLog" — the
|
|
// ones OpsLog itself launched this session.
|
|
//
|
|
// A polite close, never a kill: taskkill without /F posts WM_CLOSE, so WSJT-X
|
|
// writes its settings and its log the way it would if the operator had clicked
|
|
// the cross. Forcing it would lose exactly the state an operator cares about,
|
|
// and a program that ignores a close request is entitled to.
|
|
func (a *App) CloseAutostartPrograms() {
|
|
progs, _ := a.GetAutostartPrograms()
|
|
for _, p := range progs {
|
|
if !p.CloseOnExit {
|
|
continue
|
|
}
|
|
launched.Lock()
|
|
pid, ok := launched.pid[p.ID]
|
|
delete(launched.pid, p.ID)
|
|
launched.Unlock()
|
|
if !ok || pid <= 0 {
|
|
continue // not started by us this session — not ours to close
|
|
}
|
|
name := strings.TrimSpace(p.Name)
|
|
if name == "" {
|
|
name = filepath.Base(p.Path)
|
|
}
|
|
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
|
|
}
|
|
applog.Printf("autostart: asked %s (pid %d) to close", name, pid)
|
|
}
|
|
}
|
|
|
|
// launchProgram starts one program unless its executable is already running.
|
|
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
|
|
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
|
|
path := strings.TrimSpace(p.Path)
|
|
if path == "" {
|
|
res.Status, res.Message = "error", "no path configured"
|
|
return res
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
res.Status, res.Message = "missing", "executable not found: "+path
|
|
return res
|
|
}
|
|
// Skip if a process with the same executable name is already running, so we
|
|
// never spawn a second copy of WSJT-X / JTAlert / etc.
|
|
base := strings.ToLower(filepath.Base(path))
|
|
if running[base] {
|
|
res.Status, res.Message = "already_running", base+" already running"
|
|
return res
|
|
}
|
|
cmd := exec.Command(path, splitArgs(p.Args)...)
|
|
cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD
|
|
if err := cmd.Start(); err != nil {
|
|
res.Status, res.Message = "error", err.Error()
|
|
return res
|
|
}
|
|
// Remembered so it can be closed again on exit, if asked. Recorded BEFORE
|
|
// the wait goroutine, which releases the handle.
|
|
if cmd.Process != nil {
|
|
launched.Lock()
|
|
launched.pid[p.ID] = cmd.Process.Pid
|
|
launched.Unlock()
|
|
}
|
|
// Don't wait on the child — it runs independently of OpsLog. Release the
|
|
// handle so we don't accumulate zombies.
|
|
go func() { _ = cmd.Wait() }()
|
|
res.Status, res.Message = "launched", "started "+base
|
|
return res
|
|
}
|
|
|
|
// splitArgs does a minimal shell-like split of an argument string, honouring
|
|
// double quotes so a path with spaces stays one argument.
|
|
func splitArgs(s string) []string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
var args []string
|
|
var cur strings.Builder
|
|
inQuote := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '"':
|
|
inQuote = !inQuote
|
|
case r == ' ' && !inQuote:
|
|
if cur.Len() > 0 {
|
|
args = append(args, cur.String())
|
|
cur.Reset()
|
|
}
|
|
default:
|
|
cur.WriteRune(r)
|
|
}
|
|
}
|
|
if cur.Len() > 0 {
|
|
args = append(args, cur.String())
|
|
}
|
|
return args
|
|
}
|