chore: release v0.21.3

This commit is contained in:
2026-07-26 16:57:19 +02:00
parent 4fd70f6a9d
commit 91b5af1c7b
46 changed files with 2491 additions and 355 deletions
+88 -7
View File
@@ -793,6 +793,14 @@ func (a *App) startup(ctx context.Context) {
cat.LogSink = applog.Printf
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back
db.LogSink = applog.Printf // which schema migrations ran, and how long they took
// Version and executable FIRST, before anything else can fail. Diagnosing a
// report means knowing which build produced the log, and that was previously
// impossible: the copy someone is actually running is not always the one they
// think they installed, and the version shown in the UI is the only clue.
exe, _ := os.Executable()
applog.Printf("startup: OpsLog %s — %s", appVersion, exe)
applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
@@ -1568,9 +1576,44 @@ func (a *App) restoreWindowPosition() {
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
return // corrupt / absurd — leave the default placement
}
// The SIZE was sanity-checked above but the POSITION never was, and that is
// the one that makes OpsLog unusable: close it on a second monitor, unplug
// that monitor (or dock elsewhere, or change the layout), and the saved
// coordinates put the window somewhere no screen covers. It opens, invisibly,
// forever — with no way back short of deleting window.json, which nobody
// knows to do. Fall back to the default placement instead.
if !onSomeMonitor(ws.X, ws.Y, ws.Width, ws.Height) {
applog.Printf("window: saved position %d,%d (%dx%d) is off every monitor — opening at the default placement",
ws.X, ws.Y, ws.Width, ws.Height)
return
}
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
}
// onSomeMonitor reports whether a window at these coordinates would land on the
// visible desktop. It demands a real slab of the title bar rather than a single
// pixel: a window overlapping the screen edge by 2 px is, in practice, as lost
// as one entirely outside it. When the desktop bounds can't be read, it says yes
// — better to honour the operator's saved position than to second-guess it.
func onSomeMonitor(x, y, w, h int) bool {
vx, vy, vw, vh, ok := virtualScreenBounds()
if !ok {
return true
}
return overlapsEnough(x, y, w, h, vx, vy, vw, vh)
}
// overlapsEnough is the geometry behind onSomeMonitor, split out so it can be
// tested — the virtual desktop origin is NEGATIVE when a monitor sits left of or
// above the primary one, which is precisely the layout that produces a lost
// window and precisely where sign errors hide.
func overlapsEnough(x, y, w, h, vx, vy, vw, vh int) bool {
const grabW, grabH = 160, 32 // enough of the title bar to see and drag
overlapW := min(x+w, vx+vw) - max(x, vx)
overlapH := min(y+h, vy+vh) - max(y, vy)
return overlapW >= grabW && overlapH >= grabH
}
// readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero
// value if the file is missing/unreadable.
func readBootstrap(dataDir string) dbPointer {
@@ -5472,6 +5515,20 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
if field == "freq" {
return a.bulkSetFrequency(ids, value)
}
// Some ADIF fields have no promoted column and live in extras_json
// (OWNER_CALLSIGN) — those take the JSON path so the rest of the extras on
// each QSO survive the edit.
if key := qso.BulkExtraKey(field); key != "" {
n, err := a.qso.BulkSetExtra(a.ctx, ids, key, strings.TrimSpace(value))
if err != nil {
return 0, err
}
if n > 0 {
a.invalidateAwardStats()
a.materializeAwardRefsForIDs(ids)
}
return n, nil
}
col, ok := bulkFieldColumns[field]
if !ok {
return 0, fmt.Errorf("unknown field %q", field)
@@ -6076,12 +6133,23 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
if a.lookup == nil {
return lookup.Result{}, fmt.Errorf("lookup not initialized")
}
// Bound the whole lookup: give the providers a couple of seconds, then let
// Lookup fall through to cty.dat (country/zones). Without this a call that isn't
// in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner
// turning for 10 s+ before the cty.dat fallback showed. The providers respect
// the context, so they're cancelled at the deadline and cty.dat answers instantly.
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second)
// Bound the whole lookup, then let Lookup fall through to cty.dat
// (country/zones). Without this a call that isn't in QRZ.com — or a slow
// provider — left the "looking up" spinner turning for 10 s+ before the
// cty.dat fallback showed. The providers respect the context, so they're
// cancelled at the deadline and cty.dat answers instantly.
//
// A slashed/portable call needs a bigger slice, because it costs TWO provider
// round trips rather than one: the full form is looked up first, and only when
// that comes back not-found does the home call get tried (F4LYI/M → F4LYI).
// Two seconds covered one request but not two, so /M and /P calls always ran
// out of time on the second and fell back to cty.dat — even though the
// operator's QRZ record was there and the plain call resolved fine.
budget := 2 * time.Second
if strings.Contains(callsign, "/") {
budget = 6 * time.Second
}
ctx, cancel := context.WithTimeout(a.ctx, budget)
defer cancel()
r, err := a.lookup.Lookup(ctx, callsign)
if errors.Is(err, lookup.ErrNotFound) {
@@ -8023,7 +8091,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
a.pttKeyedMethod = "cat"
a.pttGen++
a.pttMu.Unlock()
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
applog.Printf("dvk: PTT keyed (CAT via %s)", a.cat.State().Backend)
return nil
case "rts", "dtr":
if strings.TrimSpace(cfg.PTTPort) == "" {
@@ -8212,6 +8280,19 @@ func (a *App) GetLogFilePath() string {
return applog.Path()
}
// UILog lets the frontend write to the same diagnostic log as the backend.
//
// Frontend-only logic (entry-strip auto-fill, debounce ordering, which field the
// operator has touched) was previously invisible in a bug report: console output
// dies with the window, and these problems reproduce on other operators'
// machines, not here. A line in opslog.log can simply be sent along with the
// report. Callers prefix their own subsystem, e.g. "backfill: …".
func (a *App) UILog(msg string) {
if msg = strings.TrimSpace(msg); msg != "" {
applog.Printf("ui: %s", msg)
}
}
// ── QSL defaults ──────────────────────────────────────────────────────
// GetQSLDefaults returns the stored defaults — empty strings when the