8.9 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
OpsLog is a Windows ham-radio logger built with Wails v2 — Go backend, React/TypeScript frontend, compiled into a single .exe. Author F4BPO. See README.md for the user-facing feature list and wiki/ for end-user documentation.
Pure Go, no CGO — SQLite is modernc.org/sqlite, serial is go.bug.st/serial. Any dependency requiring cgo breaks the build; check before adding one.
Commands
wails dev # hot-reload dev (Go methods also reachable at http://localhost:34115)
wails build # full build → build/bin/OpsLog.exe (~25 s)
wails generate module # REQUIRED after changing exported App methods (regenerates TS bindings)
go build ./... # fast Go-only check
go test ./... # all Go tests
go test ./internal/steppir/ -run TestApplyPendingDirHold -v # one test
gofmt -w <file>
cd frontend && npx tsc --noEmit # frontend typecheck alone (wails build does this too)
Release: .vscode/release.ps1 (Ctrl+Shift+P → Tasks: Run Task → Release OpsLog) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub.
Prefer wails build for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together.
Navigating app.go
app.go is ~14 000 lines and holds nearly every Wails binding. Do not read it linearly — it is organised into banner-delimited sections:
grep -n "^// ──\|^// ---" app.go # table of contents
Sections map to features (── Motorized antenna (Ultrabeam / SteppIR) ──, ── NET Control ──, ── DX-cluster spot alerts ──, …). Find the banner, then read that range. The App struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments.
Smaller root files split off self-contained areas: app_cw.go, app_qsl_designer.go, app_secret.go, chat.go, offline.go, relayauto.go, adifwatch.go, livestatus.go, update.go, telemetry.go.
Architecture
Wails binding boundary
Exported methods on *App are the entire frontend API. Adding or changing a signature requires wails generate module, which writes frontend/wailsjs/go/main/App.d.ts and models.ts. Parameters and return types must be Wails-serializable — the generator prints Not found: time.Time noise for unsupported types (that particular message is long-standing and harmless).
Backend→frontend push uses Wails events (runtime.EventsEmit / EventsOn), e.g. qso:logged, update:progress. Most hardware status is polled by the frontend on an interval rather than pushed.
Two separate databases
This split is easy to get wrong and matters:
a.db— settings database (settings.db/opslog.db). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded indata/config.json.a.logDb— the logbook (QSOs). Either a per-profile SQLite file, the defaultlogbook.db, or a shared MySQL so several operators log into one database.a.dbBackendsays which.
QSOs never go to a.db. internal/db holds the connection helpers and the MySQL specifics; internal/qso is the repository.
Remote MySQL is the slow path: startup deliberately brings CAT/rig links up before connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory wcbm worked-index and the clusterEvents queue that keeps the telnet socket draining.
Per-profile settings scoping
internal/settings.Store is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (p3.), so each station profile has a complete independent set. Two consequences:
App.settingsScoped(atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope.- Settings keys are declared as
key<Thing>string constants near the top ofapp.go(~180 of them) — grepkey[A-Z].*= "to find one.
Password-type keys are encrypted at rest when the secret vault is unlocked (internal/secret); a locked vault returns "" rather than ciphertext, and callers treat that as "not configured".
Hardware device pattern
The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one:
internal/<device>/— a self-contained package exposing aClientwithNew(...),Start(),Stop(),GetStatus(). The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-knownStatus. Transports are usually TCP or serial behind oneio.ReadWriteCloser.- Settings —
key<Thing>*constants plusGet<Thing>Settings()/Save<Thing>Settings()bindings inapp.go. - Lifecycle — a
start<Thing>()method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved. - Status binding —
Get<Thing>Status(), polled by the frontend. - UI — a settings panel in
frontend/src/components/SettingsModal.tsxand a live widget infrontend/src/components/.
internal/steppir and internal/antgenius are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against real captured frames — keep that up, since a wrong byte silently mistunes an antenna.
Where two devices are interchangeable (Ultrabeam / SteppIR), app.go defines a small interface (motorAntenna) plus thin per-device adapters rather than branching everywhere.
CAT
internal/cat (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB and remote-over-internet), and TCI (SunSDR / Expert Electronics). cat.Manager exposes a backend-agnostic State(); backend-specific features are reached through typed escapes such as FlexDo(func(cat.FlexController) error). Check State().Backend before using one.
Logging
internal/applog — Wails builds with the Windows GUI subsystem, so fmt.Println is discarded. Use applog.Printf (or plain log.Printf inside internal/ packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem.
Conventions
Changelog is mandatory. Every user-visible change gets an entry in changelog.json, in both en and fr, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
Version lives in two places and must stay in lockstep: appVersion in telemetry.go and APP_VERSION in frontend/src/version.ts.
Bilingual UI. Every user-visible string goes through t() from frontend/src/lib/i18n.tsx (~700 keys), with both English and French provided. No hardcoded display strings.
Commit messages must not include a Co-Authored-By line or any mention of the model.
Comments explain why, not what. The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable.
Gotchas
- Adding a promoted ADIF field touches five places in lockstep.
internal/adifpromotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan ininternal/qso/qso.go; the dictionary entry (Promoted: true) ininternal/adif/fields.go; theadifPromotedlist and the assignment ininternal/adif/import.go; the writer ininternal/adif/export.go; then the frontend column inRecentQSOsGrid.tsxwith its EN+FR i18n keys. Trace an existing field (e.g.ant_path) across the repo as a template. - Generated files — don't hand-edit.
internal/dxcc/dxcc_names_gen.go(cty.dat joined to the ARRL/ADIF entity list) andinternal/awardref/uscounties_gen.go(emitted bycmd/cntygenfrom the FIPS county CSV; a one-shot generator, not part of the build). - Single-instance guard (
main.go): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency. - Frontend controlled inputs: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it.