Compare commits

..
6 Commits
Author SHA1 Message Date
rouggy ee62930f75 fix(keyer): one list decides whether the keyer is ready
The operator was right that the main keyer widget was the problem, and
wrong about how: it never asked for a COM port on TCI — the panel header
already handles source === 'tci' and shows the rig's link instead of a
port selector. What it did was report the keyer DISCONNECTED, and every
control in that panel is disabled on !connected: the speed arrows, the
send box, the macros, Stop. A keyer that greys out is a keyer that does
not work.

The cause is a duplicated list. App.tsx computed "is the CAT keyer up"
in two places — once for wkActiveRef, once inline in the panel's
synthesised status — and the copies drifted. The panel's was missing
Yaesu (its own comment says so, from the last time) and then TCI, so for
those engines it fell through to the WinKeyer SERIAL status, which is
permanently disconnected when no WinKeyer is attached.

Now derived once, from one table at module level, with both spellings
each backend answers to — icom/icom-net and kenwood/elecraft, the second
of which the inline copy also lacked.

The Station Control card DID say "no port" and offer a Connect that
could only fail; that went with GetKeyerStatus in the previous commit.
And the dropdown no longer says "coming soon", which on its own was
enough to stop anybody choosing it.
2026-09-11 11:33:37 +02:00
rouggy ef5a8f70ff fix(keyer): the CAT-keying engines are not a WinKeyer without a port
Reported as "the TCI keyer is unusable, and it asks for a COM port TCI
does not need". The keying itself was never the problem: TCISendCW,
TCIStopCW and TCISetKeySpeed have been there since the backend landed,
and the F-keys and macros dispatch by engine. Three other things said
otherwise.

The dropdown said "TCI (coming soon)", which on its own is enough to
stop anybody selecting it.

The Station Control keyer card read GetWinkeyerStatus — the serial
manager alone. On the five engines that key over the CAT link that is
already open, that status is permanently disconnected with no port, so
the card showed a red light, "no port", and a Connect button whose only
possible outcome was a serial error.

And the card was HIDDEN on those engines: it appeared only when the
keyer was connected or a port was configured, neither of which is ever
true for CAT keying. An operator on TCI never saw it at all.

