From a00817b93ed19be71afeac759bcd402dda76a77e Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 12 Jul 2026 18:01:03 +0200 Subject: [PATCH] fix: while connected to MySQL if internet was lost no qso would be logged anymore --- app.go | 39 ++++++ frontend/src/App.tsx | 86 +++++++++++- frontend/src/lib/i18n.tsx | 16 +++ frontend/wailsjs/go/main/App.d.ts | 6 + frontend/wailsjs/go/main/App.js | 12 ++ frontend/wailsjs/go/models.ts | 16 +++ internal/adif/export.go | 12 ++ internal/db/connloss.go | 66 ++++++++++ internal/extsvc/clublog.go | 67 +++++++++- internal/extsvc/lotw.go | 11 ++ internal/offlineq/offlineq.go | 202 +++++++++++++++++++++++++++++ internal/offlineq/offlineq_test.go | 93 +++++++++++++ internal/qso/qso.go | 26 ++++ offline.go | 191 +++++++++++++++++++++++++++ singleinstance_other.go | 8 +- singleinstance_windows.go | 8 +- 16 files changed, 850 insertions(+), 9 deletions(-) create mode 100644 internal/db/connloss.go create mode 100644 internal/offlineq/offlineq.go create mode 100644 internal/offlineq/offlineq_test.go create mode 100644 offline.go diff --git a/app.go b/app.go index 4bb0ce6..f668702 100644 --- a/app.go +++ b/app.go @@ -42,6 +42,7 @@ import ( "hamlog/internal/operating" "hamlog/internal/pota" "hamlog/internal/lotwusers" + "hamlog/internal/offlineq" "hamlog/internal/powergenius" "hamlog/internal/profile" "hamlog/internal/qslcard" @@ -456,6 +457,9 @@ type App struct { logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite + offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable + offlineMode bool // last write failed because the DB was unreachable + catFlexSpots bool // push cluster spots to the FlexRadio panadapter catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter catFlexDecodeSecs int // decode spot display duration (seconds) @@ -772,6 +776,25 @@ func (a *App) startup(ctx context.Context) { } fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities") }() + // Offline safety net: with the shared MySQL logbook, losing the network would + // otherwise mean you simply cannot log. Instead, a QSO that can't reach the DB + // is parked in a local ADIF outbox and replayed automatically once the DB + // answers again. Anything already waiting from a previous session (a crash, a + // quit while offline) is replayed at startup. + a.offlineQ = newOfflineQueue(dataDir) + if n := a.offlineQ.Count(); n > 0 { + a.offlineMode = true + applog.Printf("offline: %d QSO(s) waiting in %s", n, a.offlineQ.Path()) + } + go a.offlineReplayLoop() + go func() { + if a.offlineQ.Count() > 0 { + if n, err := a.replayOfflineQueue(); err == nil && n > 0 { + applog.Printf("offline: replayed %d QSO(s) left over from a previous session", n) + } + } + }() + // ClubLog Country File (cty.xml) — date-ranged callsign exceptions that // cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on // demand. Resolution applied only when the user enables it. @@ -1711,6 +1734,17 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { } } id, err = a.qso.Add(a.ctx, q) + if err != nil && db.IsConnLost(err) { + // The database is UNREACHABLE (not a data error) — park the QSO in the + // offline outbox rather than lose it. Returns id = -1 so the UI can say + // "saved, waiting to sync" instead of showing a failure. The post-save + // side effects (recording, uploads, UDP) are skipped: they need a real + // row id and will not be replayed — the operator can upload later. + if a.queueOffline(q, err) { + return -1, nil + } + // Couldn't even write the outbox → report the real error, never pretend. + } if err == nil { q.ID = id a.saveQSORecording(&q) @@ -1722,6 +1756,11 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { if a.udp != nil { go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q)) } + // A successful write means the link is healthy again — if QSOs are still + // parked, get them in now rather than waiting for the next tick. + if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 { + go func() { _, _ = a.replayOfflineQueue() }() + } } return id, err } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f8be17..1e6bb8e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - AlertCircle, Antenna, Bell, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, + AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap, } from 'lucide-react'; import { AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered, + GetOfflineStatus, GetPendingQSOs, RetryOfflineSync, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected, SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected, ContestDupe, @@ -830,6 +831,24 @@ export default function App() { type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number }; const [recentAlerts, setRecentAlerts] = useState([]); const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown + + // Offline outbox: QSOs parked locally because the database was unreachable. + const [offlineStatus, setOfflineStatus] = useState<{ offline: boolean; pending: number; path: string }>({ offline: false, pending: 0, path: '' }); + const [pendingOpen, setPendingOpen] = useState(false); + const [pendingList, setPendingList] = useState([]); + useEffect(() => { + const load = () => GetOfflineStatus().then((s: any) => setOfflineStatus(s)).catch(() => {}); + load(); + const off = EventsOn('offline:status', (s: any) => { + setOfflineStatus(s); + // Keep the open list in step as QSOs are parked or replayed. + if (pendingOpenRef.current) GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); + }); + return () => { off?.(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const pendingOpenRef = useRef(false); + useEffect(() => { pendingOpenRef.current = pendingOpen; }, [pendingOpen]); const ALERT_TTL_MS = 10 * 60 * 1000; useEffect(() => { const id = window.setInterval(() => { @@ -1988,7 +2007,13 @@ export default function App() { srx: rE.num, srx_string: rE.str, }); } - await AddQSO(payload); + // id = -1 means the database was unreachable and the QSO was parked in the + // offline outbox — it is SAVED (on disk), just not in the logbook yet. Treat + // it as a success so logging never stops; the banner tells the operator. + const newId = await AddQSO(payload); + if (typeof newId === 'number' && newId < 0) { + showToast(t('offline.queued')); + } // Advance the sent serial only after a successful log. if (contest.active && contest.code && contest.exchange === 'serial') { updateContest({ nextSerial: contest.nextSerial + 1 }); @@ -2777,6 +2802,62 @@ export default function App() { // A discreet spot-alert LED (bell) that fills the gap at the right of the Grid // row instead of the intrusive floating cards. It lights + shows a count when // alerts are pending; click it to see the last 3 (each clickable to tune). + // ── Offline outbox (safety net) ────────────────────────────────────── + // When the shared database is unreachable, QSOs are parked in a local ADIF + // file rather than lost. Surface that HONESTLY: the operator must know their + // worked-before check doesn't include these, and see what's waiting. + const offlinePendingCount = offlineStatus.pending ?? 0; + const offlineBlock = offlinePendingCount === 0 ? null : ( +
+ + {pendingOpen && ( + <> +
setPendingOpen(false)} /> +
+
+

{t('offline.title', { n: offlinePendingCount })}

+

{t('offline.explain')}

+
+
+ {pendingList.length === 0 ? ( +

{t('offline.empty')}

+ ) : pendingList.map((q, i) => ( +
+ {q.callsign} + {q.band} + {q.mode} + + {q.qso_date ? new Date(q.qso_date).toISOString().slice(0, 16).replace('T', ' ') : ''} + +
+ ))} +
+
+ + {offlineStatus.path} +
+
+ + )} +
+ ); + const hasAlerts = recentAlerts.length > 0; // Only show the bell when there are pending alerts — hidden otherwise. const alertLedBlock = !hasAlerts ? null : ( @@ -3738,6 +3819,7 @@ export default function App() { {qthBlock} {gridBlock} {lotwBlock} + {offlineBlock} {alertLedBlock}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 75deeba..60f34fa 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -46,6 +46,14 @@ const en: Dict = { 'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.', 'lang.english': 'English', 'lang.french': 'Français', 'settings.language': 'Language', 'settings.languageHint': 'Interface language.', + 'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically', + 'offline.tip': '{n} QSO(s) waiting — the database is unreachable', + 'offline.title': 'Offline — {n} QSO(s) waiting', + 'offline.explain': "Saved to a local file, nothing is lost. They'll be added to the logbook automatically once the database answers. Note: worked-before does NOT include them.", + 'offline.empty': 'Nothing waiting.', + 'offline.retry': 'Retry now', + 'offline.synced': '{n} QSO(s) added to the logbook', + 'offline.stillDown': 'Database still unreachable — your QSOs are safe', 'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.', 'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light', 'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark', @@ -256,6 +264,14 @@ const fr: Dict = { 'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.', 'lang.english': 'English', 'lang.french': 'Français', 'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.", + 'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique', + 'offline.tip': '{n} QSO en attente — la base est injoignable', + 'offline.title': 'Hors ligne — {n} QSO en attente', + 'offline.explain': "Enregistrés dans un fichier local, rien n'est perdu. Ils rejoindront le journal automatiquement dès que la base répondra. Attention : le « déjà contacté » ne les prend PAS en compte.", + 'offline.empty': 'Rien en attente.', + 'offline.retry': 'Réessayer maintenant', + 'offline.synced': '{n} QSO ajoutés au journal', + 'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité', 'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.", 'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid', 'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 6c20d83..6503012 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -332,6 +332,8 @@ export function GetLookupSettings():Promise; export function GetMySQLSettings():Promise; +export function GetOfflineStatus():Promise; + export function GetOnlineOperators():Promise>; export function GetPGXLSettings():Promise; @@ -340,6 +342,8 @@ export function GetPGXLStatus():Promise; export function GetPOTAToken():Promise; +export function GetPendingQSOs():Promise>; + export function GetQSLDefaults():Promise; export function GetQSO(arg1:number):Promise; @@ -624,6 +628,8 @@ export function RestartApp():Promise; export function RestartQSORecorder():Promise; +export function RetryOfflineSync():Promise; + export function RotatorGoTo(arg1:number,arg2:number):Promise; export function RotatorPark():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 1040087..862ce37 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -622,6 +622,10 @@ export function GetMySQLSettings() { return window['go']['main']['App']['GetMySQLSettings'](); } +export function GetOfflineStatus() { + return window['go']['main']['App']['GetOfflineStatus'](); +} + export function GetOnlineOperators() { return window['go']['main']['App']['GetOnlineOperators'](); } @@ -638,6 +642,10 @@ export function GetPOTAToken() { return window['go']['main']['App']['GetPOTAToken'](); } +export function GetPendingQSOs() { + return window['go']['main']['App']['GetPendingQSOs'](); +} + export function GetQSLDefaults() { return window['go']['main']['App']['GetQSLDefaults'](); } @@ -1206,6 +1214,10 @@ export function RestartQSORecorder() { return window['go']['main']['App']['RestartQSORecorder'](); } +export function RetryOfflineSync() { + return window['go']['main']['App']['RetryOfflineSync'](); +} + export function RotatorGoTo(arg1, arg2) { return window['go']['main']['App']['RotatorGoTo'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ab214e1..1b226c1 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1828,6 +1828,22 @@ export namespace main { this.database = source["database"]; } } + export class OfflineStatus { + offline: boolean; + pending: number; + path: string; + + static createFrom(source: any = {}) { + return new OfflineStatus(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.offline = source["offline"]; + this.pending = source["pending"]; + this.path = source["path"]; + } + } export class PGXLSettings { enabled: boolean; host: string; diff --git a/internal/adif/export.go b/internal/adif/export.go index 502f2e9..fe3f00e 100644 --- a/internal/adif/export.go +++ b/internal/adif/export.go @@ -120,6 +120,18 @@ func SingleRecordADIF(q qso.QSO) string { return b.String() } +// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras — +// so it can be written out and read back with nothing dropped. Used by the +// offline queue: a QSO parked in the safety file must come back identical +// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…). +func FullRecordADIF(q qso.QSO) string { + var b strings.Builder + bw := bufio.NewWriter(&b) + writeRecord(bw, q, true) + bw.Flush() + return b.String() +} + // BatchRecordsADIF wraps already-serialised records (each terminated by , // e.g. from SingleRecordADIF) in a minimal ADIF document with a standard // header. Used by file-based batch upload APIs such as Club Log's putlogs.php. diff --git a/internal/db/connloss.go b/internal/db/connloss.go new file mode 100644 index 0000000..2aeb6f8 --- /dev/null +++ b/internal/db/connloss.go @@ -0,0 +1,66 @@ +package db + +import ( + "database/sql" + "database/sql/driver" + "errors" + "net" + "strings" + + "github.com/go-sql-driver/mysql" +) + +// IsConnLost reports whether err means the database was UNREACHABLE (network +// down, server gone, dead pooled connection) as opposed to a legitimate +// server-side SQL error (constraint violation, bad data, syntax). +// +// This distinction is the linchpin of the offline safety net: a QSO is parked in +// the local ADIF outbox ONLY on a connection loss. If we queued on any error, a +// genuine data bug would silently vanish into the file instead of being +// reported — the worst possible outcome. +// +// A *mysql.MySQLError means the SERVER answered and rejected us, so it is never +// a connection loss, whatever its code. +func IsConnLost(err error) bool { + if err == nil { + return false + } + // The server responded → a real SQL error, not a lost link. + var me *mysql.MySQLError + if errors.As(err, &me) { + return false + } + if errors.Is(err, driver.ErrBadConn) || errors.Is(err, sql.ErrConnDone) || errors.Is(err, sql.ErrTxDone) { + return true + } + var ne net.Error + if errors.As(err, &ne) { + return true + } + var oe *net.OpError + if errors.As(err, &oe) { + return true + } + // The MySQL driver reports a few of these as plain strings. + s := strings.ToLower(err.Error()) + for _, p := range []string{ + "invalid connection", + "bad connection", + "connection refused", + "connection reset", + "broken pipe", + "no such host", + "i/o timeout", + "dial tcp", + "unexpected eof", + "driver: bad connection", + "can't connect", + "network is unreachable", + "host is unreachable", + } { + if strings.Contains(s, p) { + return true + } + } + return false +} diff --git a/internal/extsvc/clublog.go b/internal/extsvc/clublog.go index 48095a5..3ee2763 100644 --- a/internal/extsvc/clublog.go +++ b/internal/extsvc/clublog.go @@ -21,6 +21,12 @@ const clublogRealtimeURL = "https://clublog.org/realtime.php" // N QSOs is one HTTP request instead of N realtime.php calls. const clublogBatchURL = "https://clublog.org/putlogs.php" +// clublogUserAgent identifies OpsLog to Club Log. Go's default +// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx +// returns 403 Forbidden before the request reaches the app), so every request +// must send a real, app-identifying User-Agent. +const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)" + // clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log // requires an api parameter that identifies the client software (not the // user) — the same way Log4OM embeds its own key — so we ship it baked in @@ -122,6 +128,7 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf return UploadResult{}, fmt.Errorf("clublog: build request: %w", err) } req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("User-Agent", clublogUserAgent) if client == nil { client = &http.Client{Timeout: 120 * time.Second} } @@ -135,10 +142,63 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf if resp.StatusCode == http.StatusOK { return UploadResult{OK: true, Message: msg}, nil } - if msg == "" { - msg = fmt.Sprintf("HTTP %d", resp.StatusCode) + diag := clublogFailureDiag(resp, msg) + return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag) +} + +// clublogFailureDiag turns a non-200 response into a readable one-liner that +// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare, +// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx" +// page means the request never reached the app; the fingerprint headers below +// tell the operator whether it's Club Log's own WAF or something on their side. +func clublogFailureDiag(resp *http.Response, body string) string { + server := strings.TrimSpace(resp.Header.Get("Server")) + // Notable intermediary fingerprints — presence points at the culprit. + fp := []string{} + for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} { + if v := strings.TrimSpace(resp.Header.Get(h)); v != "" { + fp = append(fp, h+"="+v) + } } - return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg) + // Collapse the HTML error page to something short. + summary := stripHTMLBrief(body) + if summary == "" { + summary = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary) + if server != "" { + out += " [server=" + server + "]" + } + if len(fp) > 0 { + out += " [" + strings.Join(fp, " ") + "]" + } + return out +} + +// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160 +// chars of visible text — enough to read "403 Forbidden" without the markup. +func stripHTMLBrief(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) + } + } + } + out := strings.Join(strings.Fields(b.String()), " ") + if len(out) > 160 { + out = out[:160] + "…" + } + return out } // TestClublog validates the configured credentials by attempting a no-op @@ -164,6 +224,7 @@ func clublogPost(ctx context.Context, client *http.Client, endpoint string, form return UploadResult{}, fmt.Errorf("clublog: build request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", clublogUserAgent) if client == nil { client = &http.Client{Timeout: 20 * time.Second} } diff --git a/internal/extsvc/lotw.go b/internal/extsvc/lotw.go index 4e2e632..179d5cd 100644 --- a/internal/extsvc/lotw.go +++ b/internal/extsvc/lotw.go @@ -3,6 +3,7 @@ package extsvc import ( "context" "encoding/xml" + "errors" "fmt" "io" "net/http" @@ -11,6 +12,7 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" "time" ) @@ -212,6 +214,15 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri if runErr != nil { if ee, ok := runErr.(*exec.ExitError); ok { code = ee.ExitCode() + } else if errors.Is(runErr, syscall.Errno(740)) || strings.Contains(strings.ToLower(runErr.Error()), "requires elevation") { + // ERROR_ELEVATION_REQUIRED (740): tqsl.exe is set to require admin + // rights (its "Run as administrator" compatibility flag, or an + // AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows + // refuses to launch it. Actionable message instead of the raw error. + return UploadResult{}, fmt.Errorf( + "lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " + + "Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " + + "Or run OpsLog itself as administrator.", tqsl) } else { return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr) } diff --git a/internal/offlineq/offlineq.go b/internal/offlineq/offlineq.go new file mode 100644 index 0000000..1d6e5b2 --- /dev/null +++ b/internal/offlineq/offlineq.go @@ -0,0 +1,202 @@ +// Package offlineq is OpsLog's offline safety net. +// +// When the shared MySQL logbook is unreachable, a QSO must never be lost just +// because the network blinked. Instead of failing, the QSO is appended to a +// local ADIF file (the "outbox") and replayed into the database as soon as it +// comes back — then the file is ARCHIVED, never deleted. +// +// Deliberately NOT a sync engine: it only ever PUSHES the operator's own QSOs. +// There is no mirror, no pull, no merge, no tombstones — which is exactly why it +// stays small. The cost, accepted by design: during an outage you don't see other +// operators' QSOs and the worked-before check doesn't know about your pending +// ones. See the queue view in the UI for what's waiting. +// +// The file lives in OpsLog's data directory — NEVER in a cloud-synced folder +// (Seafile/OneDrive): replicating a live file byte-by-byte is what we're +// escaping in the first place. +package offlineq + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "hamlog/internal/adif" + "hamlog/internal/qso" +) + +// FileName is the outbox. Pending QSOs accumulate here while the DB is down. +const FileName = "opslog-pending.adi" + +// Queue owns the outbox file. All operations are serialised: the logging path +// (append) and the replay loop (read/rewrite/archive) run on different +// goroutines. +type Queue struct { + dir string + mu sync.Mutex +} + +// New returns a queue backed by /opslog-pending.adi. +func New(dir string) *Queue { return &Queue{dir: strings.TrimSpace(dir)} } + +// Path is the outbox file's location (shown in the UI so the operator always +// knows where their QSOs physically are). +func (q *Queue) Path() string { return filepath.Join(q.dir, FileName) } + +// newQueueID mints the id that makes replay idempotent. +func newQueueID() string { + var b [12]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("t%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) +} + +// Append parks a QSO in the outbox, stamping it with a queue id (returned) so a +// repeated replay can recognise it. The file is created with an ADIF header on +// first use, then appended to — an append can't corrupt what's already there. +func (q *Queue) Append(rec qso.QSO) (string, error) { + q.mu.Lock() + defer q.mu.Unlock() + + if rec.Extras == nil { + rec.Extras = map[string]string{} + } + qid := strings.TrimSpace(rec.Extras[qso.OfflineQueueKey]) + if qid == "" { + qid = newQueueID() + rec.Extras[qso.OfflineQueueKey] = qid + } + + if err := os.MkdirAll(q.dir, 0o755); err != nil { + return "", fmt.Errorf("offlineq: create dir: %w", err) + } + path := q.Path() + _, statErr := os.Stat(path) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return "", fmt.Errorf("offlineq: open %s: %w", path, err) + } + defer f.Close() + + var b strings.Builder + if os.IsNotExist(statErr) { // brand-new file → write the ADIF header once + b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n") + b.WriteString("3.1.4 OpsLog \n\n") + } + b.WriteString(strings.TrimRight(adif.FullRecordADIF(rec), "\r\n")) + b.WriteString("\n") + + if _, err := f.WriteString(b.String()); err != nil { + return "", fmt.Errorf("offlineq: write: %w", err) + } + // Flush to disk: the whole point is surviving a crash/power cut. + if err := f.Sync(); err != nil { + return "", fmt.Errorf("offlineq: sync: %w", err) + } + return qid, nil +} + +// Pending parses the outbox into QSOs (each carrying its queue id in Extras). +// A missing file simply means nothing is pending. +func (q *Queue) Pending() ([]qso.QSO, error) { + q.mu.Lock() + defer q.mu.Unlock() + return q.pendingLocked() +} + +func (q *Queue) pendingLocked() ([]qso.QSO, error) { + f, err := os.Open(q.Path()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + + var out []qso.QSO + err = adif.Parse(f, func(rec adif.Record) error { + if v, ok := adif.RecordToQSO(rec); ok { + out = append(out, v) + } + return nil + }) + if err != nil { + return out, fmt.Errorf("offlineq: parse %s: %w", q.Path(), err) + } + return out, nil +} + +// Count is how many QSOs are waiting (0 when the file is absent). +func (q *Queue) Count() int { + p, err := q.Pending() + if err != nil { + return 0 + } + return len(p) +} + +// Rewrite replaces the outbox with exactly these QSOs — used after a replay to +// keep only the ones that FAILED. Written to a temp file and renamed, so a crash +// mid-write can't truncate the queue. +func (q *Queue) Rewrite(keep []qso.QSO) error { + q.mu.Lock() + defer q.mu.Unlock() + return q.rewriteLocked(keep) +} + +func (q *Queue) rewriteLocked(keep []qso.QSO) error { + path := q.Path() + if len(keep) == 0 { + // Nothing left: remove the (now empty) outbox. The caller archives the + // original content first, so this never destroys the only copy. + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("offlineq: remove: %w", err) + } + return nil + } + var b strings.Builder + b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n") + b.WriteString("3.1.4 OpsLog \n\n") + for _, v := range keep { + b.WriteString(strings.TrimRight(adif.FullRecordADIF(v), "\r\n")) + b.WriteString("\n") + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil { + return fmt.Errorf("offlineq: write temp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("offlineq: replace: %w", err) + } + return nil +} + +// Archive copies the outbox to a timestamped file BEFORE it is cleared, so the +// QSOs always exist somewhere on disk even if the replay later turns out to have +// gone wrong. Deleting the only copy of someone's contacts is the one mistake +// you don't get to undo. +func (q *Queue) Archive() (string, error) { + q.mu.Lock() + defer q.mu.Unlock() + + data, err := os.ReadFile(q.Path()) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + name := fmt.Sprintf("opslog-pending-%s.adi", time.Now().Format("2006-01-02-1504")) + dst := filepath.Join(q.dir, name) + if err := os.WriteFile(dst, data, 0o644); err != nil { + return "", fmt.Errorf("offlineq: archive: %w", err) + } + return dst, nil +} diff --git a/internal/offlineq/offlineq_test.go b/internal/offlineq/offlineq_test.go new file mode 100644 index 0000000..f10c3e8 --- /dev/null +++ b/internal/offlineq/offlineq_test.go @@ -0,0 +1,93 @@ +package offlineq + +import ( + "os" + "path/filepath" + "testing" + "time" + + "hamlog/internal/qso" +) + +// The outbox is the ONLY copy of a QSO logged while the database was down, so the +// round-trip must be lossless: append → read back identical (callsign, band, mode, +// date) and each record must carry a queue id (what makes the replay idempotent). +func TestQueueRoundTrip(t *testing.T) { + dir := t.TempDir() + q := New(dir) + + if n := q.Count(); n != 0 { + t.Fatalf("fresh queue: count = %d, want 0", n) + } + + mk := func(call, band, mode string) qso.QSO { + return qso.QSO{ + Callsign: call, Band: band, Mode: mode, + QSODate: time.Date(2026, 7, 10, 12, 34, 0, 0, time.UTC), + } + } + id1, err := q.Append(mk("K1ABC", "20m", "SSB")) + if err != nil { + t.Fatalf("append 1: %v", err) + } + id2, err := q.Append(mk("DL1XYZ", "40m", "CW")) + if err != nil { + t.Fatalf("append 2: %v", err) + } + if id1 == "" || id2 == "" || id1 == id2 { + t.Fatalf("queue ids must be non-empty and distinct: %q / %q", id1, id2) + } + + pending, err := q.Pending() + if err != nil { + t.Fatalf("pending: %v", err) + } + if len(pending) != 2 { + t.Fatalf("pending = %d, want 2", len(pending)) + } + if pending[0].Callsign != "K1ABC" || pending[0].Band != "20m" || pending[0].Mode != "SSB" { + t.Errorf("record 1 round-tripped wrong: %+v", pending[0]) + } + if pending[1].Callsign != "DL1XYZ" || pending[1].Mode != "CW" { + t.Errorf("record 2 round-tripped wrong: %+v", pending[1]) + } + // The queue id must survive the ADIF round-trip — without it the replay + // can't be idempotent and a crash would duplicate contacts. + for i, p := range pending { + if p.Extras[qso.OfflineQueueKey] == "" { + t.Errorf("record %d lost its %s extra", i, qso.OfflineQueueKey) + } + } + + // Archive keeps a copy BEFORE we clear anything. + arch, err := q.Archive() + if err != nil || arch == "" { + t.Fatalf("archive: %v (path %q)", err, arch) + } + if _, err := os.Stat(arch); err != nil { + t.Fatalf("archive file missing: %v", err) + } + + // Partial replay: the first QSO went in, the second failed → keep only it. + if err := q.Rewrite(pending[1:]); err != nil { + t.Fatalf("rewrite: %v", err) + } + left, err := q.Pending() + if err != nil { + t.Fatalf("pending after rewrite: %v", err) + } + if len(left) != 1 || left[0].Callsign != "DL1XYZ" { + t.Fatalf("after rewrite: got %d records (%v), want just DL1XYZ", len(left), left) + } + + // Everything replayed → the outbox is removed (the archive still holds it). + if err := q.Rewrite(nil); err != nil { + t.Fatalf("rewrite empty: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, FileName)); !os.IsNotExist(err) { + t.Errorf("outbox should be gone once fully replayed, stat err = %v", err) + } + if n := q.Count(); n != 0 { + t.Errorf("count after full replay = %d, want 0", n) + } +} diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 8e022fd..253ce8b 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1011,6 +1011,32 @@ func FilterableFields() []string { } // columnExpr resolves a filter field to a safe SQL expression — either a +// OfflineQueueKey is the ADIF extras key stamped on a QSO that was parked in the +// offline safety file. It survives the ADIF round-trip and makes the replay +// IDEMPOTENT: if the app dies between "inserted into the DB" and "removed from +// the file", the next replay sees the id already in the log and skips it instead +// of creating a duplicate. +const OfflineQueueKey = "APP_OPSLOG_QUEUEID" + +// ExistsByQueueID reports whether a QSO carrying this offline-queue id is already +// in the logbook — the guard that makes replaying the safety file safe to repeat. +func (r *Repo) ExistsByQueueID(ctx context.Context, qid string) (bool, error) { + qid = strings.TrimSpace(qid) + if qid == "" { + return false, nil + } + expr := "json_extract(extras_json, '$." + OfflineQueueKey + "')" + if db.IsMySQL() { + expr = "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + OfflineQueueKey + "'))" + } + var n int + if err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM qso WHERE `+expr+` = ?`, qid).Scan(&n); err != nil { + return false, err + } + return n > 0, nil +} + // whitelisted column name or a json_extract over extras_json. func columnExpr(field string) (string, bool) { f := strings.ToLower(strings.TrimSpace(field)) diff --git a/offline.go b/offline.go new file mode 100644 index 0000000..7ccacd7 --- /dev/null +++ b/offline.go @@ -0,0 +1,191 @@ +package main + +import ( + "context" + "fmt" + "time" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "hamlog/internal/applog" + "hamlog/internal/db" + "hamlog/internal/offlineq" + "hamlog/internal/qso" +) + +// ── Offline safety net ──────────────────────────────────────────────── +// +// With the shared MySQL logbook, the network sits on the critical path of every +// write: lose it and you can't log at all. Rather than a fragile "fall back to +// SQLite" (which would leave you with a partial log and needs a restart), we +// keep the architecture exactly as it is and add a NET: +// +// DB unreachable → the QSO is parked in a local ADIF outbox (never lost) +// DB back → replayed into the logbook, idempotently, file archived +// +// It only ever PUSHES your own QSOs — no mirror, no pull, no merge. That's what +// keeps it small. Accepted trade-off: while offline you don't see other ops' +// QSOs, and worked-before doesn't know about your own pending ones (they're +// listed separately in the UI instead). + +// offlineReplayEvery is how often we re-probe the database while QSOs are +// waiting. Short enough to feel automatic, long enough not to hammer a dead host. +const offlineReplayEvery = 15 * time.Second + +// OfflineStatus is what the UI shows: are we parked, and how much is waiting. +type OfflineStatus struct { + Offline bool `json:"offline"` // last write failed because the DB was unreachable + Pending int `json:"pending"` // QSOs sitting in the outbox + Path string `json:"path"` // where the outbox physically is +} + +// queueOffline parks a QSO that couldn't reach the database. Returns false when +// queueing isn't applicable (local SQLite can't "go offline") or the write to the +// outbox itself failed — in which case the caller MUST surface the original error +// rather than pretend the QSO was saved. +func (a *App) queueOffline(q qso.QSO, cause error) bool { + if a.offlineQ == nil || !db.IsMySQL() { + return false + } + qid, err := a.offlineQ.Append(q) + if err != nil { + // The net itself tore: do NOT claim success. + applog.Printf("offline: FAILED to park %s in the outbox: %v (original: %v)", q.Callsign, err, cause) + return false + } + applog.Printf("offline: DB unreachable (%v) — parked %s in the outbox (id %s)", cause, q.Callsign, qid) + a.offlineMode = true + a.emitOfflineStatus() + return true +} + +// emitOfflineStatus pushes the banner state to the UI. +func (a *App) emitOfflineStatus() { + if a.ctx == nil || a.offlineQ == nil { + return + } + wruntime.EventsEmit(a.ctx, "offline:status", a.GetOfflineStatus()) +} + +// GetOfflineStatus reports whether we're parked and how many QSOs are waiting. +func (a *App) GetOfflineStatus() OfflineStatus { + if a.offlineQ == nil { + return OfflineStatus{} + } + n := a.offlineQ.Count() + return OfflineStatus{ + Offline: a.offlineMode && n > 0, + Pending: n, + Path: a.offlineQ.Path(), + } +} + +// GetPendingQSOs lists the QSOs waiting in the outbox, so the operator can SEE +// what they logged while the database was down instead of flying blind. +func (a *App) GetPendingQSOs() ([]qso.QSO, error) { + if a.offlineQ == nil { + return nil, nil + } + return a.offlineQ.Pending() +} + +// RetryOfflineSync forces a replay attempt now (the "Retry" button) instead of +// waiting for the next tick. +func (a *App) RetryOfflineSync() (int, error) { + return a.replayOfflineQueue() +} + +// offlineReplayLoop re-probes the database while QSOs are waiting and replays +// them the moment it answers. +func (a *App) offlineReplayLoop() { + t := time.NewTicker(offlineReplayEvery) + defer t.Stop() + for range t.C { + if a.offlineQ == nil || a.offlineQ.Count() == 0 { + continue + } + if a.logDb == nil { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := a.logDb.PingContext(ctx) + cancel() + if err != nil { + continue // still down — keep waiting, the QSOs are safe on disk + } + if n, rerr := a.replayOfflineQueue(); rerr != nil { + applog.Printf("offline: replay failed: %v", rerr) + } else if n > 0 { + applog.Printf("offline: replayed %d QSO(s) into the logbook", n) + } + } +} + +// replayOfflineQueue imports the outbox into the logbook. It is IDEMPOTENT: each +// parked QSO carries a queue id, and one already present in the log is skipped — +// so a crash between "inserted" and "removed from the file" can never duplicate a +// contact. The file is archived BEFORE being cleared. +func (a *App) replayOfflineQueue() (int, error) { + if a.offlineQ == nil || a.qso == nil { + return 0, nil + } + pending, err := a.offlineQ.Pending() + if err != nil { + return 0, err + } + if len(pending) == 0 { + a.offlineMode = false + a.emitOfflineStatus() + return 0, nil + } + + imported := 0 + var failed []qso.QSO + for _, q := range pending { + qid := "" + if q.Extras != nil { + qid = q.Extras[qso.OfflineQueueKey] + } + // Idempotency guard: already replayed on an earlier (interrupted) pass? + if qid != "" { + if exists, e := a.qso.ExistsByQueueID(a.ctx, qid); e == nil && exists { + imported++ // it IS in the log — treat as done, drop from the outbox + continue + } else if e != nil && db.IsConnLost(e) { + failed = append(failed, q) // DB went away again mid-replay + continue + } + } + if _, e := a.qso.Add(a.ctx, q); e != nil { + applog.Printf("offline: replay of %s failed: %v", q.Callsign, e) + failed = append(failed, q) + continue + } + imported++ + } + + // Archive the outbox as it was, then keep only what still failed. + if imported > 0 { + if dst, e := a.offlineQ.Archive(); e != nil { + applog.Printf("offline: archive failed: %v", e) + } else if dst != "" { + applog.Printf("offline: outbox archived to %s", dst) + } + } + if e := a.offlineQ.Rewrite(failed); e != nil { + return imported, fmt.Errorf("offline: rewrite outbox: %w", e) + } + + a.offlineMode = len(failed) > 0 + a.emitOfflineStatus() + if imported > 0 && a.ctx != nil { + wruntime.EventsEmit(a.ctx, "logbook:changed") + wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%d QSO(s) en attente ont été enregistrés", imported)) + } + return imported, nil +} + +// newOfflineQueue builds the outbox in OpsLog's data directory — deliberately NOT +// in a cloud-synced folder: byte-level file replication (Seafile/OneDrive) is the +// very thing this design avoids. +func newOfflineQueue(dataDir string) *offlineq.Queue { return offlineq.New(dataDir) } diff --git a/singleinstance_other.go b/singleinstance_other.go index b926bfe..b6140f7 100644 --- a/singleinstance_other.go +++ b/singleinstance_other.go @@ -1,7 +1,9 @@ -//go:build !windows +//go:build !windows || bindings package main -// acquireSingleInstance is a no-op off Windows (the single-instance guard uses a -// Windows named mutex). Always allows the app to start. +// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named +// mutex), and during Wails' binding generation (the `bindings` tag) — that step +// runs this binary, and a real OpsLog already running would otherwise make it +// exit before Wails could reflect the bindings. func acquireSingleInstance() bool { return true } diff --git a/singleinstance_windows.go b/singleinstance_windows.go index 2df262d..d04e4c0 100644 --- a/singleinstance_windows.go +++ b/singleinstance_windows.go @@ -1,4 +1,10 @@ -//go:build windows +//go:build windows && !bindings + +// NB the !bindings tag: Wails generates the TypeScript bindings by BUILDING AND +// RUNNING this binary. With the guard active, a normal OpsLog already running on +// the dev machine holds the mutex, the generator's process exits instantly, and +// no bindings are produced. Excluding the guard from that build keeps generation +// working while shipping builds still get it. package main