This commit is contained in:
2026-06-13 16:17:58 +02:00
parent ff53831be4
commit 00cab6b204
4 changed files with 78 additions and 37 deletions
+10 -1
View File
@@ -4,7 +4,16 @@
"Bash(go get *)", "Bash(go get *)",
"Bash(go build *)", "Bash(go build *)",
"Bash(wails generate *)", "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 *)"
] ]
} }
} }
+13 -2
View File
@@ -10,6 +10,7 @@ import (
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -1194,10 +1195,20 @@ func (a *App) reloadLookupProviders() {
// --- QSO bindings --- // --- 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 { if a.qso == nil {
return 0, fmt.Errorf("db not initialized") 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.applyStationDefaults(&q)
a.applyDXCCNumber(&q) a.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions 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 q.Email = lr.Email
} }
} }
id, err := a.qso.Add(a.ctx, q) id, err = a.qso.Add(a.ctx, q)
if err == nil { if err == nil {
q.ID = id q.ID = id
a.saveQSORecording(&q) a.saveQSORecording(&q)
+16 -6
View File
@@ -1294,9 +1294,15 @@ export default function App() {
email: details.email, email: details.email,
}; };
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
const loggedCall = String(payload.callsign ?? '');
const loggedDxcc = typeof payload.dxcc === 'number' ? payload.dxcc : 0;
await AddQSO(payload); await AddQSO(payload);
resetEntry(); resetEntry();
await refresh(); 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) { } catch (e: any) {
setError(String(e?.message ?? e)); setError(String(e?.message ?? e));
} finally { setSaving(false); } } finally { setSaving(false); }
@@ -1556,6 +1562,11 @@ export default function App() {
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') })); setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
} }
function onCallsignInput(v: string, opts?: { force?: boolean }) { 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 // 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, // 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 // 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 — // Recording START happens on blur (leaving the callsign field), NOT here —
// you may type a call and work it minutes later. Clearing it cancels. // you may type a call and work it minutes later. Clearing it cancels.
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; } if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; }
const wasEmpty = callsign.trim() === '';
const isEmpty = v.trim() === ''; const isEmpty = v.trim() === '';
if (wasEmpty && !isEmpty && !locks.start) { if (!isEmpty && !locks.start) {
// First keystroke of a new QSO — freeze the start time so it doesn't // Restart the start time on every callsign change (each keystroke, a
// drift even if the lookup or typing takes 30 seconds. Skip when // clicked spot, a new call): "Start UTC" should mark when you actually
// start is locked: the user is back-entering a past QSO and set a // began this contact, not a stale time frozen at the first keystroke.
// specific time manually. // Skip when start is locked (back-entering a past QSO at a chosen time).
setQsoStartedAt(new Date()); setQsoStartedAt(new Date());
} else if (isEmpty && !locks.start) { } else if (isEmpty && !locks.start) {
// Callsign wiped → user abandoned this QSO; reset the timer. // Callsign wiped → user abandoned this QSO; reset the timer.
+11
View File
@@ -13,6 +13,7 @@ package winkeyer
import ( import (
"fmt" "fmt"
"runtime/debug"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -197,9 +198,11 @@ func (m *Manager) applyConfig(c Config) error {
} }
// modeRegister builds the WinKey mode-register byte (command 0x0E). // modeRegister builds the WinKey mode-register byte (command 0x0E).
//
// bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug) // bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug)
// bit 3 : paddle swap // bit 3 : paddle swap
// bit 0/... : (autospace is bit 0 of a separate group on some firmwares) // bit 0/... : (autospace is bit 0 of a separate group on some firmwares)
//
// We keep to the widely-compatible WK2 layout. // We keep to the widely-compatible WK2 layout.
func modeRegister(c Config) byte { func modeRegister(c Config) byte {
var b 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. // 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{}) { func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
defer close(done) 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) buf := make([]byte, 64)
for { for {
select { select {