So GetKeyerStatus: the WinKeyer status plus which engine is keying and
whether it rides the CAT link, with Connected taken from the CAT backend
in that case. Decided in Go rather than in each panel, because "is the
keyer up" has one answer and two places were entitled to disagree about
it — App.tsx already computed its own.
2026-09-11 11:23:49 +02:00
rouggy bb596c8aa9 fix(linux): a refusal to start is shown, not written to nobody
fatalBox off Windows was a single println. That goes to stderr, and a
binary started from a file manager or a .desktop launcher has no stderr
anyone will read — so a carefully worded refusal ("the folder OpsLog
keeps everything in cannot be written to…") reached the operator as "I
click it and nothing happens", which is the least useful thing a program
can say.

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

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

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

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

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

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

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

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

And the sound-server check was asking the wrong question. It ran `pactl
info`, but pactl comes from pulseaudio-utils, a package that has nothing
to do with whether a server is running — so a Debian 13 desktop with
PipeWire working perfectly was told it had no sound server. OpsLog
speaks the PulseAudio protocol itself and connects to the native socket,
so the socket is what the check looks for now, with pactl used only to
print the server's name when it happens to be there. It also says to run
from the desktop session rather than over SSH, the socket belonging to
the logged-in session.
2026-09-11 09:46:01 +02:00
rouggy f8d47cd3b5 fix(linux): the update relaunch is per-platform again
Restoring the PowerShell helper put it in update.go, which is shared —
so a Linux build would have tried to run `powershell` to relaunch
itself. It compiled and vetted cleanly for linux/amd64, which is exactly
why it needed catching before somebody built it: the fault only shows on
a real update, on a machine that has no PowerShell.

scheduleRelaunch now lives in the platform files. Windows keeps the
helper. Linux starts the new binary directly, which is right there and
not a compromise: nothing holds an executable open while it runs, so the
swap has already succeeded, and there is no mutex to race — the
single-instance guard is an flock the dying process releases as it
exits, and the new one waits for our pid first.

The two guards were looking at the old location and had to follow: the
Wait-Process/Start-Process check moves into relaunch_windows_test.go
where it belongs, and TestEveryRelaunchPassesItsPid now scans
updateswap_linux.go too — the direct spawn moved there, and without it
the test would have gone quiet again.

Checked from Windows, as BUILDING-LINUX.md says is done at every
release: GOOS=linux go build ./... and go vet ./... both clean.
2026-09-11 09:28:55 +02:00
18 changed files with 488 additions and 183 deletions
+31 -8
View File
@@ -38,15 +38,28 @@ sudo pacman -S base-devel pkgconf gtk3 webkit2gtk-4.1 nodejs npm
``` ```
**Go and node do not come from the package manager.** No current distribution **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 ships a Go new enough for `go.mod` (Debian 12 has 1.19, Debian 13 has 1.24,
22.04 / Mint 21 have 1.18), and Ubuntu 22.04 / Mint 21 ship node 12 where Vite Ubuntu 24.04 / Mint 22 have 1.22), and Ubuntu 22.04 / Mint 21 ship node 12 where
needs 18. Both are the usual reason a first build fails with an error that Vite needs 18. Both are the usual reason a first build fails with an error that
points somewhere else entirely: points somewhere else entirely.
**Take 1.26, not the latest.** Go can also be too NEW. The Wails CLI parses this
package with the `golang.org/x/tools` its own `go.mod` pins, and that release
cannot read the export data a newer compiler writes — so a fresh `go1.27` fails
the build with something that names neither Go nor Wails:
```
internal error: package "math" without types was imported from "hamlog/internal/geo"
```
Met on Debian 13, where go.dev simply offers 1.27 as the current release. 1.25
and 1.26 are what this repository is built with. If you already installed a
newer one, replace it and reinstall the CLI so it is rebuilt:
```bash ```bash
# Go, from go.dev # Go, from go.dev
wget https://go.dev/dl/go1.25.1.linux-amd64.tar.gz wget https://go.dev/dl/go1.26.3.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.3.linux-amd64.tar.gz
echo 'export PATH=/usr/local/go/bin:$HOME/go/bin:$PATH' >> ~/.profile # log out and back in 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 # node 20, only if `node -v` is below 18
@@ -60,8 +73,18 @@ go install github.com/wailsapp/wails/v2/cmd/[email protected]
wails doctor # says what is still missing wails doctor # says what is still missing
``` ```
`libwebkit2gtk-4.0` also works; pass `-tags webkit2_40` to `wails build` if your **The webkit version decides a build tag.** Wails v2.11 defaults to
distribution only has the older one. webkit2gtk-**4.0**; to build against **4.1** — which is all Debian 13 ships —
it has to be told:
```bash
wails build -tags webkit2_41 # webkit 4.1 (Debian 13, Fedora, Arch)
wails build # webkit 4.0 (Debian 12 and older)
```
Without the tag on a 4.1-only machine the frontend builds, npm runs, and then
cgo stops with `Package webkit2gtk-4.0 was not found in the pkg-config search
path`. `linux-setup.sh` picks the tag for you.
## Build ## Build
+58
View File
@@ -20721,6 +20721,64 @@ func (a *App) WinkeyerSetSpeed(wpm int) error {
} }
// GetWinkeyerStatus returns the current link status (used on mount). // GetWinkeyerStatus returns the current link status (used on mount).
// KeyerStatus is the keyer's state plus WHICH keyer it is.
//
// Five of the six engines key over the CAT link the rig is already on: there
// is no serial port to open and no Connect to offer. The Station Control card
// knew none of that — it read the WinKeyer manager alone, so a station keying
// through TCI saw a red light, "no port", and a Connect button that could only
// fail. Reported as the TCI keyer being unusable, when in fact only this card
// was wrong: the F-keys and the macros have been dispatching by engine all
// along.
//
// Decided HERE and not in each panel, because "is the keyer up" has one
// answer and two places were entitled to disagree about it.
type KeyerStatus struct {
winkeyer.Status
Engine string `json:"engine"`
// OverCAT: the keying rides the CAT link. No port, no Connect, and
// Connected means the CAT backend that does the keying is up.
OverCAT bool `json:"over_cat"`
}
// keyerCATBackends maps a CAT-keying engine to the backend that has to be
// running for it. Icom has two — the USB port and the rig's own LAN server —
// and both key the same way.
var keyerCATBackends = map[string][]string{
"icom": {"icom", "icom-net"},
"flex": {"flex"},
"yaesu": {"yaesu"},
"kenwood": {"kenwood", "elecraft"},
"tci": {"tci"},
}
func (a *App) GetKeyerStatus() KeyerStatus {
engine := strings.TrimSpace(a.settingOr(keyWKEngine, "winkeyer"))
out := KeyerStatus{Status: a.GetWinkeyerStatus(), Engine: engine}
want, overCAT := keyerCATBackends[engine]
if !overCAT {
return out // winkeyer or serial: the manager's own status is the truth
}
out.OverCAT = true
// The manager is not connected and never will be on this engine, so its
// fields would all read "off". The rig's link is what matters.
out.Port, out.Error = "", ""
out.Connected = false
if a.cat != nil {
st := a.cat.State()
for _, b := range want {
if st.Connected && st.Backend == b {
out.Connected = true
break
}
}
if !out.Connected {
out.Error = fmt.Sprintf("the %s keyer needs its CAT backend connected", engine)
}
}
return out
}
func (a *App) GetWinkeyerStatus() winkeyer.Status { func (a *App) GetWinkeyerStatus() winkeyer.Status {
if a.winkeyer == nil { if a.winkeyer == nil {
return winkeyer.Status{} return winkeyer.Status{}
+97 -60
View File
@@ -372,30 +372,20 @@ func (a *App) satTrackStep(t *satTracker) {
} }
} }
// Where did the operator leave the TRANSMITTER? The same question as above, // The uplink is NOT read back from the radio.
// and the same answer: what they landed on is what they want, so the
// difference becomes a standing correction rather than being overwritten.
// //
// Absorbed as a trim on the NOMINAL uplink, not on the corrected one: a // It was, for one release, and on an IC-9700 that was the wrong thing to do:
// transponder's translation error is a fixed offset in the uplink band, not // its CI-V requires making a band ACTIVE before a frequency can be written
// something that scales with the Doppler. (The difference either way is // to it, so the tracker already selected MAIN, wrote, selected SUB, wrote,
// under a hundredth of a hertz, but only one of the two is a reason.) // and came back. Adding a read of SUB made the radio switch bands twice a
// second, and an operator reported the consequence exactly: you never know
// which VFO the one knob is about to move, and a correction made on the
// receiver comes back to the centre frequency on the next update.
// //
// Not while transmitting: mid-over the operator is not turning the knob, and // So the correction comes from OpsLog instead — NudgeSatelliteUplink and
// on an Icom this read switches bands to reach the uplink — not something to // NudgeSatelliteDownlink — which is what the operator asked for and what a
// do under a carrier. // rig taking a command a second can actually live with. Nothing is read; the
if lastUp > 0 && !a.satTransmitting() { // tracker owns both frequencies and the operator moves them from the panel.
if actual, err := a.satTransmitHz(); err == nil && actual > 0 {
if drift := actual - lastUp; abs64i(drift) > satDialTolerance {
t.mu.Lock()
t.upTrim += drift
trim := t.upTrim
t.mu.Unlock()
applog.Printf("sat: uplink trimmed by %+d Hz (now %+d Hz) — the transmit VFO moved", drift, trim)
a.saveSatUplinkTrim(b.Name, t.tp, trim)
}
}
}
t.mu.Lock() t.mu.Lock()
upTrim := t.upTrim upTrim := t.upTrim
t.mu.Unlock() t.mu.Unlock()
@@ -614,44 +604,6 @@ func (a *App) satReceiveHz() (int64, error) {
return st.FreqHz, nil return st.FreqHz, nil
} }
// satTransmitHz is where the transmitter actually is, or 0 when the radio
// cannot say. Only the satellite backends can: a rig working split reports one
// frequency and it is the receiver's.
func (a *App) satTransmitHz() (int64, error) {
if a.cat == nil {
return 0, fmt.Errorf("CAT is not running")
}
if !a.cat.SatCapable() {
return 0, nil
}
var hz int64
err := a.cat.SatDo(func(st cat.SatTuner) error {
v, e := st.SatTransmitHz()
hz = v
return e
})
return hz, err
}
// satTransmitting reports whether the rig is keyed, so the uplink readback can
// stay off the air while it is.
//
// Only the two backends that hold a satellite pair are asked, which are the
// only two this matters for. Unknown counts as NOT transmitting: refusing to
// read the uplink on a radio that cannot say would disable the trim entirely.
func (a *App) satTransmitting() bool {
if a.cat == nil {
return false
}
if st, ok := a.cat.FlexState(); ok {
return st.Transmitting
}
if st, ok := a.cat.IcomState(); ok {
return st.Transmitting
}
return false
}
// ── The uplink trim, remembered ───────────────────────────────────────────── // ── The uplink trim, remembered ─────────────────────────────────────────────
// //
// Kept per satellite AND per transponder, because that is what it belongs to: // Kept per satellite AND per transponder, because that is what it belongs to:
@@ -690,6 +642,91 @@ func (a *App) saveSatUplinkTrim(name string, tp int, hz int64) {
} }
} }
// NudgeSatelliteDownlink moves where the operator is listening, in hertz.
//
// This is the same quantity a dial movement produces on a radio whose VFO can
// be read — the NOMINAL downlink, the frequency expressed as if the satellite
// were standing still — so the uplink follows it correctly, inverted where the
// transponder inverts. It therefore serves both things an operator does with
// it: moving across a linear passband to another station, and correcting a
// Doppler prediction that is never exact in any software.
//
// Clamped to the passband: a nudge that would leave the transponder is
// refused rather than silently taking the station off the satellite.
func (a *App) NudgeSatelliteDownlink(hz int64) error {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return fmt.Errorf("not tracking")
}
_, birds, _ := a.satParts()
t.mu.Lock()
name, idx, cur := t.name, t.tp, t.nominalDown
t.mu.Unlock()
b, ok := birds.Find(name)
if !ok || idx < 0 || idx >= len(b.Transponders) {
return fmt.Errorf("%s has no frequency plan", name)
}
tp := b.Transponders[idx]
next := cur + hz
// A single-channel transponder — every FM bird — has no passband to move
// within, and DownHi equals DownLo. Allowing a nudge there would walk the
// station off the channel one click at a time.
if tp.DownHi <= tp.DownLo {
return fmt.Errorf("%s is a single channel — there is nothing to tune across", tp.Label)
}
if next < tp.DownLo || next > tp.DownHi {
return fmt.Errorf("that is outside the passband (%.4f%.4f MHz)",
float64(tp.DownLo)/1e6, float64(tp.DownHi)/1e6)
}
t.mu.Lock()
t.nominalDown = next
t.mu.Unlock()
applog.Printf("sat: downlink moved %+d Hz to %.4f MHz nominal", hz, float64(next)/1e6)
a.wakeSatTracker(t)
return nil
}
// NudgeSatelliteUplink corrects the transmit frequency, in hertz, and keeps
// the correction for this transponder.
//
// A transponder does not translate by exactly the published difference — the
// oscillator on board is decades old on some birds — so an operator who sounds
// right to themselves comes back off frequency. The trim is remembered per
// satellite and transponder, because that error is a property of the hardware
// in orbit and does not change between passes.
func (a *App) NudgeSatelliteUplink(hz int64) error {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return fmt.Errorf("not tracking")
}
t.mu.Lock()
name, idx := t.name, t.tp
next := t.upTrim + hz
if next < -satUpTrimLimit || next > satUpTrimLimit {
t.mu.Unlock()
return fmt.Errorf("the uplink correction stops at %d kHz", satUpTrimLimit/1000)
}
t.upTrim = next
t.mu.Unlock()
a.saveSatUplinkTrim(name, idx, next)
applog.Printf("sat: uplink correction %+d Hz (now %+d Hz)", hz, next)
a.wakeSatTracker(t)
return nil
}
// wakeSatTracker asks for a step now rather than at the next tick, so a nudge
// is heard when the button is pressed instead of up to a second later.
func (a *App) wakeSatTracker(t *satTracker) {
select {
case t.wake <- struct{}{}:
default: // a step is already pending; it will pick this up
}
}
// GetSatUplinkTrim is what the panel shows. // GetSatUplinkTrim is what the panel shows.
func (a *App) GetSatUplinkTrim(name string, transponder int) int64 { func (a *App) GetSatUplinkTrim(name string, transponder int) int64 {
return a.loadSatUplinkTrim(name, transponder) return a.loadSatUplinkTrim(name, transponder)
+9
View File
@@ -1,4 +1,13 @@
[ [
{
"version": "0.27.26",
"en": [
"The TCI keyer works: it was reported offline, which greyed out the speed, the send box, the macros and Stop, and it was labelled \"coming soon\". The keying itself was always there. The Station Control card also asked for a COM port a radio keying over its own link does not need."
],
"fr": [
"Le keyer TCI fonctionne : il se déclarait hors ligne, ce qui grisait la vitesse, la zone denvoi, les macros et Stop, et il portait la mention « bientôt ». La manipulation elle-même était là depuis le début. La carte de Station Control réclamait aussi un port COM dont une radio qui manipule par sa propre liaison na pas besoin."
]
},
{ {
"version": "0.27.25", "version": "0.27.25",
"en": [ "en": [
+35 -2
View File
@@ -2,5 +2,38 @@
package main package main
// fatalBox is Windows-only; elsewhere the terminal carries the message. import (
func fatalBox(title, text string) { println(title + ": " + text) } "os"
"os/exec"
)
// fatalBox says why OpsLog is not starting, as visibly as the desktop allows.
//
// It used to be one println. That goes to stderr, and a binary started from a
// file manager or a .desktop launcher has no stderr anybody will ever read — so
// the refusal that was carefully worded arrived as "I click it and nothing
// happens", which is the least useful thing a program can say.
//
// So: a real dialog when the desktop has one of the two tools that every
// distribution ships with its desktop task, and stderr regardless. Neither is
// required — the startup log has the same text, and nothing here is allowed to
// stop OpsLog from exiting.
func fatalBox(title, text string) {
os.Stderr.WriteString(title + ": " + text + "\n")
for _, c := range [][]string{
{"zenity", "--error", "--no-wrap", "--title", title, "--text", text},
{"kdialog", "--title", title, "--error", text},
// Neither is a hard requirement, and on a headless box there is nothing
// to show a dialog on anyway.
} {
if _, err := exec.LookPath(c[0]); err != nil {
continue
}
cmd := exec.Command(c[0], c[1:]...)
if err := cmd.Start(); err != nil {
continue
}
_ = cmd.Wait()
return
}
}
+37 -17
View File
@@ -579,6 +579,24 @@ function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle
); );
} }
// Which CAT backend has to be up for each keying engine.
//
// Icom keys the same way over its USB port and over the rig's own LAN server,
// so both count; Kenwood's backend answers to either name.
const CAT_KEYER_BACKENDS: Record<string, string[]> = {
icom: ['icom', 'icom-net'],
flex: ['flex'],
yaesu: ['yaesu'],
kenwood: ['kenwood', 'elecraft'],
tci: ['tci'],
};
// What to show where a WinKeyer would name its COM port. Not a port: the
// point is that there is no port.
const CAT_KEYER_LINK: Record<string, string> = {
icom: 'CI-V', flex: 'CWX', yaesu: 'CAT', kenwood: 'CAT', tci: 'TCI',
};
export default function App() { export default function App() {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
// === Lists from settings (fallback for first paint) === // === Lists from settings (fallback for first paint) ===
@@ -1633,15 +1651,21 @@ export default function App() {
// segment and before every <LOGQSO> log, so aborting mid-macro stops sending AND // segment and before every <LOGQSO> log, so aborting mid-macro stops sending AND
// skips the log that hasn't happened yet. // skips the log that hasn't happened yet.
const wkSendGenRef = useRef(0); const wkSendGenRef = useRef(0);
// Is the keyer that is ACTUALLY sending ready, and what is it called?
//
// Derived ONCE. This list existed twice — here and inside the panel's own
// synthesised status — and the copies drifted: the panel's was missing
// Yaesu, then TCI. Each time, it fell through to the WinKeyer SERIAL status,
// which on a radio that keys over its own link reports disconnected with no
// port — so the panel asked the operator to choose a COM port for a keyer
// that needs none, and the engine looked unimplemented. Reported for TCI.
const keyerOverCAT = cwSource !== 'winkeyer';
const keyerReady = keyerOverCAT
? (catState.connected && (CAT_KEYER_BACKENDS[cwSource] ?? []).includes(catState.backend || ''))
: wkStatus.connected;
useEffect(() => { useEffect(() => {
const connected = cwSource === 'icom' ? ((catState.backend === 'icom' || catState.backend === 'icom-net') && catState.connected) wkActiveRef.current = wkEnabled && keyerReady;
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected) }, [wkEnabled, keyerReady]);
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
: cwSource === 'kenwood' ? (catState.backend === 'kenwood' && catState.connected)
: cwSource === 'tci' ? (catState.backend === 'tci' && catState.connected)
: wkStatus.connected;
wkActiveRef.current = wkEnabled && connected;
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]); useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
// === Digital Voice Keyer (DVK) === // === Digital Voice Keyer (DVK) ===
@@ -7994,15 +8018,11 @@ export default function App() {
<div className="w-[380px] shrink-0 min-h-0" style={{ order: wOrder('winkeyer') }}> <div className="w-[380px] shrink-0 min-h-0" style={{ order: wOrder('winkeyer') }}>
<WinkeyerPanel <WinkeyerPanel
// A rig keyer has no serial status of its own: it is connected // A rig keyer has no serial status of its own: it is connected
// exactly when its CAT backend is. Yaesu was missing from this // exactly when its CAT backend is, and keyerReady above is the
// list, so the panel fell back to the WinKeyer status — which // one place that decides so — the copy that used to live here
// reported disconnected, no WinKeyer being attached. // kept forgetting an engine.
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu' || cwSource === 'kenwood' status={keyerOverCAT
? { ? { connected: keyerReady, busy: false, wpm: wkWpm, version: 0, port: CAT_KEYER_LINK[cwSource] ?? 'CAT' }
connected: catState.backend === cwSource && catState.connected,
busy: false, wpm: wkWpm, version: 0,
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' || cwSource === 'kenwood' ? 'CAT' : 'CI-V',
}
: wkStatus} : wkStatus}
ports={wkPorts} ports={wkPorts}
port={wkPort} port={wkPort}
@@ -26,7 +26,7 @@ import {
GetTunerGeniusStatus, GetTunerGeniusSettings, GetTunerGeniusStatus, GetTunerGeniusSettings,
GetPSUStatus, GetPSUSettings, SetPSUOutput, GetPSUStatus, GetPSUSettings, SetPSUOutput,
GetCATState, GetCATState,
GetWinkeyerStatus, WinkeyerSetSpeed, WinkeyerStop, WinkeyerConnect, GetKeyerStatus, WinkeyerSetSpeed, WinkeyerStop, WinkeyerConnect,
GetDVKStatus, GetDVKMessages, DVKPlay, DVKStop, GetDVKStatus, GetDVKMessages, DVKPlay, DVKStop,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
@@ -155,7 +155,7 @@ function KeyerCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>(null); const [st, setSt] = useState<any>(null);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const tick = () => GetWinkeyerStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {}); const tick = () => GetKeyerStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
tick(); tick();
const h = window.setInterval(tick, 1000); const h = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(h); }; return () => { alive = false; window.clearInterval(h); };
@@ -193,11 +193,18 @@ function KeyerCard({ t }: { t: (k: string, v?: any) => string }) {
<Square className="size-3 mr-1" />{t('station.stop')} <Square className="size-3 mr-1" />{t('station.stop')}
</Button> </Button>
</div> </div>
{/* Five of the six engines key over the CAT link the rig is already
on. There is no port to name and no Connect to offer, and
offering one anyway is what made the TCI keyer look unusable:
a red light, "no port", and a button that could only fail. */}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"> <div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{st?.port || t('station.noPort')}</span> <span className="truncate">
{st?.over_cat ? t('station.keyerViaCat', { engine: (st.engine || '').toUpperCase() })
: (st?.port || t('station.noPort'))}
</span>
{!!st?.version && <span className="ml-auto shrink-0">v{st.version}</span>} {!!st?.version && <span className="ml-auto shrink-0">v{st.version}</span>}
</div> </div>
{!on && ( {!on && !st?.over_cat && (
<Button variant="outline" size="sm" className="w-full h-7" <Button variant="outline" size="sm" className="w-full h-7"
onClick={() => WinkeyerConnect().catch(() => {})}> onClick={() => WinkeyerConnect().catch(() => {})}>
{t('station.connect')} {t('station.connect')}
@@ -506,8 +513,12 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
const [dvkShown, setDvkShown] = useState(false); const [dvkShown, setDvkShown] = useState(false);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
GetWinkeyerStatus().then((s: any) => { // over_cat counts as "there is a keyer here": on those engines there is no
if (alive) setKeyerShown(!!s && (!!s.connected || !!String(s.port ?? '').trim())); // port to configure and the link may be down at the moment the dashboard
// is drawn, so the old test — connected, or a port set — hid the card
// permanently for anyone keying through TCI or a rig's own keyer.
GetKeyerStatus().then((s: any) => {
if (alive) setKeyerShown(!!s && (!!s.over_cat || !!s.connected || !!String(s.port ?? '').trim()));
}).catch(() => {}); }).catch(() => {});
GetDVKMessages().then((m: any[]) => { GetDVKMessages().then((m: any[]) => {
if (alive) setDvkShown((m ?? []).some((x) => x?.has_audio)); if (alive) setDvkShown((m ?? []).some((x) => x?.has_audio));
+4 -4
View File
@@ -268,7 +268,7 @@ const en: Dict = {
'station.valueNeedsLabels': 'A URL above uses {value}, which sends the relays label — name every relay you switch that way, or its URL goes out with an empty value.', 'station.valueNeedsLabels': 'A URL above uses {value}, which sends the relays label — name every relay you switch that way, or its URL goes out with an empty value.',
'station.rig': 'Radio', 'station.keyer': 'CW keyer', 'station.voiceKeyer': 'Voice keyer', 'station.rig': 'Radio', 'station.keyer': 'CW keyer', 'station.voiceKeyer': 'Voice keyer',
'station.rigDown': 'CAT is on but the radio is not answering', 'station.rigDown': 'CAT is on but the radio is not answering',
'station.rigOff': 'CAT is switched off', 'station.noPort': 'no port configured', 'station.rigOff': 'CAT is switched off', 'station.noPort': 'no port configured', 'station.keyerViaCat': 'via {engine} — no port needed',
'station.connect': 'Connect', 'station.noVoiceMsg': 'No message recorded — Settings ▸ Audio.', 'station.connect': 'Connect', 'station.noVoiceMsg': 'No message recorded — Settings ▸ Audio.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotateTo': 'Rotate to {az}°', 'station.rotatorNoRead': 'No heading read', 'station.bands': 'Bands', 'station.nudgeUp': 'Up {n} kHz', 'station.nudgeDown': 'Down {n} kHz', 'station.trackOn': 'Tracking on', 'station.trackOff': 'Tracking off', 'station.trackStepTip': 'Re-tune only when the rig has moved this far', 'station.trackModeTip': 'When the antenna is allowed to re-tune', 'station.trackAlways': 'Every frequency change', 'station.trackStep': 'Past a step', 'station.trackBand': 'Band change only', 'station.trackAlwaysTip': 'Follow every frequency change. Best resonance, but the motors run constantly — and on a SteppIR every move blocks transmit while the elements travel.', 'station.trackStepTipMode': 'Re-tune only once the rig has moved further than the step. Follows a QSY, ignores tuning around.', 'station.trackBandTip': 'Re-tune only when the band changes. The motors move a few times a day and are left alone within a band.', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Motorised antenna · click to show', 'station.motorWidgetHide': 'Motorised antenna — shown · click to hide', 'station.hideWidget': 'Hide this widget', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the boards HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.', 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotateTo': 'Rotate to {az}°', 'station.rotatorNoRead': 'No heading read', 'station.bands': 'Bands', 'station.nudgeUp': 'Up {n} kHz', 'station.nudgeDown': 'Down {n} kHz', 'station.trackOn': 'Tracking on', 'station.trackOff': 'Tracking off', 'station.trackStepTip': 'Re-tune only when the rig has moved this far', 'station.trackModeTip': 'When the antenna is allowed to re-tune', 'station.trackAlways': 'Every frequency change', 'station.trackStep': 'Past a step', 'station.trackBand': 'Band change only', 'station.trackAlwaysTip': 'Follow every frequency change. Best resonance, but the motors run constantly — and on a SteppIR every move blocks transmit while the elements travel.', 'station.trackStepTipMode': 'Re-tune only once the rig has moved further than the step. Follows a QSY, ignores tuning around.', 'station.trackBandTip': 'Re-tune only when the band changes. The motors move a few times a day and are left alone within a band.', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Motorised antenna · click to show', 'station.motorWidgetHide': 'Motorised antenna — shown · click to hide', 'station.hideWidget': 'Hide this widget', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the boards HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
'station.retractTip': 'Home the elements into the hubs — the storage position, for a gale or a winter. The next time the antenna is tuned it comes back out on its own.', 'station.retractTip': 'Home the elements into the hubs — the storage position, for a gale or a winter. The next time the antenna is tuned it comes back out on its own.',
@@ -285,7 +285,7 @@ const en: Dict = {
'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)', 'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)',
'wk.esm': 'ESM — Enter Sends Message (CW)', 'wk.esm': 'ESM — Enter Sends Message (CW)',
'wk.esmHint': 'In CW with the keyer on, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs if the macro contains <LOGQSO>.', 'wk.esmHint': 'In CW with the keyer on, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs if the macro contains <LOGQSO>.',
'wk.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engYaesu': 'Yaesu (rig keyer)', 'wk.yaesuHint': 'Keying goes over the CAT link already configured in Settings → CAT — no second COM port. For the FTDX101 / FT-991A / FT-710 family. An FTDX10 does NOT accept it (DAKY included): on that one use the "Serial port (DTR=CW / RTS=PTT)" keyer on its other COM port (the standard one) with PC KEYING on DTR — confirmed working.', 'wk.catWarnYaesu': 'The Yaesu keyer needs the Yaesu CAT backend — CAT is currently {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (rig keyer)', 'wk.kenwoodHint': 'Keying goes over the CAT link already configured in Settings → CAT (the KY command) — no second COM port. Ideal for an Elecraft K3/K4 whose single USB port is the CAT port.', 'wk.catWarnKenwood': 'The Kenwood/Elecraft keyer needs the Kenwood/Elecraft CAT backend — CAT is currently {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (coming soon)', 'wk.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engYaesu': 'Yaesu (rig keyer)', 'wk.yaesuHint': 'Keying goes over the CAT link already configured in Settings → CAT — no second COM port. For the FTDX101 / FT-991A / FT-710 family. An FTDX10 does NOT accept it (DAKY included): on that one use the "Serial port (DTR=CW / RTS=PTT)" keyer on its other COM port (the standard one) with PC KEYING on DTR — confirmed working.', 'wk.catWarnYaesu': 'The Yaesu keyer needs the Yaesu CAT backend — CAT is currently {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (rig keyer)', 'wk.kenwoodHint': 'Keying goes over the CAT link already configured in Settings → CAT (the KY command) — no second COM port. Ideal for an Elecraft K3/K4 whose single USB port is the CAT port.', 'wk.catWarnKenwood': 'The Kenwood/Elecraft keyer needs the Kenwood/Elecraft CAT backend — CAT is currently {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (SunSDR / Expert Electronics)',
'wk.icomNote': "Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).", 'wk.icomNote': "Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).",
'wk.flexNote': "FlexRadio keys CW through the radio's CWX keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).", 'wk.flexNote': "FlexRadio keys CW through the radio's CWX keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).",
'wk.catWarnIcom': 'Your CAT backend is set to {backend}. Icom CI-V CW needs the CAT backend set to Icom and connected — change it under Settings → CAT interface, otherwise sending CW will fail.', 'wk.catWarnIcom': 'Your CAT backend is set to {backend}. Icom CI-V CW needs the CAT backend set to Icom and connected — change it under Settings → CAT interface, otherwise sending CW will fail.',
@@ -916,7 +916,7 @@ const fr: Dict = {
'station.valueNeedsLabels': 'Une URL ci-dessus utilise {value}, qui envoie le libellé du relais — nomme chaque relais commuté ainsi, sinon son URL part avec une valeur vide.', 'station.valueNeedsLabels': 'Une URL ci-dessus utilise {value}, qui envoie le libellé du relais — nomme chaque relais commuté ainsi, sinon son URL part avec une valeur vide.',
'station.rig': 'Radio', 'station.keyer': 'Manipulateur CW', 'station.voiceKeyer': 'Manipulateur vocal', 'station.rig': 'Radio', 'station.keyer': 'Manipulateur CW', 'station.voiceKeyer': 'Manipulateur vocal',
'station.rigDown': 'le CAT est actif mais la radio ne répond pas', 'station.rigDown': 'le CAT est actif mais la radio ne répond pas',
'station.rigOff': 'le CAT est désactivé', 'station.noPort': 'aucun port configuré', 'station.rigOff': 'le CAT est désactivé', 'station.noPort': 'aucun port configuré', 'station.keyerViaCat': 'via {engine} — aucun port nécessaire',
'station.connect': 'Connecter', 'station.noVoiceMsg': 'Aucun message enregistré — Réglages ▸ Audio.', 'station.connect': 'Connecter', 'station.noVoiceMsg': 'Aucun message enregistré — Réglages ▸ Audio.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotateTo': 'Tourner vers {az}°', 'station.rotatorNoRead': 'Azimut non lu', 'station.bands': 'Bandes', 'station.nudgeUp': 'Monter de {n} kHz', 'station.nudgeDown': 'Descendre de {n} kHz', 'station.trackOn': 'Suivi actif', 'station.trackOff': 'Suivi inactif', 'station.trackStepTip': 'Ne réaccorder que si le rig a bougé d au moins ça', 'station.trackModeTip': "Quand l'antenne a le droit de se réaccorder", 'station.trackAlways': 'À chaque changement', 'station.trackStep': 'Au-delà d un pas', 'station.trackBand': 'Changement de bande', 'station.trackAlwaysTip': "Suivre chaque changement de fréquence. Résonance idéale, mais les moteurs tournent en permanence — et sur une SteppIR chaque déplacement bloque l'émission le temps du mouvement.", 'station.trackStepTipMode': "Ne réaccorder qu'une fois le rig sorti du pas. Suit un QSY, ignore la recherche autour.", 'station.trackBandTip': "Ne réaccorder qu'au changement de bande. Les moteurs bougent quelques fois par jour et restent tranquilles dans une bande.", 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Antenne motorisée · cliquer pour afficher', 'station.motorWidgetHide': 'Antenne motorisée — affichée · cliquer pour masquer', 'station.hideWidget': 'Masquer ce widget', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).', 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotateTo': 'Tourner vers {az}°', 'station.rotatorNoRead': 'Azimut non lu', 'station.bands': 'Bandes', 'station.nudgeUp': 'Monter de {n} kHz', 'station.nudgeDown': 'Descendre de {n} kHz', 'station.trackOn': 'Suivi actif', 'station.trackOff': 'Suivi inactif', 'station.trackStepTip': 'Ne réaccorder que si le rig a bougé d au moins ça', 'station.trackModeTip': "Quand l'antenne a le droit de se réaccorder", 'station.trackAlways': 'À chaque changement', 'station.trackStep': 'Au-delà d un pas', 'station.trackBand': 'Changement de bande', 'station.trackAlwaysTip': "Suivre chaque changement de fréquence. Résonance idéale, mais les moteurs tournent en permanence — et sur une SteppIR chaque déplacement bloque l'émission le temps du mouvement.", 'station.trackStepTipMode': "Ne réaccorder qu'une fois le rig sorti du pas. Suit un QSY, ignore la recherche autour.", 'station.trackBandTip': "Ne réaccorder qu'au changement de bande. Les moteurs bougent quelques fois par jour et restent tranquilles dans une bande.", 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Antenne motorisée · cliquer pour afficher', 'station.motorWidgetHide': 'Antenne motorisée — affichée · cliquer pour masquer', 'station.hideWidget': 'Masquer ce widget', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
'station.retractTip': 'Rentrer les éléments dans les moyeux — la position de rangement, pour un coup de vent ou pour lhiver. Au prochain accord, lantenne ressort delle-même.', 'station.retractTip': 'Rentrer les éléments dans les moyeux — la position de rangement, pour un coup de vent ou pour lhiver. Au prochain accord, lantenne ressort delle-même.',
@@ -934,7 +934,7 @@ const fr: Dict = {
'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)", 'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)",
'wk.esm': 'ESM — Entrée envoie le message (CW)', 'wk.esm': 'ESM — Entrée envoie le message (CW)',
'wk.esmHint': "En CW avec le keyer actif, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue si le macro contient <LOGQSO>.", 'wk.esmHint': "En CW avec le keyer actif, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue si le macro contient <LOGQSO>.",
'wk.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engYaesu': 'Yaesu (keyer de la radio)', 'wk.yaesuHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT — sans second port COM. Pour la famille FTDX101 / FT-991A / FT-710. Un FTDX10 ne l'accepte PAS (DAKY compris) : sur celui-ci, utilisez le keyer « Port série (DTR=CW / RTS=PTT) » sur son autre port COM (le standard) avec PC KEYING sur DTR — confirmé fonctionnel.", 'wk.catWarnYaesu': 'Le keyer Yaesu nécessite le backend CAT Yaesu — le CAT est actuellement : {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (keyer de la radio)', 'wk.kenwoodHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT (la commande KY) — sans second port COM. Idéal pour un Elecraft K3/K4 dont l'unique port USB est le port CAT.", 'wk.catWarnKenwood': 'Le keyer Kenwood/Elecraft nécessite le backend CAT Kenwood/Elecraft — le CAT est actuellement : {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (bientôt)', 'wk.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engYaesu': 'Yaesu (keyer de la radio)', 'wk.yaesuHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT — sans second port COM. Pour la famille FTDX101 / FT-991A / FT-710. Un FTDX10 ne l'accepte PAS (DAKY compris) : sur celui-ci, utilisez le keyer « Port série (DTR=CW / RTS=PTT) » sur son autre port COM (le standard) avec PC KEYING sur DTR — confirmé fonctionnel.", 'wk.catWarnYaesu': 'Le keyer Yaesu nécessite le backend CAT Yaesu — le CAT est actuellement : {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (keyer de la radio)', 'wk.kenwoodHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT (la commande KY) — sans second port COM. Idéal pour un Elecraft K3/K4 dont l'unique port USB est le port CAT.", 'wk.catWarnKenwood': 'Le keyer Kenwood/Elecraft nécessite le backend CAT Kenwood/Elecraft — le CAT est actuellement : {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (SunSDR / Expert Electronics)',
'wk.icomNote': "L'Icom CI-V manipule la CW via le keyer interne de la radio sur la connexion CAT existante (commande 0x17) — il réutilise le port COM CAT défini dans Réglages → CAT, rien d'autre à câbler ici. Mets la radio en mode CW. Poids, ratio, sidetone, mode paddle… se règlent sur la radio ; seule la vitesse est définie ici (KEY SPEED de la radio).", 'wk.icomNote': "L'Icom CI-V manipule la CW via le keyer interne de la radio sur la connexion CAT existante (commande 0x17) — il réutilise le port COM CAT défini dans Réglages → CAT, rien d'autre à câbler ici. Mets la radio en mode CW. Poids, ratio, sidetone, mode paddle… se règlent sur la radio ; seule la vitesse est définie ici (KEY SPEED de la radio).",
'wk.flexNote': "FlexRadio manipule la CW via le keyer CWX de la radio sur la connexion SmartSDR CAT existante — pas besoin de WinKeyer ni de SmartCAT. Il réutilise la connexion définie dans Réglages → CAT, rien d'autre à câbler ici. Mets une slice en mode CW. Seule la vitesse est définie ici ; poids, sidetone et break-in se règlent sur la radio (le break-in doit être activé pour que la CW parte vraiment).", 'wk.flexNote': "FlexRadio manipule la CW via le keyer CWX de la radio sur la connexion SmartSDR CAT existante — pas besoin de WinKeyer ni de SmartCAT. Il réutilise la connexion définie dans Réglages → CAT, rien d'autre à câbler ici. Mets une slice en mode CW. Seule la vitesse est définie ici ; poids, sidetone et break-in se règlent sur la radio (le break-in doit être activé pour que la CW parte vraiment).",
'wk.catWarnIcom': "Ton backend CAT est réglé sur {backend}. La CW Icom CI-V nécessite le backend CAT réglé sur Icom et connecté — change-le dans Réglages → Interface CAT, sinon l'envoi CW échouera.", 'wk.catWarnIcom': "Ton backend CAT est réglé sur {backend}. La CW Icom CI-V nécessite le backend CAT réglé sur Icom et connecté — change-le dans Réglages → Interface CAT, sinon l'envoi CW échouera.",
+6
View File
@@ -526,6 +526,8 @@ export function GetIcomState():Promise<cat.IcomTXState>;
export function GetKenwoodState():Promise<cat.KenwoodTXState>; export function GetKenwoodState():Promise<cat.KenwoodTXState>;
export function GetKeyerStatus():Promise<main.KeyerStatus>;
export function GetLinkedAmps():Promise<Array<string>>; export function GetLinkedAmps():Promise<Array<string>>;
export function GetListsSettings():Promise<main.ListsSettings>; export function GetListsSettings():Promise<main.ListsSettings>;
@@ -914,6 +916,10 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function NudgeKenwoodRIT(arg1:number):Promise<void>; export function NudgeKenwoodRIT(arg1:number):Promise<void>;
export function NudgeSatelliteDownlink(arg1:number):Promise<void>;
export function NudgeSatelliteUplink(arg1:number):Promise<void>;
export function OpenADIFFile():Promise<string>; export function OpenADIFFile():Promise<string>;
export function OpenAwardsFolder():Promise<void>; export function OpenAwardsFolder():Promise<void>;
+12
View File
@@ -982,6 +982,10 @@ export function GetKenwoodState() {
return window['go']['main']['App']['GetKenwoodState'](); return window['go']['main']['App']['GetKenwoodState']();
} }
export function GetKeyerStatus() {
return window['go']['main']['App']['GetKeyerStatus']();
}
export function GetLinkedAmps() { export function GetLinkedAmps() {
return window['go']['main']['App']['GetLinkedAmps'](); return window['go']['main']['App']['GetLinkedAmps']();
} }
@@ -1758,6 +1762,14 @@ export function NudgeKenwoodRIT(arg1) {
return window['go']['main']['App']['NudgeKenwoodRIT'](arg1); return window['go']['main']['App']['NudgeKenwoodRIT'](arg1);
} }
export function NudgeSatelliteDownlink(arg1) {
return window['go']['main']['App']['NudgeSatelliteDownlink'](arg1);
}
export function NudgeSatelliteUplink(arg1) {
return window['go']['main']['App']['NudgeSatelliteUplink'](arg1);
}
export function OpenADIFFile() { export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile'](); return window['go']['main']['App']['OpenADIFFile']();
} }
+26
View File
@@ -3117,6 +3117,32 @@ export namespace main {
this.samples = source["samples"]; this.samples = source["samples"];
} }
} }
export class KeyerStatus {
connected: boolean;
busy: boolean;
wpm: number;
version: number;
port: string;
error?: string;
engine: string;
over_cat: boolean;
static createFrom(source: any = {}) {
return new KeyerStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.busy = source["busy"];
this.wpm = source["wpm"];
this.version = source["version"];
this.port = source["port"];
this.error = source["error"];
this.engine = source["engine"];
this.over_cat = source["over_cat"];
}
}
export class ModePreset { export class ModePreset {
name: string; name: string;
default_rst_sent?: string; default_rst_sent?: string;
-27
View File
@@ -1,9 +1,7 @@
package main package main
import ( import (
"os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -31,28 +29,3 @@ func TestRelaunchCmdPassesItsArgumentsAndFolder(t *testing.T) {
t.Errorf("Dir = %q, want the executable's folder", cmd.Dir) t.Errorf("Dir = %q, want the executable's folder", cmd.Dir)
} }
} }
// The update relaunch goes through a helper that OUTLIVES this process.
//
// Two rewrites started the new exe from here instead, and both left operators
// with no window after an update: the launch then happens while this process is
// still alive and still holds the mutex. The helper waits for our pid first.
// Restored from before 0430aab and pinned here so a third rewrite has to argue
// with the two reports rather than rediscover them.
func TestUpdateRelaunchWaitsForUsFromOutside(t *testing.T) {
src, err := os.ReadFile("update.go")
if err != nil {
t.Fatalf("read update.go: %v", err)
}
got := string(src)
for _, want := range []string{"Wait-Process -Id", "Start-Process -FilePath"} {
if !strings.Contains(got, want) {
t.Errorf("update.go no longer contains %q — the relaunch must wait for this process from outside it", want)
}
}
// Start-Process shows the new window normally. A direct exec.Command(exe…)
// here is the shape that broke it, twice.
if strings.Contains(got, "exec.Command(exe") {
t.Error("update.go starts the new exe directly again")
}
}
+27
View File
@@ -3,7 +3,9 @@
package main package main
import ( import (
"os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -48,3 +50,28 @@ func TestRelaunchIsDetached(t *testing.T) {
t.Error("CREATE_NEW_PROCESS_GROUP is missing: a cleanup aimed at the old instance can reach the new one") t.Error("CREATE_NEW_PROCESS_GROUP is missing: a cleanup aimed at the old instance can reach the new one")
} }
} }
// The update relaunch goes through a helper that OUTLIVES this process.
//
// Two rewrites started the new exe from inside the dying one instead, and both
// left operators with no window after an update: the launch then happens while
// this process is still alive and still holds the mutex. The helper waits for
// our pid first. Restored from before 0430aab and pinned here so a third
// rewrite has to argue with the two reports rather than rediscover them.
func TestWindowsUpdateRelaunchWaitsForUsFromOutside(t *testing.T) {
src, err := os.ReadFile("updateswap_windows.go")
if err != nil {
t.Fatalf("read updateswap_windows.go: %v", err)
}
got := string(src)
for _, want := range []string{"Wait-Process -Id", "Start-Process -FilePath"} {
if !strings.Contains(got, want) {
t.Errorf("the relaunch no longer contains %q — it must wait for this process from outside it", want)
}
}
// Start-Process shows the new window normally. A direct exec.Command(exe…)
// is the shape that broke it, twice.
if strings.Contains(got, "exec.Command(exe") {
t.Error("the Windows relaunch starts the new exe directly again")
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func TestEveryRelaunchPassesItsPid(t *testing.T) {
// quietly matched nothing at all — a guard that passes because it looks // quietly matched nothing at all — a guard that passes because it looks
// nowhere. // nowhere.
spawn := regexp.MustCompile(`(exec\.Command|relaunchCmd)\(exe, "--(post-update|relaunch)"[^)]*\)`) spawn := regexp.MustCompile(`(exec\.Command|relaunchCmd)\(exe, "--(post-update|relaunch)"[^)]*\)`)
for _, file := range []string{"update.go", "app.go"} { for _, file := range []string{"update.go", "app.go", "updateswap_linux.go"} {
src, err := os.ReadFile(file) src, err := os.ReadFile(file)
if err != nil { if err != nil {
t.Fatalf("read %s: %v", file, err) t.Fatalf("read %s: %v", file, err)
+58 -14
View File
@@ -37,17 +37,26 @@ have gcc || have cc || note "a C compiler (Wails links against the system WebKit
have pkg-config || note "pkg-config" have pkg-config || note "pkg-config"
pkg gtk+-3.0 || note "GTK 3 development headers" 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 webkit, and therefore which build tag.
# which works with an extra build tag. Detect which one is present rather than #
# telling the operator to guess. # Wails v2.11 defaults to webkit2gtk-4.0 and takes 4.1 only when told:
# `#cgo !webkit2_41 pkg-config: webkit2gtk-4.0` against
# `#cgo webkit2_41 pkg-config: webkit2gtk-4.1`. This script had it the other
# way round and passed no tag on a Debian 13 box that has only 4.1, so the
# frontend built and then cgo stopped with "Package webkit2gtk-4.0 was not
# found in the pkg-config search path" — after several minutes of npm.
#
# libsoup travels with it: 4.0 pairs with libsoup-2.4, 4.1 with libsoup-3.0.
webkit_tags="" webkit_tags=""
if pkg webkit2gtk-4.1; then if pkg webkit2gtk-4.1; then
ok "webkit2gtk-4.1" ok "webkit2gtk-4.1 (building with -tags webkit2_41)"
webkit_tags="-tags webkit2_41"
pkg libsoup-3.0 || note "libsoup 3 development headers (webkit 4.1 links against them)"
elif pkg webkit2gtk-4.0; then elif pkg webkit2gtk-4.0; then
ok "webkit2gtk-4.0 (older — will build with -tags webkit2_40)" ok "webkit2gtk-4.0 (the default — no tag needed)"
webkit_tags="-tags webkit2_40" pkg libsoup-2.4 || note "libsoup 2.4 development headers (webkit 4.0 links against them)"
else else
note "webkit2gtk development headers (4.1 preferred, 4.0 accepted)" note "webkit2gtk development headers (4.1 or 4.0)"
fi fi
# Node, and its VERSION — this is the trap on an LTS base. Ubuntu 22.04 (so # Node, and its VERSION — this is the trap on an LTS base. Ubuntu 22.04 (so
@@ -77,10 +86,28 @@ if have go; then
# golang-go` gets you a Go that cannot build this repository at all. # 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 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" 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 " wget https://go.dev/dl/go1.26.3.linux-amd64.tar.gz"
ylw " sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz" ylw " sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.3.linux-amd64.tar.gz"
ylw " echo 'export PATH=/usr/local/go/bin:\$HOME/go/bin:\$PATH' >> ~/.profile # then log out and back in" ylw " echo 'export PATH=/usr/local/go/bin:\$HOME/go/bin:\$PATH' >> ~/.profile # then log out and back in"
fi fi
# And TOO NEW breaks too, which is the harder one to meet cold. The Wails
# CLI parses this package with the golang.org/x/tools its own go.mod pins,
# and that release cannot read the export data a newer compiler writes. The
# failure names neither Go nor Wails:
#
# internal error: package "math" without types was imported from
# "hamlog/internal/geo"
#
# Met on Debian 13 with go1.27.1, which is simply what go.dev offers as
# latest. 1.25 and 1.26 are the versions this repository is built with.
if go version | grep -Eq 'go1\.(2[7-9]|[3-9][0-9])'; then
ylw " $gov is NEWER than the Wails CLI can parse this package with."
ylw " The build fails with: internal error: package \"math\" without types."
ylw " Install 1.26 (or 1.25) and rebuild the CLI with it:"
ylw " wget https://go.dev/dl/go1.26.3.linux-amd64.tar.gz"
ylw " sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.3.linux-amd64.tar.gz"
ylw " go install github.com/wailsapp/wails/v2/cmd/[email protected]"
fi
else else
note "go 1.25+ — from https://go.dev/dl/, NOT from apt (no distribution ships 1.25 yet)" note "go 1.25+ — from https://go.dev/dl/, NOT from apt (no distribution ships 1.25 yet)"
fi fi
@@ -129,12 +156,29 @@ else
fi fi
head_ "Sound server" head_ "Sound server"
if have pactl && pactl info >/dev/null 2>&1; then # OpsLog speaks the PulseAudio protocol itself (jfreymuth/pulse) and connects to
ok "$(pactl info | sed -n 's/^Server Name: //p')" # the NATIVE SOCKET. That socket is what decides whether audio works — not
# whether pactl is installed, which is a separate package (pulseaudio-utils).
# This check used to ask for pactl and told a Debian 13 desktop running PipeWire
# perfectly well that it had no sound server at all.
sock="${PULSE_SERVER:-}"
sock="${sock#unix:}"
if [ -z "$sock" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then
sock="$XDG_RUNTIME_DIR/pulse/native"
fi
if [ -n "$sock" ] && [ -S "$sock" ]; then
if have pactl && pactl info >/dev/null 2>&1; then
ok "$(pactl info | sed -n 's/^Server Name: //p')"
else
ok "socket at $sock (install pulseaudio-utils if you want its name)"
fi
else else
ylw " No PulseAudio/PipeWire server answered. The voice keyer, the QSO" ylw " No PulseAudio/PipeWire socket found."
ylw " recorder and the CW decoder will report they cannot reach it." ylw " The voice keyer, the QSO recorder and the CW decoder will report they"
ylw " Everything else works without one." ylw " cannot reach it. Everything else works without one."
ylw " sudo apt install pipewire-pulse wireplumber # Debian/Ubuntu"
ylw " And run this from the DESKTOP session: the sound server belongs to the"
ylw " logged-in session, so a remote shell may not see it."
fi fi
head_ "Building" head_ "Building"
+6 -44
View File
@@ -7,7 +7,6 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -214,51 +213,14 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
} }
applog.Printf("update: installed new build, scheduling relaunch") applog.Printf("update: installed new build, scheduling relaunch")
// A DETACHED, HIDDEN POWERSHELL waits for this process to exit and THEN // How the new build gets started differs by platform — see
// starts the new exe. Restored, verbatim, from before 0430aab. // scheduleRelaunch in updateswap_windows.go and updateswap_linux.go. On
// // Windows it is a helper that outlives us; on Linux it is simply the new
// Two rewrites tried to do without it and both failed on real stations. // binary, because nothing there holds an image open.
// Starting the new exe from here means launching it while this process is if err := a.scheduleRelaunch(exe, dir); err != nil {
// still alive — and the comment on the original said exactly what that
// costs: "Launching the new exe directly while we're still alive raced the
// mutex and often left nothing running". Telling the new instance our pid so
// it could wait on the other side looked equivalent and was not; operators
// kept reporting no window after an update, on 0.27.23 and again after.
// What the helper has that neither rewrite did is that it OUTLIVES us: the
// launch happens after this process is completely gone, from a process that
// was never our child.
//
// The cost is known and accepted. Windows Defender removed 0.27.14 from a
// station as Trojan:Script/Wacatac.H!ml: an unsigned binary that replaces
// itself, clears the mark-of-the-web and spawns a windowless script to start
// another executable has the shape of a dropper, and the model reads shapes,
// not intentions. The operator's answer is to allow OpsLog in Defender. An
// updater that works and occasionally needs whitelisting beats one that
// leaves people with no running program.
//
// HideWindow here is right and is NOT the bug that made the updated OpsLog
// invisible: it hides POWERSHELL's console, which is the whole point. The
// new OpsLog is started by Start-Process, with a normal show.
quoted := strings.ReplaceAll(exe, "'", "''")
ps := fmt.Sprintf(
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
os.Getpid(), quoted)
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
cmd.Dir = dir
hideConsole(cmd)
if err := cmd.Start(); err != nil {
applog.Printf("update: the relaunch could not be started: %v", err) applog.Printf("update: the relaunch could not be started: %v", err)
return fmt.Errorf("schedule relaunch: %w", err) return err
} }
// The HELPER's pid, so the log says the launcher was started and not just
// that we meant to. The new OpsLog logs its own arrival in startup.log; the
// two together tell "the helper never ran" from "it ran and the exe did not
// start", which have different causes.
applog.Printf("update: relaunch helper started as pid %d — it waits for this process (pid %d) to exit, then starts %s",
cmd.Process.Pid, os.Getpid(), filepath.Base(exe))
// Released rather than waited on: this process is about to exit, and a child
// that outlives its parent must not be left as a zombie handle.
_ = cmd.Process.Release()
if a.ctx != nil { if a.ctx != nil {
wruntime.Quit(a.ctx) wruntime.Quit(a.ctx)
} else { } else {
+21
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"hamlog/internal/applog" "hamlog/internal/applog"
) )
@@ -40,3 +41,23 @@ func (a *App) scheduleDeferredSwap(exe, pending string) error {
applog.Printf("update: installed %s over %s after the staging rename failed", filepath.Base(pending), filepath.Base(exe)) applog.Printf("update: installed %s over %s after the staging rename failed", filepath.Base(pending), filepath.Base(exe))
return nil return nil
} }
// scheduleRelaunch starts the new build. On Linux that is simply the new binary.
//
// None of the Windows machinery applies: nothing holds an executable open while
// it runs, so the swap above already succeeded, and there is no mutex to race —
// the single-instance guard is an flock the dying process releases as it exits,
// and the new one waits for our pid before trying it. No helper, no script, and
// nothing that needs to outlive us.
func (a *App) scheduleRelaunch(exe, dir string) error {
cmd := relaunchCmd(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule relaunch: %w", err)
}
applog.Printf("update: relaunch started as pid %d — it waits for this process (pid %d) to exit",
cmd.Process.Pid, os.Getpid())
// Released rather than waited on: this process is about to exit, and a child
// that outlives its parent must not be left as a zombie.
_ = cmd.Process.Release()
return nil
}
+43
View File
@@ -22,6 +22,49 @@ func clearDownloadMark(path string) { _ = os.Remove(path + ":Zone.Identifier") }
// makeExecutable is a no-op on Windows, where the extension decides. // makeExecutable is a no-op on Windows, where the extension decides.
func makeExecutable(path string) error { return nil } func makeExecutable(path string) error { return nil }
// scheduleRelaunch starts the new build once THIS process is gone.
//
// A detached, hidden PowerShell waits for our pid and then launches the exe.
// Restored verbatim from before 0430aab, after two rewrites that started the
// new exe from inside the dying process both failed on real stations — the
// original's own comment had already said why: "Launching the new exe
// directly while we're still alive raced the mutex and often left nothing
// running." Telling the new instance our pid so it could wait on the other
// side looked equivalent and was not. What the helper has that neither
// rewrite did is that it OUTLIVES us.
//
// The cost is known and accepted: Windows Defender removed 0.27.14 from a
// station as Trojan:Script/Wacatac.H!ml, because an unsigned binary that
// replaces itself, clears the mark-of-the-web and spawns a windowless script
// to start another executable has the shape of a dropper. Allowing OpsLog in
// Defender beats an updater that leaves people with no running program.
//
// HideWindow is right here and is NOT the bug that made the updated OpsLog
// invisible: it hides POWERSHELL's console, which is the point, while
// Start-Process shows the new window normally.
func (a *App) scheduleRelaunch(exe, dir string) error {
quoted := strings.ReplaceAll(exe, "'", "''")
ps := fmt.Sprintf(
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
os.Getpid(), quoted)
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
cmd.Dir = dir
hideConsole(cmd)
if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule relaunch: %w", err)
}
// The HELPER's pid, so the log says the launcher was started and not just
// that we meant to. The new OpsLog logs its own arrival in startup.log; the
// two together tell "the helper never ran" from "it ran and the exe did not
// start", which have different causes.
applog.Printf("update: relaunch helper started as pid %d — it waits for this process (pid %d) to exit, then starts %s",
cmd.Process.Pid, os.Getpid(), filepath.Base(exe))
// Released rather than waited on: this process is about to exit, and a child
// that outlives its parent must not be left as a zombie handle.
_ = cmd.Process.Release()
return nil
}
// scheduleDeferredSwap hands the exe swap to a detached helper that runs AFTER // scheduleDeferredSwap hands the exe swap to a detached helper that runs AFTER
// this process is gone. // this process is gone.
// //