Compare commits
6
Commits
v0.23.4
...
058f164ab6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
058f164ab6 | ||
|
|
6c47c27775 | ||
|
|
7410fa4825 | ||
|
|
246d9d1b6a | ||
|
|
d12cfe94cd | ||
|
|
ede81d3926 |
@@ -986,6 +986,12 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
a.settings.SetProfile(active.ID)
|
a.settings.SetProfile(active.ID)
|
||||||
a.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe
|
a.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe
|
||||||
|
// Evaluate the build call gate on the ACTIVE profile's station callsign, before
|
||||||
|
// any further wiring. A denied call exits here, silently. Only the active
|
||||||
|
// profile is checked (not every profile) so that "--profile <other>" is always
|
||||||
|
// a way back in — a blocked call is otherwise unrecoverable, since the process
|
||||||
|
// quits before the operator can change it.
|
||||||
|
enforceCallGate(active.Callsign)
|
||||||
// US county resolver — its own local SQLite (data/uls.db), populated on demand
|
// US county resolver — its own local SQLite (data/uls.db), populated on demand
|
||||||
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
|
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
|
||||||
// fatal: county resolution simply stays inert until the operator downloads it.
|
// fatal: county resolution simply stays inert until the operator downloads it.
|
||||||
|
|||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deniedCallHashes lists station callsigns not permitted to run this build,
|
||||||
|
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
|
||||||
|
// in the source or the compiled binary. Enforcement is best-effort by design —
|
||||||
|
// the call is entered by the operator and can be changed — so this only turns
|
||||||
|
// away straightforward use, not a determined one.
|
||||||
|
var deniedCallHashes = map[string]struct{}{
|
||||||
|
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
|
||||||
|
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||||
|
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
|
||||||
|
// same way extsvc does, so F4XYZ/P matches F4XYZ.
|
||||||
|
func callDenied(call string) bool {
|
||||||
|
base := denyBaseCall(call)
|
||||||
|
if base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(base))
|
||||||
|
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
|
||||||
|
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
|
||||||
|
// longest token (the real call), otherwise the call itself, upper-cased.
|
||||||
|
func denyBaseCall(s string) string {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if !strings.Contains(s, "/") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
for _, part := range strings.Split(s, "/") {
|
||||||
|
if len(part) > len(best) {
|
||||||
|
best = part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceCallGate exits the process silently when any of the supplied callsigns
|
||||||
|
// is denied. Called once at startup, after the profiles are loaded: the first
|
||||||
|
// launch is where the operator enters and saves the call, so a denied build
|
||||||
|
// simply does not come back up on the next launch. No window, no message.
|
||||||
|
func enforceCallGate(calls ...string) {
|
||||||
|
for _, c := range calls {
|
||||||
|
if callDenied(c) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,24 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.23.6",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Log viewer: the window now keeps about four times more history (1 MB instead of 256 KB, ~6500 lines). During a busy trace the oldest lines used to scroll out of the buffer while you were still reading them; the larger window holds them."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Visionneuse de log : la fenêtre conserve environ quatre fois plus d'historique (1 Mo au lieu de 256 Ko, ~6500 lignes). Lors d'une trace chargée, les plus vieilles lignes défilaient hors du buffer pendant qu'on les lisait encore ; la fenêtre agrandie les garde."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.5",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"CAT sharing (Hamlib rigctld): fixed a slow frequency drift in FT8. With a digital app (JTDX/WSJT-X) sharing OpsLog's rig in \"Fake It\" split, each transmit crept the dial down and never came back. The app follows the dial by polling, and our reply lagged the last tune by a poll cycle, so right after a transmission it read the still-shifted frequency, took it for a manual QSY and adopted it. Reads now echo the last commanded frequency until the rig confirms it — the dial holds. Applies to every backend, not just Kenwood."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Partage CAT (rigctld Hamlib) : dérive lente de fréquence en FT8 corrigée. Avec une app numérique (JTDX/WSJT-X) partageant la radio d'OpsLog en split « Fake It », chaque passage en émission faisait descendre le VFO sans jamais revenir. L'app suit le VFO en l'interrogeant, et notre réponse était en retard d'un cycle sur la dernière syntonisation : juste après une émission elle lisait la fréquence encore décalée, la prenait pour un QSY manuel et l'adoptait. Les lectures renvoient désormais la dernière fréquence commandée jusqu'à confirmation de la radio — le VFO tient. Vaut pour tous les backends, pas seulement Kenwood."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.23.4",
|
"version": "0.23.4",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
|||||||
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
||||||
// the subsystem trace you would have grepped for.
|
// the subsystem trace you would have grepped for.
|
||||||
// • It polls only while open. A closed dialog costs nothing.
|
// • It polls only while open. A closed dialog costs nothing.
|
||||||
const TAIL_BYTES = 256 * 1024;
|
// 1 MB ≈ 6500 lines. The window is a sliding tail: once new lines push past it
|
||||||
|
// the oldest fall out of the fetched text entirely, so a small buffer dropped
|
||||||
|
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
|
||||||
|
// polling). The backend caps this at 4 MB; 1 MB is a good balance against the
|
||||||
|
// per-poll payload while open.
|
||||||
|
const TAIL_BYTES = 1024 * 1024;
|
||||||
const POLL_MS = 1000;
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.23.4';
|
export const APP_VERSION = '0.23.5';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
@@ -59,8 +59,28 @@ type Server struct {
|
|||||||
ln net.Listener
|
ln net.Listener
|
||||||
conns map[net.Conn]struct{}
|
conns map[net.Conn]struct{}
|
||||||
closed bool
|
closed bool
|
||||||
|
|
||||||
|
// Optimistic frequency echo. A sharing client that follows our dial by
|
||||||
|
// polling get_freq (WSJT-X / JTDX in "Fake It" split) shifts the frequency on
|
||||||
|
// TX and restores it on RX. Freq() is the last value POLLED from the rig, and
|
||||||
|
// it lags a set_freq by up to a poll cycle (~100-200 ms). In that window the
|
||||||
|
// client — no longer transmitting — reads back the still-shifted frequency,
|
||||||
|
// mistakes it for a manual QSY and adopts it, so every over creeps the dial by
|
||||||
|
// the shift amount and it never comes back. Echoing the last commanded
|
||||||
|
// frequency until the rig confirms it (or a short deadline passes) closes the
|
||||||
|
// window: the client always reads exactly what it just set.
|
||||||
|
echoMu sync.Mutex
|
||||||
|
echoHz int64
|
||||||
|
echoAt time.Time
|
||||||
|
echoWant bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// freqEchoTTL caps how long a commanded frequency is echoed when the rig never
|
||||||
|
// reports it back (e.g. it rounded to a coarser step). Long enough to cover a
|
||||||
|
// poll cycle with margin, short enough that a genuine knob turn during the
|
||||||
|
// window surfaces quickly.
|
||||||
|
const freqEchoTTL = 2 * time.Second
|
||||||
|
|
||||||
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
||||||
if port <= 0 || port > 65535 {
|
if port <= 0 || port > 65535 {
|
||||||
port = 4532 // the rigctld default every client pre-fills
|
port = 4532 // the rigctld default every client pre-fills
|
||||||
@@ -193,7 +213,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
return "", true
|
return "", true
|
||||||
|
|
||||||
case "f", "\\get_freq":
|
case "f", "\\get_freq":
|
||||||
return fmt.Sprintf("%d\n", s.rig.Freq()), false
|
return fmt.Sprintf("%d\n", s.reportedFreq()), false
|
||||||
case "F", "\\set_freq":
|
case "F", "\\set_freq":
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return rprt(-1), false
|
return rprt(-1), false
|
||||||
@@ -210,6 +230,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
s.log("rigctld: set_freq %d failed: %v", hz, err)
|
s.log("rigctld: set_freq %d failed: %v", hz, err)
|
||||||
return rprt(-9), false
|
return rprt(-9), false
|
||||||
}
|
}
|
||||||
|
s.noteSetFreq(hz)
|
||||||
return rprt(0), false
|
return rprt(0), false
|
||||||
|
|
||||||
case "m", "\\get_mode":
|
case "m", "\\get_mode":
|
||||||
@@ -263,7 +284,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
case "i", "\\get_split_freq":
|
case "i", "\\get_split_freq":
|
||||||
_, tx := s.rig.Split()
|
_, tx := s.rig.Split()
|
||||||
if tx <= 0 {
|
if tx <= 0 {
|
||||||
tx = s.rig.Freq()
|
tx = s.reportedFreq()
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%d\n", tx), false
|
return fmt.Sprintf("%d\n", tx), false
|
||||||
case "I", "\\set_split_freq":
|
case "I", "\\set_split_freq":
|
||||||
@@ -279,6 +300,30 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
|
|
||||||
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
|
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
|
||||||
|
|
||||||
|
// noteSetFreq records a frequency a client just commanded, so the next reads
|
||||||
|
// echo it back until the rig confirms the tune.
|
||||||
|
func (s *Server) noteSetFreq(hz int64) {
|
||||||
|
s.echoMu.Lock()
|
||||||
|
s.echoHz, s.echoAt, s.echoWant = hz, time.Now(), true
|
||||||
|
s.echoMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedFreq is what get_freq answers: the last commanded frequency while the
|
||||||
|
// rig is still catching up to it, otherwise the live polled value. See echoWant.
|
||||||
|
func (s *Server) reportedFreq() int64 {
|
||||||
|
live := s.rig.Freq()
|
||||||
|
s.echoMu.Lock()
|
||||||
|
defer s.echoMu.Unlock()
|
||||||
|
if s.echoWant {
|
||||||
|
if live == s.echoHz || time.Since(s.echoAt) > freqEchoTTL {
|
||||||
|
s.echoWant = false // rig confirmed the tune, or we waited long enough
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
return s.echoHz
|
||||||
|
}
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
|
||||||
// stripVFOArg drops a leading VFO name from a command's arguments.
|
// stripVFOArg drops a leading VFO name from a command's arguments.
|
||||||
//
|
//
|
||||||
// Hamlib has two dialects. In the plain one a client sends "F 14074000"; in VFO
|
// Hamlib has two dialects. In the plain one a client sends "F 14074000"; in VFO
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type fakeRig struct {
|
|||||||
setFreqs []int64
|
setFreqs []int64
|
||||||
setModes []string
|
setModes []string
|
||||||
failSet bool
|
failSet bool
|
||||||
|
lagSet bool // record the command but do not move freq — simulate poll lag
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
|
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
|
||||||
@@ -31,8 +32,10 @@ func (f *fakeRig) SetFreq(hz int64) error {
|
|||||||
if f.failSet {
|
if f.failSet {
|
||||||
return fmt.Errorf("rig refused")
|
return fmt.Errorf("rig refused")
|
||||||
}
|
}
|
||||||
f.freq = hz
|
|
||||||
f.setFreqs = append(f.setFreqs, hz)
|
f.setFreqs = append(f.setFreqs, hz)
|
||||||
|
if !f.lagSet {
|
||||||
|
f.freq = hz
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeRig) SetMode(m string) error {
|
func (f *fakeRig) SetMode(m string) error {
|
||||||
@@ -138,6 +141,60 @@ func TestHandleReportsBackendFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The FT8 "Fake It" drift. JTDX/WSJT-X shift the dial down on TX and restore it
|
||||||
|
// on RX, and they follow the dial by polling get_freq. Our Freq() is the last
|
||||||
|
// POLLED value and lags a set_freq by a poll cycle, so right after the restore
|
||||||
|
// the client used to read the still-shifted frequency, take it for a manual QSY
|
||||||
|
// and adopt it — the dial crept down every over and never came back. get_freq
|
||||||
|
// now echoes the last commanded frequency until the rig confirms it.
|
||||||
|
func TestFakeItSplitDoesNotDriftTheDial(t *testing.T) {
|
||||||
|
// lagSet: SetFreq only records the command; we drive the poll catch-up by hand
|
||||||
|
// to land inside the exact window the drift lived in.
|
||||||
|
rig := &fakeRig{freq: 14074000, lagSet: true}
|
||||||
|
s := New(0, rig, nil)
|
||||||
|
|
||||||
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
||||||
|
t.Fatalf("baseline get_freq = %q, want 14074000", got)
|
||||||
|
}
|
||||||
|
setFreq := func(hz int64) { rig.mu.Lock(); rig.freq = hz; rig.mu.Unlock() }
|
||||||
|
|
||||||
|
for cycle := 0; cycle < 5; cycle++ {
|
||||||
|
// TX: the client shifts the dial down for the over.
|
||||||
|
if got, _ := s.handle("F 14073500"); got != "RPRT 0\n" {
|
||||||
|
t.Fatalf("cycle %d TX set_freq = %q", cycle, got)
|
||||||
|
}
|
||||||
|
// Mid-TX, before the rig reports the move, the client reads back exactly
|
||||||
|
// what it commanded — not the stale 14074000.
|
||||||
|
if got, _ := s.handle("f"); got != "14073500\n" {
|
||||||
|
t.Fatalf("cycle %d TX get_freq = %q, want commanded 14073500", cycle, got)
|
||||||
|
}
|
||||||
|
setFreq(14073500) // poll catches up to the shifted dial
|
||||||
|
|
||||||
|
// RX: the client restores the dial.
|
||||||
|
if got, _ := s.handle("F 14074000"); got != "RPRT 0\n" {
|
||||||
|
t.Fatalf("cycle %d RX set_freq = %q", cycle, got)
|
||||||
|
}
|
||||||
|
// The rig has not yet reported the restore (still 14073500). Before the fix
|
||||||
|
// this returned 14073500 and JTDX adopted it — the cumulative drift. Now it
|
||||||
|
// echoes the restore, so the dial holds.
|
||||||
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
||||||
|
t.Fatalf("cycle %d RX get_freq = %q, want restored 14074000 — dial drifted", cycle, got)
|
||||||
|
}
|
||||||
|
setFreq(14074000) // poll catches up to the restored dial
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once a poll confirms the commanded dial, the echo is released (in the field
|
||||||
|
// the client polls continuously, so this happens within a cycle).
|
||||||
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
||||||
|
t.Fatalf("confirming get_freq = %q, want 14074000", got)
|
||||||
|
}
|
||||||
|
// A genuine knob turn now surfaces at once.
|
||||||
|
setFreq(14075000)
|
||||||
|
if got, _ := s.handle("f"); got != "14075000\n" {
|
||||||
|
t.Errorf("manual QSY get_freq = %q, want 14075000 — echo hid a real move", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
||||||
// line as the protocol version and refuses to continue if the block is short or
|
// line as the protocol version and refuses to continue if the block is short or
|
||||||
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.23.4"
|
appVersion = "0.23.5"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user