Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee62930f75 | ||
|
|
ef5a8f70ff | ||
|
|
bb596c8aa9 | ||
|
|
f22058355b | ||
|
|
13fd2367bd | ||
|
|
f8d47cd3b5 |
+31
-8
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 d’envoi, 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 n’a pas besoin."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.25",
|
"version": "0.27.25",
|
||||||
"en": [
|
"en": [
|
||||||
|
|||||||
+35
-2
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ const en: Dict = {
|
|||||||
'station.valueNeedsLabels': 'A URL above uses {value}, which sends the relay’s 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 relay’s 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 board’s 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 board’s 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 l’hiver. Au prochain accord, l’antenne ressort d’elle-même.',
|
'station.retractTip': 'Rentrer les éléments dans les moyeux — la position de rangement, pour un coup de vent ou pour l’hiver. Au prochain accord, l’antenne ressort d’elle-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.",
|
||||||
|
|||||||
Vendored
+6
@@ -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>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user