2 Commits
Author SHA1 Message Date
rouggy 7398261c50 chore: release v0.19.5 2026-07-11 10:35:54 +02:00
rouggy 73f3ec51f7 fix: Flex with multiple slices opened was always showing slice A s-meter. 2026-07-10 23:13:06 +02:00
12 changed files with 194 additions and 17 deletions
+50 -6
View File
@@ -815,6 +815,12 @@ func (a *App) startup(ctx context.Context) {
// CAT manager: emit pushes state to the frontend via Wails events, and // CAT manager: emit pushes state to the frontend via Wails events, and
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM). // forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
a.cat = cat.NewManager(func(s cat.RigState) { a.cat = cat.NewManager(func(s cat.RigState) {
// DIAGNOSTIC: the manager only fires this on a USER-relevant change, so a
// burst of these lines = the frontend is being re-rendered rapidly (the
// "screen flickers" symptom). Shows WHAT is churning — connection flap,
// or freq/split/mode oscillating between slices during FT8.
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s",
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band)
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cat:state", s) wruntime.EventsEmit(a.ctx, "cat:state", s)
} }
@@ -7870,6 +7876,12 @@ func (a *App) consumeUDPEvents() {
if a.udp == nil { if a.udp == nil {
return return
} }
// Rate-limit decode spots: an FT8 opening decodes the SAME stations every
// cycle, so spot each call at most once per window (well within its display
// lifetime) instead of blasting the radio with a burst of adds+removes every
// 15 s. Single-goroutine loop → the map needs no lock.
const decodeSpotWindow = 20 * time.Second
lastDecodeSpot := map[string]time.Time{}
for ev := range a.udp.Events() { for ev := range a.udp.Events() {
if a.ctx == nil { if a.ctx == nil {
continue continue
@@ -7880,6 +7892,14 @@ func (a *App) consumeUDPEvents() {
// panadapter when the option is on; green + SNR comment, auto-expiring // panadapter when the option is on; green + SNR comment, auto-expiring
// after the configured duration. De-duped per call in the Flex backend. // after the configured duration. De-duped per call in the Flex backend.
if a.catFlexDecodeSpots && a.cat != nil { if a.catFlexDecodeSpots && a.cat != nil {
now := time.Now()
if t, seen := lastDecodeSpot[ev.DecodeCall]; seen && now.Sub(t) < decodeSpotWindow {
continue // spotted this call very recently — don't re-hammer the radio
}
if len(lastDecodeSpot) > 4000 {
lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions
}
lastDecodeSpot[ev.DecodeCall] = now
secs := a.catFlexDecodeSecs secs := a.catFlexDecodeSecs
if secs <= 0 { if secs <= 0 {
secs = 120 secs = 120
@@ -8203,6 +8223,7 @@ func (a *App) SetCATFrequency(hz int64) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
} }
applog.Printf("cat: SetCATFrequency %.3f MHz — deliberate frontend set (spot click / band / memory / entry)", float64(hz)/1e6)
err := a.cat.SetFrequency(hz) err := a.cat.SetFrequency(hz)
if err != nil { if err != nil {
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err) applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
@@ -8226,6 +8247,7 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
if err != nil || !s.Enabled || !s.Follow { if err != nil || !s.Enabled || !s.Follow {
return return
} }
applog.Printf("ultrabeam: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6)
step := s.StepKHz step := s.StepKHz
if step <= 0 { if step <= 0 {
step = 50 step = 50
@@ -8241,17 +8263,22 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
} }
} }
khz := int(freqHz / 1000) khz := int(freqHz / 1000)
diff := khz - st.Frequency ref := st.Frequency
if ref <= 0 {
ref = c.LastSetKHz()
}
diff := khz - ref
if diff < 0 { if diff < 0 {
diff = -diff diff = -diff
} }
if st.Frequency > 0 && diff < step { if ref > 0 && diff < step {
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
return // within the deadband — don't chase a tiny QSY return // within the deadband — don't chase a tiny QSY
} }
if err := c.SetFrequency(khz, st.Direction); err != nil { if err := c.SetFrequency(khz, st.Direction); err != nil {
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err) applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
} else { } else {
applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d)", khz, st.Direction) applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d, was ref %d kHz)", khz, st.Direction, ref)
} }
} }
@@ -9594,6 +9621,7 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
} }
ticker := time.NewTicker(1500 * time.Millisecond) ticker := time.NewTicker(1500 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
lastRigKHz := 0 // only log when the followed rig frequency actually changes
for { for {
select { select {
case <-stop: case <-stop:
@@ -9611,6 +9639,15 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
continue continue
} }
rigKHz := int(rs.FreqHz / 1000) rigKHz := int(rs.FreqHz / 1000)
// Log the moment the poll sees a NEW rig frequency — this is what the
// follow loop chases. If the antenna QSYs unexpectedly, this shows the
// CAT backend started reporting that frequency (e.g. WSJT-X moved the
// dial, or the active slice changed) even though you didn't touch the VFO.
if rigKHz != lastRigKHz {
applog.Printf("ultrabeam: follow loop reads rig freq %.3f MHz (mode %s) — antenna at %d kHz, step %d kHz",
float64(rs.FreqHz)/1e6, rs.Mode, st.Frequency, stepKHz)
lastRigKHz = rigKHz
}
// Skip frequencies outside the antenna's tunable range (other band). // Skip frequencies outside the antenna's tunable range (other band).
if st.FreqMin > 0 && st.FreqMax > 0 { if st.FreqMin > 0 && st.FreqMax > 0 {
rigMHz := rs.FreqHz / 1_000_000 rigMHz := rs.FreqHz / 1_000_000
@@ -9618,17 +9655,24 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
continue continue
} }
} }
diff := rigKHz - st.Frequency // Deadband reference = the antenna's reported freq, or (when it hasn't
// reported one yet) the last freq we commanded — so a 0/blank status
// doesn't bypass the deadband and re-tune on every small QSY.
ref := st.Frequency
if ref <= 0 {
ref = c.LastSetKHz()
}
diff := rigKHz - ref
if diff < 0 { if diff < 0 {
diff = -diff diff = -diff
} }
if st.Frequency > 0 && diff < stepKHz { if ref > 0 && diff < stepKHz {
continue // within the deadband — leave the motors alone continue // within the deadband — leave the motors alone
} }
if err := c.SetFrequency(rigKHz, st.Direction); err != nil { if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err) applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
} else { } else {
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d)", rigKHz, st.Direction) applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, step %d)", rigKHz, st.Direction, ref, stepKHz)
} }
} }
} }
+10 -2
View File
@@ -37,7 +37,7 @@ type FlexState = {
}; };
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean }; type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number }; type Meter = { id: number; src?: string; name?: string; unit?: string; slice?: number; value: number; lo: number; hi: number };
const ZERO: FlexState = { const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
@@ -356,7 +356,15 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
// Radio meters (exclude the amplifier's, which we show separately). // Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) => const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP')); (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL'); // Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the
// one for the ACTIVE slice; otherwise we'd always show slice A's level.
const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1;
const sliceMeter = (name: string) => {
const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP'));
if (m.length === 0) return undefined;
return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0];
};
const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL');
const fwd = radio('FWDPWR'); const fwd = radio('FWDPWR');
const swr = radio('SWR'); const swr = radio('SWR');
// Mic input level + speech-compression (voltage & PA temp live in the // Mic input level + speech-compression (voltage & PA temp live in the
@@ -321,7 +321,6 @@ function EditDialog({
<Label>{t('udpp.name')}</Label> <Label>{t('udpp.name')}</Label>
<Input <Input
autoFocus autoFocus
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
value={draft.name} value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))} onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
/> />
+1 -1
View File
@@ -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.19.4'; export const APP_VERSION = '0.19.5';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -504,6 +504,7 @@ export namespace cat {
src?: string; src?: string;
name?: string; name?: string;
unit?: string; unit?: string;
slice: number;
value: number; value: number;
lo: number; lo: number;
hi: number; hi: number;
@@ -518,6 +519,7 @@ export namespace cat {
this.src = source["src"]; this.src = source["src"];
this.name = source["name"]; this.name = source["name"];
this.unit = source["unit"]; this.unit = source["unit"];
this.slice = source["slice"];
this.value = source["value"]; this.value = source["value"];
this.lo = source["lo"]; this.lo = source["lo"];
this.hi = source["hi"]; this.hi = source["hi"];
+1
View File
@@ -330,6 +330,7 @@ type FlexMeter struct {
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP… Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP… Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
Value float64 `json:"value"` Value float64 `json:"value"`
Lo float64 `json:"lo"` Lo float64 `json:"lo"`
Hi float64 `json:"hi"` Hi float64 `json:"hi"`
+24 -6
View File
@@ -140,6 +140,7 @@ type meterInfo struct {
src string // SLC (slice), TX-, COD, RAD, AMP… src string // SLC (slice), TX-, COD, RAD, AMP…
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B… name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
unit string // dbm, dbfs, swr, volts, degc, watts… unit string // dbm, dbfs, swr, volts, degc, watts…
slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
lo float64 lo float64
hi float64 hi float64
} }
@@ -641,7 +642,7 @@ func (f *Flex) handleStatus(payload string) {
// One meter per token; its fields are '#'-separated: // One meter per token; its fields are '#'-separated:
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…". // "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
num := -1 num := -1
var mi meterInfo mi := meterInfo{slc: -1}
for _, sub := range strings.Split(tok, "#") { for _, sub := range strings.Split(tok, "#") {
key, val, ok := splitKV(sub) key, val, ok := splitKV(sub)
if !ok { if !ok {
@@ -661,6 +662,12 @@ func (f *Flex) handleStatus(payload string) {
mi.src = val mi.src = val
case "nam": case "nam":
mi.name = val mi.name = val
case "num":
// For a slice (SLC) meter this field is the slice index —
// how we tell slice A's S-meter from slice B's.
if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil {
mi.slc = v
}
case "unit", "units": case "unit", "units":
mi.unit = val mi.unit = val
case "low", "lo": case "low", "lo":
@@ -675,6 +682,7 @@ func (f *Flex) handleStatus(payload string) {
old, seen := f.meterMeta[num] old, seen := f.meterMeta[num]
if !seen { if !seen {
newIDs = append(newIDs, num) newIDs = append(newIDs, num)
old.slc = -1
} }
if mi.src != "" { if mi.src != "" {
old.src = mi.src old.src = mi.src
@@ -685,6 +693,9 @@ func (f *Flex) handleStatus(payload string) {
if mi.unit != "" { if mi.unit != "" {
old.unit = mi.unit old.unit = mi.unit
} }
if mi.slc >= 0 {
old.slc = mi.slc
}
if mi.lo != 0 { if mi.lo != 0 {
old.lo = mi.lo old.lo = mi.lo
} }
@@ -1079,15 +1090,22 @@ func (f *Flex) SendSpot(s SpotInfo) error {
} }
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already // De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
// spotted gets its previous spot removed first — one live spot per call, // spotted gets its previous spot removed first — one live spot per call,
// refreshed, instead of a pile that all expire independently. // refreshed, instead of a pile that all expire independently. NB: capture the
// old index under the lock but send OUTSIDE it — f.send() takes f.mu itself,
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
// whole Flex goroutine → the radio drops OFFLINE).
upperCall := strings.ToUpper(s.Callsign)
f.mu.Lock() f.mu.Lock()
if old, ok := f.spotByCall[strings.ToUpper(s.Callsign)]; ok { old, hadOld := f.spotByCall[upperCall]
delete(f.spotByCall, strings.ToUpper(s.Callsign)) if hadOld {
delete(f.spotByCall, upperCall)
delete(f.spotCall, old) delete(f.spotCall, old)
delete(f.spotIdx, old) delete(f.spotIdx, old)
f.send(fmt.Sprintf("spot remove %d", old))
} }
f.mu.Unlock() f.mu.Unlock()
if hadOld {
f.send(fmt.Sprintf("spot remove %d", old))
}
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d", cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix()) float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
if m := flexEncode(s.Mode); m != "" { if m := flexEncode(s.Mode); m != "" {
@@ -1321,7 +1339,7 @@ func (f *Flex) FlexState() FlexTXState {
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
for _, id := range ids { for _, id := range ids {
mi := f.meterMeta[id] mi := f.meterMeta[id]
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi}) st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
} }
} }
return st return st
+25
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"log" "log"
"net" "net"
"runtime"
"sync" "sync"
"time" "time"
) )
@@ -75,6 +76,20 @@ type Client struct {
pendingDir int pendingDir int
pendingDirAt time.Time pendingDirAt time.Time
pendingDirSet bool pendingDirSet bool
// lastSetKHz is the frequency we last COMMANDED. Used as the follow-loop
// deadband reference when the antenna's own status hasn't reported a frequency
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
// re-tunes the motors.
lastSetKHz int
}
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
// or 0 if none yet.
func (c *Client) LastSetKHz() int {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.lastSetKHz
} }
type Status struct { type Status struct {
@@ -457,6 +472,15 @@ func (c *Client) queryProgress() ([]int, error) {
// SetFrequency changes frequency and optional direction (command 3) // SetFrequency changes frequency and optional direction (command 3)
func (c *Client) SetFrequency(freqKhz int, direction int) error { func (c *Client) SetFrequency(freqKhz int, direction int) error {
// Trace WHO asked for the change — the caller's function + line — so an
// unexpected antenna QSY (e.g. jumping to 14.074 while on 40m) can be traced
// to the follow loop, an immediate re-tune, or a direction re-issue.
caller := "?"
if pc, _, line, ok := runtime.Caller(1); ok {
caller = fmt.Sprintf("%s:%d", runtime.FuncForPC(pc).Name(), line)
}
log.Printf("Ultrabeam: SetFrequency(%d kHz, dir %d) ← %s", freqKhz, direction, caller)
data := []byte{ data := []byte{
byte(freqKhz & 0xFF), byte(freqKhz & 0xFF),
byte((freqKhz >> 8) & 0xFF), byte((freqKhz >> 8) & 0xFF),
@@ -467,6 +491,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
if err == nil { if err == nil {
c.statusMu.Lock() c.statusMu.Lock()
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.lastSetKHz = freqKhz
if c.lastStatus != nil { if c.lastStatus != nil {
c.lastStatus.Direction = direction // reflect immediately c.lastStatus.Direction = direction // reflect immediately
} }
+9
View File
@@ -36,6 +36,15 @@ func profileArg(args []string) string {
} }
func main() { func main() {
// Single-instance guard: if OpsLog is already running, focus that window and
// exit instead of spawning a duplicate. A second process would open its own
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
// its own" when a windowless zombie instance was left running.
if !acquireSingleInstance() {
return
}
// Create an instance of the app structure // Create an instance of the app structure
app := NewApp() app := NewApp()
app.startupProfile = profileArg(os.Args[1:]) app.startupProfile = profileArg(os.Args[1:])
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package main
// acquireSingleInstance is a no-op off Windows (the single-instance guard uses a
// Windows named mutex). Always allows the app to start.
func acquireSingleInstance() bool { return true }
+64
View File
@@ -0,0 +1,64 @@
//go:build windows
package main
import (
"errors"
"unsafe"
"golang.org/x/sys/windows"
)
// singleInstanceName is a per-session named mutex. The Windows kernel releases
// it automatically when the owning process dies (even on a crash), so a
// lingering/zombie OpsLog can't permanently block future launches — killing it
// frees the name at once. Session-local (no "Global\\") = one instance per
// logged-in desktop, which is what we want.
const singleInstanceName = "OpsLog-SingleInstance-Mutex"
// acquireSingleInstance creates the named mutex. Returns ok=false when another
// OpsLog already holds it (this instance should exit); on the way out it brings
// the existing window to the front so a double-click just refocuses OpsLog
// instead of spawning a duplicate that fights over the CAT / antenna.
//
// The mutex handle is deliberately never closed — it must live for the whole
// process lifetime; the OS reclaims it on exit.
func acquireSingleInstance() (ok bool) {
namePtr, err := windows.UTF16PtrFromString(singleInstanceName)
if err != nil {
return true // never block launch on an unexpected error
}
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
createMutex := kernel32.NewProc("CreateMutexW")
// CreateMutexW(lpSecurityAttributes=NULL, bInitialOwner=FALSE, lpName)
h, _, callErr := createMutex.Call(0, 0, uintptr(unsafe.Pointer(namePtr)))
if h == 0 {
return true // couldn't create the mutex → don't block the app
}
if errors.Is(callErr, windows.ERROR_ALREADY_EXISTS) {
focusExistingWindow()
return false
}
return true
}
// focusExistingWindow finds the running OpsLog window by its title and restores
// + foregrounds it. Best-effort; failures are silently ignored.
func focusExistingWindow() {
user32 := windows.NewLazySystemDLL("user32.dll")
findWindow := user32.NewProc("FindWindowW")
setForeground := user32.NewProc("SetForegroundWindow")
showWindow := user32.NewProc("ShowWindow")
title, err := windows.UTF16PtrFromString("OpsLog")
if err != nil {
return
}
hwnd, _, _ := findWindow.Call(0, uintptr(unsafe.Pointer(title)))
if hwnd == 0 {
return
}
const swRestore = 9 // SW_RESTORE — un-minimise if needed
showWindow.Call(hwnd, swRestore)
setForeground.Call(hwnd)
}
+1 -1
View File
@@ -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.19.4" appVersion = "0.19.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.