Files
OpsLog/CLAUDE.md
T
2026-07-26 16:57:19 +02:00

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 TaskRelease 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 in data/config.json.
  • a.logDb — the logbook (QSOs). Either a per-profile SQLite file, the default logbook.db, or a shared MySQL so several operators log into one database. a.dbBackend says 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 of app.go (~180 of them) — grep key[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:

  1. internal/<device>/ — a self-contained package exposing a Client with New(...), Start(), Stop(), GetStatus(). The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known Status. Transports are usually TCP or serial behind one io.ReadWriteCloser.
  2. Settingskey<Thing>* constants plus Get<Thing>Settings() / Save<Thing>Settings() bindings in app.go.
  3. 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.
  4. Status bindingGet<Thing>Status(), polled by the frontend.
  5. UI — a settings panel in frontend/src/components/SettingsModal.tsx and a live widget in frontend/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/adif promotes ~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 in internal/qso/qso.go; the dictionary entry (Promoted: true) in internal/adif/fields.go; the adifPromoted list and the assignment in internal/adif/import.go; the writer in internal/adif/export.go; then the frontend column in RecentQSOsGrid.tsx with 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) and internal/awardref/uscounties_gen.go (emitted by cmd/cntygen from 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.