From 1f74e4d2345a15a1b9f27eee3120f925dd2a3268 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 9 Jul 2026 17:32:13 +0200 Subject: [PATCH] chore: release v0.19.3 --- app.go | 68 ++++++++++++ frontend/src/App.tsx | 42 ++++++- frontend/src/lib/i18n.tsx | 2 + frontend/src/version.ts | 2 +- frontend/wailsjs/go/main/App.d.ts | 5 + frontend/wailsjs/go/main/App.js | 8 ++ frontend/wailsjs/go/models.ts | 60 ++++++++++ internal/extsvc/eqsl.go | 76 ++++++++++++- internal/solar/solar.go | 178 ++++++++++++++++++++++++++++++ internal/solar/solar_test.go | 48 ++++++++ telemetry.go | 2 +- 11 files changed, 482 insertions(+), 9 deletions(-) create mode 100644 internal/solar/solar.go create mode 100644 internal/solar/solar_test.go diff --git a/app.go b/app.go index 334edba..9d2ea6a 100644 --- a/app.go +++ b/app.go @@ -47,6 +47,7 @@ import ( "hamlog/internal/qso" "hamlog/internal/rotator/pst" "hamlog/internal/settings" + "hamlog/internal/solar" "hamlog/internal/ultrabeam" "hamlog/internal/winkeyer" @@ -420,6 +421,7 @@ type App struct { pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) + solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping // NET Control: persistent net definitions/rosters (global JSON) + the live // session (in-memory only — active stations currently in QSO). @@ -874,6 +876,16 @@ func (a *App) startup(ctx context.Context) { }, ) + // Live space-weather (solar flux, sunspots, A/K indices) for the header strip + // and per-QSO stamping. Refreshes in the background; pushes a UI event on each + // update so the strip re-reads. + a.solar = solar.NewManager(func() { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "solar:update") + } + }) + a.solar.Start() + // Digital Voice Keyer + QSO recorder (WASAPI). Idle until used. a.audioMgr = audio.NewManager(func() { st := a.dvkStatus() @@ -1116,6 +1128,9 @@ func (a *App) shutdown(ctx context.Context) { if a.udp != nil { a.udp.StopAll() } + if a.solar != nil { + a.solar.Stop() + } // Stop CAT so the backend disconnects cleanly. Critical for the Icom network // backend: without this the rig never gets a disconnect and holds its single // control session for minutes, refusing every new login (even from the Icom @@ -1643,6 +1658,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries a.applyQSLDefaults(&q) + a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather // Fill the contacted operator's e-mail from the (cached) lookup so the // recording can be auto-sent. Cheap: the entry already looked the call up. if strings.TrimSpace(q.Email) == "" && a.lookup != nil { @@ -1666,6 +1682,58 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { return id, err } +// applySolar stamps the current space-weather onto a QSO before it's saved: the +// standard ADIF SFI / A_INDEX / K_INDEX fields (only when the QSO doesn't already +// carry them), and the sunspot number as an APP_OPSLOG_SSN extra (SSN has no +// standard ADIF field). No-op when the feed hasn't loaded yet. +func (a *App) applySolar(q *qso.QSO) { + if a.solar == nil { + return + } + d := a.solar.Get() + if !d.OK { + return + } + if q.SFI == nil { + if v, err := strconv.ParseFloat(strings.TrimSpace(d.SFI), 64); err == nil { + q.SFI = &v + } + } + if q.AIndex == nil { + if v, err := strconv.ParseFloat(strings.TrimSpace(d.AIndex), 64); err == nil { + q.AIndex = &v + } + } + if q.KIndex == nil { + if v, err := strconv.ParseFloat(strings.TrimSpace(d.KIndex), 64); err == nil { + q.KIndex = &v + } + } + if ssn := strings.TrimSpace(d.SSN); ssn != "" { + if q.Extras == nil { + q.Extras = map[string]string{} + } + if _, ok := q.Extras["APP_OPSLOG_SSN"]; !ok { + q.Extras["APP_OPSLOG_SSN"] = ssn + } + } +} + +// GetSolarData returns the latest space-weather snapshot for the header strip. +func (a *App) GetSolarData() solar.Data { + if a.solar == nil { + return solar.Data{} + } + return a.solar.Get() +} + +// RefreshSolar forces an immediate re-fetch of the space-weather feed. +func (a *App) RefreshSolar() { + if a.solar != nil { + go a.solar.Refresh() + } +} + // StationInfoComputed bundles the data we resolve live from the // profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone, // lat/lon. Used by the Settings UI to show the "what will be stamped on diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a94c10..576cefd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import { ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ListClusterServers, ClusterSpotStatuses, SendClusterSpot, GetCATSettings, + GetSolarData, OperatingDefaultForBand, LogUDPLoggedADIF, ListCountries, @@ -371,6 +372,17 @@ export default function App() { // hide the rig ON/OFF buttons on USB, where the interface is unpowered when the // rig is off so power-ON can't work). const [catBackend, setCatBackend] = useState(''); + // Live space-weather (solar flux / sunspots / A / K) for the header strip. + // Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow + // fallback poll. These same numbers are stamped onto each logged QSO. + const [solar, setSolar] = useState(null); + useEffect(() => { + const load = () => { GetSolarData().then((d) => setSolar(d ?? null)).catch(() => {}); }; + load(); + const off = EventsOn('solar:update', load); + const id = window.setInterval(load, 60 * 60 * 1000); // hourly fallback; the event refreshes it live + return () => { off(); window.clearInterval(id); }; + }, []); const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false }); const [agStatus, setAgStatus] = useState({ connected: false, port_a: 0, port_b: 0, antennas: [] }); @@ -3222,7 +3234,7 @@ export default function App() { ) : ( -
+
@@ -3445,6 +3457,34 @@ export default function App() { )}
+ {/* Space-weather / propagation — compact, in the header. Live from N0NBH + (hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped + onto each logged QSO. Always renders one element so the grid columns + stay aligned (empty span until the feed loads). */} + {solar && solar.ok ? ( +
+ {(() => { + const geo = String(solar.geomag_field || '').toUpperCase(); + const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger' + : /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success'; + const it = (label: string, val: any, cls = 'text-foreground') => ( + + {label} + {(val ?? '') === '' ? '—' : val} + + ); + return (<> + {it('SFI', solar.sfi)} + {it('SSN', solar.ssn)} + {it('A', solar.a_index)} + {it('K', solar.k_index)} + {geo ? {geo} : null} + ); + })()} +
+ ) : } +
{utcNow}UTC diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 6958a70..7f4b5d9 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -13,6 +13,7 @@ type Dict = Record; const en: Dict = { // Menu bar + 'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather', 'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools', 'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…', 'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit', @@ -223,6 +224,7 @@ const en: Dict = { }; const fr: Dict = { + 'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale', 'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils', 'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…', 'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter', diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 9c54817..bd17e89 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,6 +1,6 @@ // 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). -export const APP_VERSION = '0.19.2'; +export const APP_VERSION = '0.19.3'; // Author / credits, shown in Help -> About. export const APP_AUTHOR = 'F4BPO'; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index b2a1118..f9ce496 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -11,6 +11,7 @@ import {awardref} from '../models'; import {cluster} from '../models'; import {extsvc} from '../models'; import {powergenius} from '../models'; +import {solar} from '../models'; import {winkeyer} from '../models'; import {alerts} from '../models'; import {audio} from '../models'; @@ -344,6 +345,8 @@ export function GetRotatorSettings():Promise; export function GetSecretStatus():Promise; +export function GetSolarData():Promise; + export function GetStartupStatus():Promise; export function GetStationSettings():Promise; @@ -592,6 +595,8 @@ export function QuitApp():Promise; export function RefreshCtyDat():Promise; +export function RefreshSolar():Promise; + export function ReloadUDPIntegrations():Promise>; export function RemovePassphrase(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index cff577d..04f98d3 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -650,6 +650,10 @@ export function GetSecretStatus() { return window['go']['main']['App']['GetSecretStatus'](); } +export function GetSolarData() { + return window['go']['main']['App']['GetSolarData'](); +} + export function GetStartupStatus() { return window['go']['main']['App']['GetStartupStatus'](); } @@ -1146,6 +1150,10 @@ export function RefreshCtyDat() { return window['go']['main']['App']['RefreshCtyDat'](); } +export function RefreshSolar() { + return window['go']['main']['App']['RefreshSolar'](); +} + export function ReloadUDPIntegrations() { return window['go']['main']['App']['ReloadUDPIntegrations'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 2ef98b3..e2f3aea 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -3298,6 +3298,66 @@ export namespace qso { } +export namespace solar { + + export class Data { + sfi: string; + ssn: string; + a_index: string; + k_index: string; + xray: string; + geomag_field: string; + aurora: string; + muf: string; + updated: string; + source: string; + ok: boolean; + error?: string; + // Go type: time + fetched_at: any; + + static createFrom(source: any = {}) { + return new Data(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.sfi = source["sfi"]; + this.ssn = source["ssn"]; + this.a_index = source["a_index"]; + this.k_index = source["k_index"]; + this.xray = source["xray"]; + this.geomag_field = source["geomag_field"]; + this.aurora = source["aurora"]; + this.muf = source["muf"]; + this.updated = source["updated"]; + this.source = source["source"]; + this.ok = source["ok"]; + this.error = source["error"]; + this.fetched_at = this.convertValues(source["fetched_at"], null); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace udp { export class Config { diff --git a/internal/extsvc/eqsl.go b/internal/extsvc/eqsl.go index f4272d3..ef84d9e 100644 --- a/internal/extsvc/eqsl.go +++ b/internal/extsvc/eqsl.go @@ -61,9 +61,12 @@ const ( eqslBaseURL = "https://www.eQSL.cc" ) -// eqslAdiHrefRe pulls the generated .adi path out of the DownloadInBox reply, -// e.g. ``. -var eqslAdiHrefRe = regexp.MustCompile(`(?i)href="(/[^"]+\.adi)"`) +// eqslAdiHrefRe pulls the generated .adi link out of the DownloadInBox reply. +// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes, +// e.g. `` or ``, and the +// path may be root-relative or a bare filename. Match case-insensitively with +// optional quoting; resolve to an absolute URL afterwards. +var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.adi)`) // DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs = // confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since @@ -97,18 +100,79 @@ func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg Ser } m := eqslAdiHrefRe.FindStringSubmatch(page) if m == nil { - // eQSL reports problems inline ("Error: …", or "no QSLs" wording). - return "", fmt.Errorf("eqsl: no download link in response: %s", eqslReason(page)) + // eQSL reports problems inline ("Error: …", "You have no … confirmations"). + return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page)) + } + + // Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so + // the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\ + // xxx.adi`); normalise those. It may also be absolute or a bare filename. + link := strings.TrimSpace(m[1]) + link = strings.ReplaceAll(link, `\`, "/") + link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…" + var fileURL string + switch { + case strings.HasPrefix(strings.ToLower(link), "http"): + fileURL = link + case strings.HasPrefix(link, "/"): + fileURL = eqslBaseURL + link + default: + fileURL = eqslBaseURL + "/" + link } // Step 2: fetch the generated .adi file. - adif, err := eqslGet(ctx, client, eqslBaseURL+m[1]) + adif, err := eqslGet(ctx, client, fileURL) if err != nil { return "", fmt.Errorf("eqsl: fetch adif: %w", err) } return adif, nil } +// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's +// own error/notice text if present, else a short tag-stripped snippet of the page +// so the real wording (e.g. "You have no Inbox items") is visible in the log. +func eqslNoLinkReason(page string) string { + low := strings.ToLower(page) + for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} { + if i := strings.Index(low, marker); i >= 0 { + seg := page[i:] + if len(seg) > 160 { + seg = seg[:160] + } + return strings.Join(strings.Fields(stripTags(seg)), " ") + } + } + txt := strings.Join(strings.Fields(stripTags(page)), " ") + if len(txt) > 200 { + txt = txt[:200] + } + if txt == "" { + txt = "empty response" + } + return txt +} + +// stripTags removes HTML tags for a readable one-line diagnostic. +func stripTags(s string) string { + var b strings.Builder + depth := 0 + for _, r := range s { + switch r { + case '<': + depth++ + case '>': + if depth > 0 { + depth-- + } + default: + if depth == 0 { + b.WriteRune(r) + } + } + } + return b.String() +} + // eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on // a non-200 status. func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) { diff --git a/internal/solar/solar.go b/internal/solar/solar.go new file mode 100644 index 0000000..e5f93e7 --- /dev/null +++ b/internal/solar/solar.go @@ -0,0 +1,178 @@ +// Package solar fetches live space-weather / propagation numbers (solar flux, +// sunspot number, A/K indices, X-ray, geomagnetic field) from N0NBH's public +// hamqsl.com XML feed, caches them, and refreshes periodically. OpsLog shows the +// numbers in a header strip and stamps SFI / A_INDEX / K_INDEX onto each logged +// QSO (SSN has no standard ADIF field — the app stores it as an extra). +package solar + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// feedURL is N0NBH's solar data as XML. Free, no key. +const feedURL = "https://www.hamqsl.com/solarxml.php" + +// Data is the parsed snapshot exposed to the UI (strings — some fields read +// "No Report" or carry units, so we keep them verbatim and parse to numbers only +// when stamping a QSO). +type Data struct { + SFI string `json:"sfi"` // solar flux index + SSN string `json:"ssn"` // sunspot number + AIndex string `json:"a_index"` // geomagnetic A index + KIndex string `json:"k_index"` // geomagnetic K index + XRay string `json:"xray"` // X-ray flux class (e.g. C1.5) + GeomagField string `json:"geomag_field"` // QUIET / UNSETTLED / STORM… + Aurora string `json:"aurora"` + MUF string `json:"muf"` + Updated string `json:"updated"` // source timestamp text + Source string `json:"source"` // e.g. N0NBH + OK bool `json:"ok"` // last fetch succeeded + Error string `json:"error,omitempty"` + FetchedAt time.Time `json:"fetched_at"` +} + +// xmlSolar mirrors the hamqsl solarxml.php document. +type xmlSolar struct { + XMLName xml.Name `xml:"solar"` + SolarData struct { + Source string `xml:"source"` + Updated string `xml:"updated"` + SolarFlux string `xml:"solarflux"` + AIndex string `xml:"aindex"` + KIndex string `xml:"kindex"` + XRay string `xml:"xray"` + Sunspots string `xml:"sunspots"` + Aurora string `xml:"aurora"` + GeomagField string `xml:"geomagfield"` + MUF string `xml:"muf"` + } `xml:"solardata"` +} + +// Fetch performs one GET + parse of the feed. +func Fetch(ctx context.Context, client *http.Client) (Data, error) { + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) + if err != nil { + return Data{}, err + } + req.Header.Set("User-Agent", "OpsLog") + resp, err := client.Do(req) + if err != nil { + return Data{}, fmt.Errorf("solar: request failed: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return Data{}, fmt.Errorf("solar: read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return Data{}, fmt.Errorf("solar: http %d", resp.StatusCode) + } + return parseSolar(body) +} + +// parseSolar turns the feed's XML bytes into a Data (split out so it can be +// unit-tested without a network call). +func parseSolar(body []byte) (Data, error) { + var x xmlSolar + if err := xml.Unmarshal(body, &x); err != nil { + return Data{}, fmt.Errorf("solar: parse: %w", err) + } + s := x.SolarData + return Data{ + SFI: strings.TrimSpace(s.SolarFlux), + SSN: strings.TrimSpace(s.Sunspots), + AIndex: strings.TrimSpace(s.AIndex), + KIndex: strings.TrimSpace(s.KIndex), + XRay: strings.TrimSpace(s.XRay), + GeomagField: strings.TrimSpace(s.GeomagField), + Aurora: strings.TrimSpace(s.Aurora), + MUF: strings.TrimSpace(s.MUF), + Updated: strings.TrimSpace(s.Updated), + Source: strings.TrimSpace(s.Source), + OK: true, + FetchedAt: time.Now(), + }, nil +} + +// Manager caches the latest Data and refreshes it on a timer. +type Manager struct { + mu sync.RWMutex + data Data + client *http.Client + onChange func() + stop chan struct{} + once sync.Once +} + +// NewManager builds a manager. onChange (optional) fires after each refresh so +// the host can push a UI event. +func NewManager(onChange func()) *Manager { + return &Manager{ + client: &http.Client{Timeout: 20 * time.Second}, + onChange: onChange, + stop: make(chan struct{}), + } +} + +// Start fetches now and every hour until Stop (the feed updates roughly hourly). +func (m *Manager) Start() { + go func() { + m.refresh() + t := time.NewTicker(1 * time.Hour) + defer t.Stop() + for { + select { + case <-m.stop: + return + case <-t.C: + m.refresh() + } + } + }() +} + +// Stop ends the refresh loop. +func (m *Manager) Stop() { + if m == nil { + return + } + m.once.Do(func() { close(m.stop) }) +} + +// Get returns the latest snapshot. +func (m *Manager) Get() Data { + if m == nil { + return Data{} + } + m.mu.RLock() + defer m.mu.RUnlock() + return m.data +} + +// Refresh forces an immediate fetch (used by a manual "refresh" binding). +func (m *Manager) Refresh() { m.refresh() } + +func (m *Manager) refresh() { + d, err := Fetch(context.Background(), m.client) + m.mu.Lock() + if err != nil { + m.data.OK = false + m.data.Error = err.Error() + } else { + m.data = d + } + m.mu.Unlock() + if m.onChange != nil { + m.onChange() + } +} diff --git a/internal/solar/solar_test.go b/internal/solar/solar_test.go new file mode 100644 index 0000000..15e598f --- /dev/null +++ b/internal/solar/solar_test.go @@ -0,0 +1,48 @@ +package solar + +import "testing" + +// A trimmed sample of the real N0NBH hamqsl.com solarxml.php document. +const sampleXML = ` + + +N0NBH +08 Jul 2025 2100 GMT +170 +5 +2 +C1.5 +150 +QUIET +18.5 + +` + +func TestParseSolar(t *testing.T) { + d, err := parseSolar([]byte(sampleXML)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !d.OK { + t.Fatalf("expected OK") + } + checks := map[string]string{ + "SFI": d.SFI, "SSN": d.SSN, "A": d.AIndex, "K": d.KIndex, + "XRay": d.XRay, "Geomag": d.GeomagField, "Source": d.Source, + } + want := map[string]string{ + "SFI": "170", "SSN": "150", "A": "5", "K": "2", + "XRay": "C1.5", "Geomag": "QUIET", "Source": "N0NBH", + } + for k, got := range checks { + if got != want[k] { + t.Errorf("%s = %q, want %q", k, got, want[k]) + } + } +} + +func TestParseSolarBadXML(t *testing.T) { + if _, err := parseSolar([]byte("not xml")); err == nil { + t.Fatalf("expected an error for non-XML input") + } +} diff --git a/telemetry.go b/telemetry.go index e9a0faa..73f62ed 100644 --- a/telemetry.go +++ b/telemetry.go @@ -21,7 +21,7 @@ import ( const ( // appVersion is stamped on every heartbeat (and could feed the About box). - appVersion = "0.19.2" + appVersion = "0.19.3" // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // to https://us.i.posthog.com for a US project.