From 00cab6b2042bfec67e5b682205259284f577dceb Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sat, 13 Jun 2026 16:17:58 +0200 Subject: [PATCH] up --- .claude/settings.json | 11 +++++- app.go | 15 ++++++-- frontend/src/App.tsx | 22 ++++++++---- internal/winkeyer/winkeyer.go | 67 ++++++++++++++++++++--------------- 4 files changed, 78 insertions(+), 37 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index b3ffd6b..f47a192 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,16 @@ "Bash(go get *)", "Bash(go build *)", "Bash(wails generate *)", - "Bash(npm run *)" + "Bash(npm run *)", + "Bash(gofmt -w app.go internal/winkeyer/winkeyer.go)", + "Bash(timeout 20 git status --short)", + "Bash(echo \"exit: $?\")", + "Bash(GIT_TERMINAL_PROMPT=0 timeout 20 git push --dry-run)", + "Bash(echo \"push exit: $?\")", + "Bash(git config *)", + "Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)", + "Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)", + "Bash(which git-credential-manager *)" ] } } diff --git a/app.go b/app.go index 90718d1..70e8c7c 100644 --- a/app.go +++ b/app.go @@ -10,6 +10,7 @@ import ( "math" "os" "path/filepath" + "runtime/debug" "sort" "strconv" "strings" @@ -1194,10 +1195,20 @@ func (a *App) reloadLookupProviders() { // --- QSO bindings --- -func (a *App) AddQSO(q qso.QSO) (int64, error) { +func (a *App) AddQSO(q qso.QSO) (id int64, err error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } + // Never let a panic in the logging path crash the whole app — the user lost + // QSOs that way (CW + WinKeyer). Surface it as an error instead. + defer func() { + if r := recover(); r != nil { + applog.Printf("PANIC in AddQSO: %v\n%s", r, debug.Stack()) + if id == 0 { + err = fmt.Errorf("internal error while logging: %v", r) + } + } + }() a.applyStationDefaults(&q) a.applyDXCCNumber(&q) a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions @@ -1210,7 +1221,7 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) { q.Email = lr.Email } } - id, err := a.qso.Add(a.ctx, q) + id, err = a.qso.Add(a.ctx, q) if err == nil { q.ID = id a.saveQSORecording(&q) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a16ad9c..db20895 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1294,9 +1294,15 @@ export default function App() { email: details.email, }; applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); + const loggedCall = String(payload.callsign ?? ''); + const loggedDxcc = typeof payload.dxcc === 'number' ? payload.dxcc : 0; await AddQSO(payload); resetEntry(); await refresh(); + // Refresh the Worked-before matrix so the just-logged band/mode flips to + // "worked" — resetEntry cleared it, so re-fetch for the logged call (a + // live DB query, so it now includes this QSO). + if (loggedCall.length >= 3) runWorkedBefore(loggedCall, loggedDxcc); } catch (e: any) { setError(String(e?.message ?? e)); } finally { setSaving(false); } @@ -1556,6 +1562,11 @@ export default function App() { setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') })); } function onCallsignInput(v: string, opts?: { force?: boolean }) { + // Programmatic call-sets (force: spot click, UDP, external app) count as + // "not manually typed", so a later UDP DX call (DXHunter remote control) can + // still replace it. Without this, clicking a cluster spot froze the call: + // applyUdpCall saw current != lastUdpCall and refused every later UDP call. + if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase(); // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call // on every status packet. If it matches what's already in the entry, // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and @@ -1567,13 +1578,12 @@ export default function App() { // Recording START happens on blur (leaving the callsign field), NOT here — // you may type a call and work it minutes later. Clearing it cancels. if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; } - const wasEmpty = callsign.trim() === ''; const isEmpty = v.trim() === ''; - if (wasEmpty && !isEmpty && !locks.start) { - // First keystroke of a new QSO — freeze the start time so it doesn't - // drift even if the lookup or typing takes 30 seconds. Skip when - // start is locked: the user is back-entering a past QSO and set a - // specific time manually. + if (!isEmpty && !locks.start) { + // Restart the start time on every callsign change (each keystroke, a + // clicked spot, a new call): "Start UTC" should mark when you actually + // began this contact, not a stale time frozen at the first keystroke. + // Skip when start is locked (back-entering a past QSO at a chosen time). setQsoStartedAt(new Date()); } else if (isEmpty && !locks.start) { // Callsign wiped → user abandoned this QSO; reset the timer. diff --git a/internal/winkeyer/winkeyer.go b/internal/winkeyer/winkeyer.go index d030806..d70e0d6 100644 --- a/internal/winkeyer/winkeyer.go +++ b/internal/winkeyer/winkeyer.go @@ -13,6 +13,7 @@ package winkeyer import ( "fmt" + "runtime/debug" "strings" "sync" "time" @@ -26,28 +27,28 @@ import ( type Mode string const ( - ModeIambicB Mode = "iambic_b" - ModeIambicA Mode = "iambic_a" - ModeUltimatic Mode = "ultimatic" - ModeBug Mode = "bug" + ModeIambicB Mode = "iambic_b" + ModeIambicA Mode = "iambic_a" + ModeUltimatic Mode = "ultimatic" + ModeBug Mode = "bug" ) // Config is the keyer configuration the UI persists and applies on connect. type Config struct { - Port string `json:"port"` // e.g. "COM6" - Baud int `json:"baud"` // 1200 for WK2, also fine for WK3 - WPM int `json:"wpm"` // 5..99 - Weight int `json:"weight"` // 10..90, 50 = normal - LeadInMs int `json:"lead_in_ms"` // PTT lead-in, 10 ms units sent to device - TailMs int `json:"tail_ms"` // PTT tail - Ratio int `json:"ratio"` // dah/dit ratio 33..66 (50 = 3:1) - Farnsworth int `json:"farnsworth"` // Farnsworth WPM (0 = off) - Sidetone int `json:"sidetone_hz"` // 0 = off; else target Hz (mapped to WK code) - Mode Mode `json:"mode"` // paddle mode - Swap bool `json:"swap"` // swap dit/dah paddles - AutoSpace bool `json:"autospace"` // auto letter-space - UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output) - SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host + Port string `json:"port"` // e.g. "COM6" + Baud int `json:"baud"` // 1200 for WK2, also fine for WK3 + WPM int `json:"wpm"` // 5..99 + Weight int `json:"weight"` // 10..90, 50 = normal + LeadInMs int `json:"lead_in_ms"` // PTT lead-in, 10 ms units sent to device + TailMs int `json:"tail_ms"` // PTT tail + Ratio int `json:"ratio"` // dah/dit ratio 33..66 (50 = 3:1) + Farnsworth int `json:"farnsworth"` // Farnsworth WPM (0 = off) + Sidetone int `json:"sidetone_hz"` // 0 = off; else target Hz (mapped to WK code) + Mode Mode `json:"mode"` // paddle mode + Swap bool `json:"swap"` // swap dit/dah paddles + AutoSpace bool `json:"autospace"` // auto letter-space + UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output) + SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host } func (c Config) normalised() Config { @@ -77,9 +78,9 @@ func (c Config) normalised() Config { // Status is pushed to the UI whenever the link state or keyer activity changes. type Status struct { Connected bool `json:"connected"` - Busy bool `json:"busy"` // device is currently sending CW - WPM int `json:"wpm"` // current speed (tracks the speed pot) - Version int `json:"version"` // host firmware version byte + Busy bool `json:"busy"` // device is currently sending CW + WPM int `json:"wpm"` // current speed (tracks the speed pot) + Version int `json:"version"` // host firmware version byte Port string `json:"port"` Error string `json:"error,omitempty"` } @@ -177,11 +178,11 @@ func (m *Manager) Connect(cfg Config) error { // applyConfig pushes the keying parameters to the device. func (m *Manager) applyConfig(c Config) error { cmds := [][]byte{ - {0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…) - {0x02, byte(c.WPM)}, // set speed (WPM) - {0x03, byte(c.Weight)}, // set weighting + {0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…) + {0x02, byte(c.WPM)}, // set speed (WPM) + {0x03, byte(c.Weight)}, // set weighting {0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units) - {0x11, byte(c.Ratio)}, // set dit/dah ratio + {0x11, byte(c.Ratio)}, // set dit/dah ratio } // Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor. cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)}) @@ -197,9 +198,11 @@ func (m *Manager) applyConfig(c Config) error { } // modeRegister builds the WinKey mode-register byte (command 0x0E). -// bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug) -// bit 3 : paddle swap -// bit 0/... : (autospace is bit 0 of a separate group on some firmwares) +// +// bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug) +// bit 3 : paddle swap +// bit 0/... : (autospace is bit 0 of a separate group on some firmwares) +// // We keep to the widely-compatible WK2 layout. func modeRegister(c Config) byte { var b byte @@ -339,6 +342,14 @@ func (m *Manager) Disconnect() { // the echo of characters being sent. We track busy/idle and the speed pot. func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) { defer close(done) + // A panic here (e.g. in a status/echo callback) would otherwise take down + // the whole app — which showed up as a crash when logging a CW QSO while + // the keyer was still streaming echo bytes. Recover, log, end the loop. + defer func() { + if r := recover(); r != nil { + applog.Printf("winkeyer: readLoop panic recovered: %v\n%s", r, debug.Stack()) + } + }() buf := make([]byte, 64) for { select {