Compare commits

..
Author SHA1 Message Date
sjg 8a3c426a93 [fix](trx-server): offload history persistence
CI / lint (pull_request) Failing after 0s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
2026-08-01 01:26:09 +02:00
Stan Grams b12c83e8b5 [fix](trx-frontend): preload AIS map icons
CI / lint (pull_request) Failing after 6s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 2s
CI / reuse (push) Failing after 0s
2026-08-01 01:14:07 +02:00
sjg ff4e2a5c5d [chore](trx-rs): install reuse with charset-normalizer extra
CI / lint (pull_request) Successful in 9m35s
CI / reuse (pull_request) Failing after 5s
CI / lint (push) Successful in 8m40s
CI / test (pull_request) Successful in 29m0s
CI / reuse (push) Failing after 5s
CI / test (push) Successful in 14m28s
The runner image's `reuse` failed to import (NoEncodingModuleError): it
needs an encoding-detection backend, which the bare `reuse` install does
not provide and which libmagic/`file` is not present to satisfy. Install
`reuse[charset-normalizer]` so the reuse job can run in the image.

Rebuild the runner image and recreate the containers to pick this up.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 23:57:38 +02:00
sjg 2ef9f80fc1 [chore](trx-rs): drop redundant CI setup steps for baked runner image
CI / lint (pull_request) Successful in 9m14s
CI / reuse (pull_request) Failing after 6s
CI / test (pull_request) Successful in 15m15s
The host-executor runners run jobs inside one container that already has
the Rust toolchain and all build dependencies baked in, so the per-job
`apt-get install` and rustup steps were redundant. Worse, with two jobs
running concurrently in the same runner they collided on the dpkg lock
("Could not get lock /var/lib/dpkg/lock-frontend").

Remove the system-dependency, rustup and cache steps; jobs now run cargo
directly. The cargo registry persists in the long-lived runner container.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 23:33:42 +02:00
sjg 6fe549e34e [chore](trx-rs): run reuse lint directly instead of Docker action
CI / test (pull_request) Failing after 52s
CI / reuse (pull_request) Failing after 3s
CI / lint (pull_request) Successful in 3m34s
The host-executor runners have no Docker daemon, so fsfe/reuse-action
(a Docker action) fails with "Cannot connect to the Docker daemon". Call
the reuse CLI directly; it is baked into the runner image.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 13:24:26 +02:00
sjg 406a8f86b1 [chore](trx-rs): add Podman act_runner CI deployment
CI / lint (pull_request) Successful in 2m43s
CI / test (pull_request) Successful in 3m20s
CI / reuse (pull_request) Successful in 7s
CI / lint (push) Successful in 2m36s
CI / test (push) Successful in 2m35s
CI / reuse (push) Failing after 4s
Add container/: a rootless Podman + systemd (Quadlet) setup to run
per-project Gitea Actions runners on one host instead of VMs. Uses the
host executor with a purpose-built image that bakes in the Rust toolchain
and all build dependencies (opus, alsa, soapysdr, clang), so CI runs skip
the per-run install and cold soapysdr-sys build.

Includes the runner Containerfile, first-boot registration entrypoint,
act_runner config template, two Quadlet units (trx-rs + a second project),
and a README covering build, registration, the required host-executor
workflow tweak, and tuning.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 12:45:53 +02:00
sjg 4b17b4ac1d [style](trx-ftx): use iterators in LDPC single-index loops
CI / lint (pull_request) Successful in 2m35s
CI / test (pull_request) Successful in 3m22s
CI / reuse (pull_request) Successful in 7s
CI / lint (push) Successful in 2m34s
CI / test (push) Successful in 3m26s
CI / reuse (push) Successful in 7s
clippy needless_range_loop (rust 1.97) flagged the loops in ldpc_check
and ldpc_decode that use a range only to index one array. Replace them
with iterator/enumerate forms. The belief-propagation loops that index
several arrays by the same variable are left as-is (not flagged).

Behaviour is unchanged; the transformations are index-for-index
equivalent. Verified the lib compiles and is clippy-clean; the crate's
LDPC tests run in the CI test job (they need a dev-dependency not
available in the local offline sandbox).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 10:43:01 +02:00
sjg 977f7b709e [style](trx-ais): drop redundant u32 cast
CI / lint (pull_request) Failing after 2m28s
CI / test (pull_request) Successful in 3m23s
CI / reuse (pull_request) Successful in 7s
get_uint returns Option<u32>, so `? as u32` is an unnecessary same-type
cast flagged by clippy under -D warnings (rust 1.97).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 10:28:55 +02:00
sjg a2838b06a2 [style](trx-ftx): fix clippy question_mark and collapsible_match
CI / lint (pull_request) Failing after 2m22s
CI / test (pull_request) Successful in 3m27s
CI / reuse (pull_request) Successful in 8s
CI runs a newer clippy (1.97) than was available locally, which flagged
three lints in trx-ftx not caught earlier:

- question_mark: replace the Some/None match in CallsignHashTable::lookup
  with `self.entries[idx].as_ref()?`
- collapsible_match: fold the nested `if` in text.rs char/nchar into match
  guards on the AlphanumSpaceSlash arm

Behaviour is unchanged; verified clean with nightly clippy (1.93).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-18 09:25:12 +02:00
sjg c6cd661676 [style](trx-rs): fix clippy warnings for -D warnings CI
CI / lint (pull_request) Failing after 4m14s
CI / test (pull_request) Successful in 15m22s
CI / reuse (pull_request) Successful in 7s
The CI lint job runs clippy with -D warnings, which surfaced a set of
existing warnings across decoders, the client, and the soapysdr backend.
Resolve them so the workspace is clean under the enforced lint level:

- collapsible_match / identity_op / needless_range_loop / same_item_push
  in trx-rds, trx-wspr, trx-vdes, trx-wefax, trx-aprs (mostly tests)
- field_reassign_with_default -> struct-update syntax in trx-client config
  tests
- assign_op_pattern, useless vec!, and test-module ordering picked up by
  cargo clippy --fix in trx-client and the soapysdr WFM tests

No behaviour changes; all affected crates' tests pass.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:49:12 +02:00
sjg bf08c7ebc0 [chore](trx-rs): install libclang for soapysdr-sys in CI
CI / lint (pull_request) Failing after 4m7s
CI / reuse (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
soapysdr-sys builds bindings with bindgen, which needs libclang at build
time. Add clang and libclang-dev to the system dependencies so the
soapysdr backend (a default feature) compiles under CI.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:30:49 +02:00
sjg c7470acc1c [chore](trx-rs): fix Rust PATH handling in CI
CI / lint (pull_request) Failing after 3m18s
CI / reuse (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
The Set up Rust step wrote the cargo bin dir to GITHUB_PATH, which does
not help within the same step and is not relied upon across steps on the
Gitea act_runner. Prepend $HOME/.cargo/bin to PATH directly in the setup
and each cargo step instead, so rustup and cargo resolve regardless of
GITHUB_PATH support.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:25:21 +02:00
sjg bcde03de38 [docs](trx-rs): track improvement areas in aidocs/wip.md
CI / lint (pull_request) Failing after 31s
CI / reuse (pull_request) Successful in 35s
CI / test (pull_request) Failing after 29s
Record the three-tier improvement backlog (infrastructure, robustness,
product) with a per-item progress status column, so the work discovered
in the July 2026 repo scan is tracked. Register aidocs/** in REUSE.toml
alongside docs/**.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 21:21:57 +02:00
sjg 63d46562c7 [chore](trx-rs): add Gitea Actions CI pipeline
Introduce CI running on push to main and pull requests. Three jobs:
lint (rustfmt check + clippy with -D warnings), test (build and test the
workspace with --locked), and REUSE compliance. Installs the required
system libraries (opus, alsa, soapysdr) and caches the cargo registry
and target directory.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 21:21:57 +02:00
sjg e575b3b365 [docs](trx-rs): adopt kernel-style AI attribution trailers
Replace the guidance to credit LLM usage via 'Co-authored-by:' with the
Linux kernel convention. Co-Authored-By and Co-Developed-By are reserved
for human authors (who must also sign off); AI/LLM assistance is
disclosed with an Assisted-By: trailer instead.

Apply the new policy to this commit as a worked example.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 21:10:09 +02:00
sjgandClaude Opus 4.8 2121817c04 [style](trx-frontend-http): refine web UI design system and a11y
Introduce a shared design-token layer and apply it to the web
frontend for a more consistent, readable, and accessible feel.
Pure CSS plus one HTML block; no app.js behaviour changes, and the
existing multi-theme (style x light/dark) system is preserved.

- Add spacing, type, radius and motion tokens to :root.
- Fix undefined --surface consumed by .controls-tray, which dropped
  the panel background entirely; it now resolves per theme.
- Set body line-height and font smoothing; collapse near-duplicate
  font sizes onto canonical scale steps.
- Rebuild the auth gate with classes and replace hardcoded colours
  (#ff6b6b, #9aa4b5) with theme variables so it renders correctly in
  every theme.
- Add an opacity fade on tab switches, gated by prefers-reduced-motion.
- Extend focus-visible rings to the spectrum canvas, tabindex controls
  and links.
- Add a self-theming --accent-text token to lift accent-coloured text
  (links, active tab) to WCAG AA without altering brand fills.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 20:57:43 +02:00
sjgandClaude Opus 4.7 d0bd38d7df [docs](trx-frontend-http): point footer repo link to Gitea
Repoint the web UI footer link to git.haxx.space/sjg/trx-rs, swap the
GitHub octocat mark for a host-neutral git-branch icon, and relabel to
"trx-rs source".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-05-18 00:06:45 +02:00
28 changed files with 848 additions and 245 deletions
+47
View File
@@ -0,0 +1,47 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
# CI for the self-hosted, host-executor Podman runners (see container/).
# The runner image bakes in the Rust toolchain and all build dependencies,
# so jobs go straight to cargo — no apt/rustup setup steps (which also
# collided on the dpkg lock when jobs ran concurrently in the same runner).
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: rustfmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --workspace --all-targets --locked
- name: Test
run: cargo test --workspace --locked
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: REUSE compliance
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
# action, which the host-executor runners cannot run. `reuse` is baked
# into the runner image (see container/Containerfile).
run: reuse lint
+21 -1
View File
@@ -18,7 +18,7 @@ When contributing to the project, please follow these guidelines:
- Use a maximum of 80 characters per line. - Use a maximum of 80 characters per line.
- Use a blank line between the commit message and the body. - Use a blank line between the commit message and the body.
- Sign your commits with `git commit -s`. - Sign your commits with `git commit -s`.
- Explicitly mark LLM usage in commit messages with 'Co-authored-by:'. - Disclose AI/LLM assistance with an `Assisted-By:` trailer (see below).
Use the format below for commit titles: Use the format below for commit titles:
[<type>](<crate>): <description> [<type>](<crate>): <description>
@@ -39,3 +39,23 @@ Allowed types:
- chore: build or maintenance changes - chore: build or maintenance changes
Write isolated commits for each crate. Write isolated commits for each crate.
## Attribution trailers
This project follows the Linux kernel convention for crediting work.
The `Co-Authored-By:` and `Co-Developed-By:` trailers name **people** who
authored the change. They are reserved for humans, and every person named
this way must also add their own `Signed-off-by:` line. Never use these
trailers for tools, assistants, or bots.
When a commit was produced with help from an AI assistant or LLM,
disclose it with an `Assisted-By:` trailer naming the tool (and model,
where relevant). The human committer remains the author of record and
takes responsibility for the change through `Signed-off-by:`.
Example:
Assisted-By: Claude Code (claude-opus-4)
Co-Authored-By: Jane Developer <jane@example.com>
Signed-off-by: Your Name <you@example.com>
+1
View File
@@ -11,6 +11,7 @@ path = [
"README.md", "README.md",
"trx-rs.toml.example", "trx-rs.toml.example",
"docs/**", "docs/**",
"aidocs/**",
"src/decoders/trx-ftx/README.md", "src/decoders/trx-ftx/README.md",
"src/decoders/trx-wxsat/README.md", "src/decoders/trx-wxsat/README.md",
"assets/trx-logo.png", "assets/trx-logo.png",
+89
View File
@@ -0,0 +1,89 @@
# Work In Progress — Project Improvement Areas
Living tracker for engineering-infrastructure and hardening work identified
during a July 2026 repo scan. The architecture-level backlog
(`docs/Improvement-Areas.md`, P0P3) is closed; these items focus on the
tooling, robustness, and product concerns *around* the code.
Status legend: **Done** · **In progress** · **Not started**
| # | Tier | Area | Status |
|----|------|------|--------|
| 1 | 1 — Infrastructure | Gitea Actions CI (fmt, clippy, test, REUSE) | In progress |
| 2 | 1 — Infrastructure | Supply-chain & lint governance (cargo-deny/audit, MSRV, `[workspace.lints]`) | Not started |
| 3 | 1 — Infrastructure | Release & deployment (container image, systemd units, binary releases) | Not started |
| 4 | 2 — Robustness | Panic-resilience audit (harden ~495 unwrap/expect/panic sites) | Not started |
| 5 | 2 — Robustness | `unsafe` SIMD safety scaffolding (`# Safety` docs, scalar↔SIMD equivalence tests) | Not started |
| 6 | 2 — Robustness | DSP performance benchmarks (criterion, guard optimization gains) | Not started |
| 7 | 2 — Robustness | Test-coverage measurement & gap-filling (llvm-cov; CAT backends, server tasks) | Not started |
| 8 | 3 — Product | Frontend modularization & tooling (split `app.js` into ES modules, ESLint, JS tests) | Not started |
| 9 | 3 — Product | Runtime observability (`/health`, Prometheus metrics) | Not started |
| 10 | 3 — Product | Documentation freshness & consolidation (reconcile `docs/` with code) | Not started |
## Tier 1 — Infrastructure gaps
### 1. Gitea Actions CI
No `.gitea/` / `.github/` / Woodpecker workflows exist, despite 768 tests.
Add a workflow running `cargo fmt --check`, `cargo clippy -D warnings`,
`cargo build`/`cargo test`, and REUSE lint on push + pull_request.
System deps: `pkg-config cmake libopus-dev libasound2-dev libsoapysdr-dev`
(the `soapysdr` backend is a default feature).
Notes for the runner: assumes an `act_runner` registered with an
`ubuntu-latest` label and GitHub-action proxying enabled (used by
`actions/checkout`, `actions/cache`, `fsfe/reuse-action`). `clippy` runs
with `-D warnings`; core crates are already clean, but if the first full
`--all-features` run surfaces warnings in a less-travelled crate, fix them
(preferred) or temporarily soften that step.
### 2. Supply-chain & lint governance
No `deny.toml`, `cargo audit`, `rustfmt.toml`/`clippy.toml`, declared MSRV,
or `[workspace.lints]`. Add dependency auditing to CI, pin an MSRV
(`rust-version`), and centralize lint policy in the workspace manifest.
### 3. Release & deployment
No `Dockerfile`, systemd units, packaging, or release automation (only
`script/dummy-server.sh`). Add a container image, example systemd units for
`trx-server`/`trx-client`, and a tag-triggered static-binary release job.
## Tier 2 — Robustness & correctness
### 4. Panic-resilience audit
495 `unwrap()`/`expect()`/`panic!` sites, 11 in the hottest server files
(`audio.rs`, `rig_task.rs`). A panic there can drop a rig task or the
process. Convert hot-path panics to error propagation / graceful
degradation; reserve `expect` for documented invariants.
### 5. `unsafe` SIMD safety scaffolding
13 `unsafe` blocks (AVX2 DSP). Add `# Safety` docs stating invariants,
confirm runtime feature detection is tested, and add property tests
asserting SIMD output matches the scalar fallback across random inputs.
### 6. DSP performance benchmarks
`docs/Optimization-Guidelines.md` documents NCO/polyphase/AVX2 gains, but no
`criterion` benches guard them. Add benches for the demod/resample/FFT hot
paths so regressions surface as numbers.
### 7. Test-coverage measurement & gap-filling
67 of 146 Rust files have no test module. Protocol is well covered; backends
(CAT BCD/ASCII encoding), `listener.rs`, and `config.rs` look thin. Wire up
`cargo-llvm-cov` and target the CAT backends and server tasks first.
## Tier 3 — Product & maintainability
### 8. Frontend modularization & tooling
`app.js` is 8,760 lines and `map-core.js` 3,515, with no modules, linter, or
tests. Keeping vanilla HTML+JS (no framework), split into native ES modules
by concern, add ESLint + Prettier, and add `node:test` unit tests for pure
logic (frequency formatting, unit math, decode parsing).
### 9. Runtime observability
`tracing` is set up, but there is no `/health` endpoint or metrics
instrumentation. Add health/readiness endpoints and Prometheus-format
metrics (decode rates, reconnects, audio underruns, per-rig state).
### 10. Documentation freshness & consolidation
`docs/` (13 files) is mostly dated 2026-03-29 while code moved into July, and
mixes planning artifacts with reference docs. Reconcile against current code,
separate "plans" from "reference," and fold still-true content into the
canonical docs.
+61
View File
@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B").
#
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI
# runs skip the per-run apt/rustup install cost. `sudo` is present so the
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they
# just become fast no-ops because everything is already installed.
FROM docker.io/library/debian:bookworm-slim
ARG ACT_RUNNER_VERSION=0.2.11
ARG NODE_MAJOR=20
ENV DEBIAN_FRONTEND=noninteractive \
RUSTUP_HOME=/opt/rustup \
CARGO_HOME=/opt/cargo \
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils git sudo pipx \
build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev \
&& rm -rf /var/lib/apt/lists/*
# Node.js (JS-based actions need node in PATH under the host executor).
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml).
# The [charset-normalizer] extra provides an encoding-detection backend;
# without it (and without libmagic) reuse fails to import at runtime.
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
# Rust stable with rustfmt + clippy, installed system-wide.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
--component rustfmt --component clippy \
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
# act_runner binary.
RUN arch="$(dpkg --print-architecture)"; \
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
curl -fsSL -o /usr/local/bin/act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \
&& chmod +x /usr/local/bin/act_runner
# Default config template (seeded into the /data volume on first boot).
COPY config.yaml /etc/act_runner/config.yaml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# /data holds the .runner registration, cache and workflow workspaces.
VOLUME /data
WORKDIR /data
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+118
View File
@@ -0,0 +1,118 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Podman-based Gitea Actions runners
Run two independent Gitea Actions runners on one host as rootless Podman
containers managed by systemd (Quadlet) — one per project — instead of two
VMs. Uses the **host executor**: workflow steps run directly inside a
purpose-built runner image that already has the Rust toolchain and all build
dependencies baked in, so CI runs skip the per-run install cost and no
Docker/Podman socket is needed.
## Files
| File | Purpose |
|------|---------|
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. |
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. |
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
| `project2-runner.container` | Quadlet unit for the second project's runner. |
## Prerequisites (once per host)
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
user that will own the runners:
```bash
# Survive logout / start on boot without an interactive session.
loginctl enable-linger "$USER"
```
No `podman.socket` is required for the host executor.
## 1. Build the image
```bash
cd container
podman build -t trx-rs-ci:latest .
```
## 2. Get a registration token
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy
the token. (Org- or instance-level tokens work too if you prefer wider scope.)
## 3. Install and start the runners
```bash
mkdir -p ~/.config/containers/systemd
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/
# Paste each repo's token for the FIRST boot only:
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx…
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container
$EDITOR ~/.config/containers/systemd/project2-runner.container
systemctl --user daemon-reload
systemctl --user start trx-rs-runner
systemctl --user start project2-runner
systemctl --user status trx-rs-runner
podman logs -f gitea-runner-trx-rs
```
Once each runner shows **online** in the repo's runner list, blank out the
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
the `…-data` volume) and `systemctl --user daemon-reload`.
## Required workflow change: the `reuse` job
The host executor runs steps directly in the container and therefore **cannot
run Docker-based actions**. The current `reuse` job uses `fsfe/reuse-action@v5`,
which is a Docker action. `reuse` is baked into the image, so replace that job
with a plain command:
```yaml
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: REUSE compliance
run: reuse lint
```
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
steps still run, but become fast no-ops because the image already has those
packages and the toolchain. (`sudo` is included in the image for exactly this
reason.)
> If you would rather keep Docker-based actions and per-run images, use the
> **Docker executor** instead: drop the `:host` suffix from the label in
> `config.yaml`, enable `systemctl --user --now enable podman.socket`, mount it
> into the container, and set `container.docker_host` to the socket path. That
> trades the baked-in speed for stronger per-job isolation.
## Tuning
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
are heavy; 12 is sensible when two runners share a host.
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
so one project cannot starve the other.
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
enforcing.
## Committing these files
If you add this directory to a REUSE-checked repo, register the markdown in
`REUSE.toml` (the other files carry inline SPDX headers):
```toml
[[annotations]]
path = ["container/**"]
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later"
```
+32
View File
@@ -0,0 +1,32 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# act_runner configuration template. Seeded into /data/config.yaml on first
# boot; edit the copy inside the volume to change settings per runner.
log:
level: info
runner:
# Registration state. Relative to the daemon's working directory (/data).
file: .runner
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
# modest, especially if two runners share one host. The trx-rs workflow has
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
capacity: 2
timeout: 3h
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
# steps directly inside THIS container (which already has all the toolchain).
# No Docker/Podman socket is required in this mode.
labels:
- "ubuntu-latest:host"
cache:
# Built-in actions cache server (used by actions/cache). Stored in the volume.
enabled: true
dir: "/data/cache"
host:
# Where per-job workspaces are created.
workdir_parent: /data/workflows
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Registers the runner on first boot (if no .runner state exists in /data),
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
# the stored registration and ignores the token.
set -euo pipefail
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
cd /data
# Seed the config from the image's template on first boot so it lives in the
# persistent volume and can be edited there.
if [ ! -f "$CONFIG_FILE" ]; then
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
fi
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
if [ ! -f /data/.runner ]; then
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
echo " in the Quadlet unit for the first boot only." >&2
exit 1
fi
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
act_runner register --no-interactive \
--config "$CONFIG_FILE" \
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
--name "${GITEA_RUNNER_NAME:-podman}" \
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
fi
exec act_runner daemon --config "$CONFIG_FILE"
+40
View File
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
# co-located on the same host as the trx-rs runner.
#
# It has its own name, its own data volume and its own registration token, so
# the two runners are fully independent. They share the `ubuntu-latest` label,
# but registration SCOPE (which repo each token came from) keeps their jobs
# separate — neither will pick up the other's work.
#
# If project 2 needs different build dependencies, build it its own image from
# an adjusted Containerfile and point Image= at that instead of reusing the
# trx-rs image below.
[Unit]
Description=Gitea Actions runner — project 2
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/gitea-act-runner:latest
ContainerName=gitea-runner-project2
Volume=gitea-runner-project2-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=project2-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
TimeoutStartSec=0
[Install]
WantedBy=default.target
+41
View File
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the trx-rs Gitea Actions runner.
# Install to ~/.config/containers/systemd/trx-rs-runner.container then:
# systemctl --user daemon-reload
# systemctl --user start trx-rs-runner
#
# First boot only: paste a registration token (repo Settings -> Actions ->
# Runners) into GITEA_RUNNER_REGISTRATION_TOKEN. After the runner appears
# online you can blank it again — the registration is persisted in the volume.
[Unit]
Description=Gitea Actions runner — trx-rs
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/trx-rs-ci:latest
ContainerName=gitea-runner-trx-rs
# Persistent state: .runner registration, cache, workspaces.
Volume=gitea-runner-trx-rs-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=trx-rs-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
# Resource caps so a heavy Rust build here cannot starve the other project's
# runner on the same host. Tune to your box.
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
# A cold Rust build can be slow; don't let systemd consider startup failed.
TimeoutStartSec=0
[Install]
WantedBy=default.target
+1 -1
View File
@@ -243,7 +243,7 @@ fn parse_frame(frame: RawFrame, channel: &str) -> Option<AisMessage> {
let message_type = get_uint(&bits, 0, 6)? as u8; let message_type = get_uint(&bits, 0, 6)? as u8;
let repeat = get_uint(&bits, 6, 2)? as u8; let repeat = get_uint(&bits, 6, 2)? as u8;
let mmsi = get_uint(&bits, 8, 30)? as u32; let mmsi = get_uint(&bits, 8, 30)?;
let mut msg = AisMessage { let mut msg = AisMessage {
rig_id: None, rig_id: None,
+5 -5
View File
@@ -638,7 +638,7 @@ mod tests {
for (i, &ch) in b"N0CALL".iter().enumerate() { for (i, &ch) in b"N0CALL".iter().enumerate() {
addr[i] = ch << 1; addr[i] = ch << 1;
} }
addr[6] = (0 << 1) | 1; // SSID=0, last=true addr[6] = 1; // SSID=0, last=true
let decoded = decode_ax25_address(&addr, 0); let decoded = decode_ax25_address(&addr, 0);
assert_eq!(decoded.call, "N0CALL"); assert_eq!(decoded.call, "N0CALL");
@@ -652,7 +652,7 @@ mod tests {
for (i, &ch) in b"SP2SJG".iter().enumerate() { for (i, &ch) in b"SP2SJG".iter().enumerate() {
addr[i] = ch << 1; addr[i] = ch << 1;
} }
addr[6] = (5 << 1) | 0; // SSID=5, last=false addr[6] = 5 << 1; // SSID=5, last=false
let decoded = decode_ax25_address(&addr, 0); let decoded = decode_ax25_address(&addr, 0);
assert_eq!(decoded.call, "SP2SJG"); assert_eq!(decoded.call, "SP2SJG");
@@ -667,7 +667,7 @@ mod tests {
for (i, &ch) in b"W1AW ".iter().enumerate() { for (i, &ch) in b"W1AW ".iter().enumerate() {
addr[i] = ch << 1; addr[i] = ch << 1;
} }
addr[6] = (0 << 1) | 1; addr[6] = 1;
let decoded = decode_ax25_address(&addr, 0); let decoded = decode_ax25_address(&addr, 0);
assert_eq!(decoded.call, "W1AW"); assert_eq!(decoded.call, "W1AW");
@@ -691,8 +691,8 @@ mod tests {
for &ch in src_bytes.as_bytes().iter().take(6) { for &ch in src_bytes.as_bytes().iter().take(6) {
frame.push(ch << 1); frame.push(ch << 1);
} }
frame.push((0 << 1) | 1); // SSID=0, last=true frame.push(1); // SSID=0, last=true
// Control + PID // Control + PID
frame.push(0x03); // UI frame frame.push(0x03); // UI frame
frame.push(0xF0); // No layer-3 protocol frame.push(0xF0); // No layer-3 protocol
// Info field // Info field
@@ -160,18 +160,14 @@ impl CallsignHashTable {
let mut idx = start_idx; let mut idx = start_idx;
loop { loop {
match &self.entries[idx] { let entry = self.entries[idx].as_ref()?;
Some(entry) => { let stored = (entry.hash & HASH22_MASK) >> shift;
let stored = (entry.hash & HASH22_MASK) >> shift; if stored == target {
if stored == target { return Some(entry.callsign.clone());
return Some(entry.callsign.clone()); }
} idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE; if idx == start_idx {
if idx == start_idx { return None;
return None;
}
}
None => return None,
} }
} }
} }
+12 -12
View File
@@ -42,8 +42,8 @@ pub(crate) fn ldpc_check(codeword: &[u8; FTX_LDPC_N]) -> i32 {
for m in 0..FTX_LDPC_M { for m in 0..FTX_LDPC_M {
let mut x: u8 = 0; let mut x: u8 = 0;
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize; let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
for i in 0..num_rows { for &nm in FTX_LDPC_NM[m].iter().take(num_rows) {
x ^= codeword[FTX_LDPC_NM[m][i] as usize - 1]; x ^= codeword[nm as usize - 1];
} }
if x != 0 { if x != 0 {
errors += 1; errors += 1;
@@ -81,11 +81,11 @@ pub fn ldpc_decode(
for j in 0..FTX_LDPC_M { for j in 0..FTX_LDPC_M {
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize; let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
let m_row = j * FTX_LDPC_N; let m_row = j * FTX_LDPC_N;
for ii1 in 0..num_rows { for &nm1 in FTX_LDPC_NM[j].iter().take(num_rows) {
let i1 = FTX_LDPC_NM[j][ii1] as usize - 1; let i1 = nm1 as usize - 1;
let mut a = 1.0f32; let mut a = 1.0f32;
for ii2 in 0..num_rows { for &nm2 in FTX_LDPC_NM[j].iter().take(num_rows) {
let i2 = FTX_LDPC_NM[j][ii2] as usize - 1; let i2 = nm2 as usize - 1;
if i2 != i1 { if i2 != i1 {
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32); a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
} }
@@ -97,8 +97,8 @@ pub fn ldpc_decode(
// Hard decisions // Hard decisions
for i in 0..FTX_LDPC_N { for i in 0..FTX_LDPC_N {
let mut l = codeword[i]; let mut l = codeword[i];
for j in 0..3 { for &mn in FTX_LDPC_MN[i].iter().take(3) {
l += e_matrix[(FTX_LDPC_MN[i][j] as usize - 1) * FTX_LDPC_N + i]; l += e_matrix[(mn as usize - 1) * FTX_LDPC_N + i];
} }
plain[i] = if l > 0.0 { 1 } else { 0 }; plain[i] = if l > 0.0 { 1 } else { 0 };
} }
@@ -113,12 +113,12 @@ pub fn ldpc_decode(
// Update m[][] from e[][] // Update m[][] from e[][]
for i in 0..FTX_LDPC_N { for i in 0..FTX_LDPC_N {
for ji1 in 0..3 { for (ji1, &mn1) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
let j1 = FTX_LDPC_MN[i][ji1] as usize - 1; let j1 = mn1 as usize - 1;
let mut l = codeword[i]; let mut l = codeword[i];
for ji2 in 0..3 { for (ji2, &mn2) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
if ji1 != ji2 { if ji1 != ji2 {
let j2 = FTX_LDPC_MN[i][ji2] as usize - 1; let j2 = mn2 as usize - 1;
l += e_matrix[j2 * FTX_LDPC_N + i]; l += e_matrix[j2 * FTX_LDPC_N + i];
} }
} }
+4 -8
View File
@@ -64,10 +64,8 @@ pub fn charn(mut c: i32, table: CharTable) -> char {
return EXTRAS[c as usize]; return EXTRAS[c as usize];
} }
} }
CharTable::AlphanumSpaceSlash => { CharTable::AlphanumSpaceSlash if c == 0 => {
if c == 0 { return '/';
return '/';
}
} }
_ => {} _ => {}
} }
@@ -116,10 +114,8 @@ pub fn nchar(c: char, table: CharTable) -> Option<i32> {
'?' => return Some(n + 4), '?' => return Some(n + 4),
_ => {} _ => {}
}, },
CharTable::AlphanumSpaceSlash => { CharTable::AlphanumSpaceSlash if c == '/' => {
if c == '/' { return Some(n);
return Some(n);
}
} }
_ => {} _ => {}
} }
+13 -28
View File
@@ -632,32 +632,17 @@ impl Candidate {
} }
let segment = usize::from((block_b & 0x0003) as u8); let segment = usize::from((block_b & 0x0003) as u8);
let di = ((block_b >> 2) & 0x1) != 0; let di = ((block_b >> 2) & 0x1) != 0;
match segment { let di_flag = Some(di);
0 => { let slot = match segment {
if self.state.dynamic_pty != Some(di) { 0 => &mut self.state.dynamic_pty,
self.state.dynamic_pty = Some(di); 1 => &mut self.state.compressed,
changed = true; 2 => &mut self.state.artificial_head,
} 3 => &mut self.state.stereo,
} _ => unreachable!("segment is masked to two bits"),
1 => { };
if self.state.compressed != Some(di) { if *slot != di_flag {
self.state.compressed = Some(di); *slot = di_flag;
changed = true; changed = true;
}
}
2 => {
if self.state.artificial_head != Some(di) {
self.state.artificial_head = Some(di);
changed = true;
}
}
3 => {
if self.state.stereo != Some(di) {
self.state.stereo = Some(di);
changed = true;
}
}
_ => {}
} }
let [b0, b1] = block_d.to_be_bytes(); let [b0, b1] = block_d.to_be_bytes();
self.ps_bytes[segment * 2] = sanitize_text_byte(b0); self.ps_bytes[segment * 2] = sanitize_text_byte(b0);
@@ -1458,9 +1443,9 @@ mod tests {
} }
// BPSK modulate onto the 57 kHz subcarrier. // BPSK modulate onto the 57 kHz subcarrier.
for t in 0..n { for (t, sample) in shaped.iter_mut().enumerate().take(n) {
let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate; let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
shaped[t] *= phase.cos(); *sample *= phase.cos();
} }
shaped shaped
} }
+1 -3
View File
@@ -134,9 +134,7 @@ mod tests {
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1)) .flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
.collect(); .collect();
// Append wrong CRC // Append wrong CRC
for _ in 0..16 { bits.resize(bits.len() + 16, 0);
bits.push(0);
}
assert!(!check_crc16(&bits)); assert!(!check_crc16(&bits));
} }
+2 -2
View File
@@ -346,8 +346,8 @@ mod tests {
write_bits(&mut bits, 12, 32, 123456); // source_id write_bits(&mut bits, 12, 32, 123456); // source_id
write_bits(&mut bits, 44, 11, 20); // data_count = 20 write_bits(&mut bits, 44, 11, 20); // data_count = 20
// Fill some payload // Fill some payload
for i in 55..75 { for (i, bit) in bits.iter_mut().enumerate().take(75).skip(55) {
bits[i] = (i % 2) as u8; *bit = (i % 2) as u8;
} }
append_crc(&mut bits); append_crc(&mut bits);
+2 -2
View File
@@ -374,8 +374,8 @@ mod tests {
let (y, m, d, h, mi, _) = unix_to_utc(1775055000); let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
assert_eq!(y, 2026); assert_eq!(y, 2026);
// Just verify reasonable values without asserting exact date. // Just verify reasonable values without asserting exact date.
assert!(m >= 1 && m <= 12); assert!((1..=12).contains(&m));
assert!(d >= 1 && d <= 31); assert!((1..=31).contains(&d));
assert!(h < 24); assert!(h < 24);
assert!(mi < 60); assert!(mi < 60);
} }
+2 -4
View File
@@ -161,10 +161,8 @@ mod tests {
for line_idx in 0..20 { for line_idx in 0..20 {
let mut line = vec![1.0f32; spl]; let mut line = vec![1.0f32; spl];
for j in pulse_start..pulse_start + pw { for slot in line.iter_mut().skip(pulse_start).take(pw) {
if j < spl { *slot = 0.0;
line[j] = 0.0;
}
} }
let result = det.process(&line); let result = det.process(&line);
if let Some(offset) = result { if let Some(offset) = result {
+3 -3
View File
@@ -483,7 +483,7 @@ mod tests {
let c4 = idx27(b'T'); let c4 = idx27(b'T');
let c5 = idx27(b' '); let c5 = idx27(b' ');
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5; let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13 + 0; // FN20 let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13; // FN20 (final term is 0)
let power_code = 37u32; let power_code = 37u32;
let mut input_bits = [0u8; NBITS]; let mut input_bits = [0u8; NBITS];
@@ -530,8 +530,8 @@ mod tests {
fn interleave_deinterleave_roundtrip() { fn interleave_deinterleave_roundtrip() {
// Create a sequence of distinguishable values // Create a sequence of distinguishable values
let mut original = [0u8; NSYMS]; let mut original = [0u8; NSYMS];
for i in 0..NSYMS { for (i, slot) in original.iter_mut().enumerate() {
original[i] = (i % 256) as u8; *slot = (i % 256) as u8;
} }
let interleaved = interleave(&original); let interleaved = interleave(&original);
+53 -53
View File
@@ -356,59 +356,6 @@ async fn run_single_rig_audio_client(
} }
} }
#[cfg(test)]
mod tests {
use super::{resolve_audio_addr, AudioConnectConfig};
use std::collections::HashMap;
#[test]
fn resolve_audio_addr_prefers_fixed_url() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4531),
&rig_connect,
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
assert_eq!(addr, "audio.example.com:4700");
}
#[test]
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4600),
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "control.example.com:4600");
}
#[test]
fn resolve_audio_addr_falls_back_to_default_port() {
let rig_connect = HashMap::new();
let addr = resolve_audio_addr(
"home-hf",
None,
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "fallback.example.com:4531");
}
}
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection` /// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
/// but publishes to per-rig channels directly and mirrors to global when selected. /// but publishes to per-rig channels directly and mirrors to global when selected.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -767,3 +714,56 @@ async fn handle_single_rig_connection(
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::{resolve_audio_addr, AudioConnectConfig};
use std::collections::HashMap;
#[test]
fn resolve_audio_addr_prefers_fixed_url() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4531),
&rig_connect,
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
assert_eq!(addr, "audio.example.com:4700");
}
#[test]
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4600),
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "control.example.com:4600");
}
#[test]
fn resolve_audio_addr_falls_back_to_default_port() {
let rig_connect = HashMap::new();
let addr = resolve_audio_addr(
"home-hf",
None,
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "fallback.example.com:4531");
}
}
+49 -41
View File
@@ -1110,36 +1110,40 @@ url = "remote.example.com:4530"
#[test] #[test]
fn test_validate_rejects_duplicate_remote_names() { fn test_validate_rejects_duplicate_remote_names() {
let mut config = ClientConfig::default(); let config = ClientConfig {
config.remotes = vec![ remotes: vec![
RemoteEntry { RemoteEntry {
name: "dup".to_string(), name: "dup".to_string(),
url: "a:4530".to_string(), url: "a:4530".to_string(),
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
}, },
RemoteEntry { RemoteEntry {
name: "dup".to_string(), name: "dup".to_string(),
url: "b:4530".to_string(), url: "b:4530".to_string(),
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
}, },
]; ],
..Default::default()
};
assert!(config.validate().unwrap_err().contains("duplicate name")); assert!(config.validate().unwrap_err().contains("duplicate name"));
} }
#[test] #[test]
fn test_validate_rejects_empty_remote_name() { fn test_validate_rejects_empty_remote_name() {
let mut config = ClientConfig::default(); let config = ClientConfig {
config.remotes = vec![RemoteEntry { remotes: vec![RemoteEntry {
name: "".to_string(), name: "".to_string(),
url: "a:4530".to_string(), url: "a:4530".to_string(),
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
}]; }],
..Default::default()
};
assert!(config assert!(config
.validate() .validate()
.unwrap_err() .unwrap_err()
@@ -1148,14 +1152,16 @@ url = "remote.example.com:4530"
#[test] #[test]
fn test_validate_rejects_empty_remote_url() { fn test_validate_rejects_empty_remote_url() {
let mut config = ClientConfig::default(); let config = ClientConfig {
config.remotes = vec![RemoteEntry { remotes: vec![RemoteEntry {
name: "hf".to_string(), name: "hf".to_string(),
url: " ".to_string(), url: " ".to_string(),
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
}]; }],
..Default::default()
};
assert!(config assert!(config
.validate() .validate()
.unwrap_err() .unwrap_err()
@@ -1164,14 +1170,16 @@ url = "remote.example.com:4530"
#[test] #[test]
fn test_validate_rejects_zero_remote_poll_interval() { fn test_validate_rejects_zero_remote_poll_interval() {
let mut config = ClientConfig::default(); let config = ClientConfig {
config.remotes = vec![RemoteEntry { remotes: vec![RemoteEntry {
name: "hf".to_string(), name: "hf".to_string(),
url: "a:4530".to_string(), url: "a:4530".to_string(),
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 0, poll_interval_ms: 0,
}]; }],
..Default::default()
};
assert!(config assert!(config
.validate() .validate()
.unwrap_err() .unwrap_err()
+1 -1
View File
@@ -1678,7 +1678,7 @@ mod tests {
#[test] #[test]
fn global_target_for_snapshot_skips_other_server_selection() { fn global_target_for_snapshot_skips_other_server_selection() {
let snapshot = sample_snapshot(); let snapshot = sample_snapshot();
let rigs = vec![RigEntry { let rigs = [RigEntry {
rig_id: "hf".to_string(), rig_id: "hf".to_string(),
display_name: Some("Gdansk HF".to_string()), display_name: Some("Gdansk HF".to_string()),
state: snapshot, state: snapshot,
@@ -104,24 +104,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<!-- Auth gate (hidden by default, shown if auth is required) --> <!-- Auth gate (hidden by default, shown if auth is required) -->
<div id="auth-gate" style="display:none; max-width: 30rem; margin: 0 auto 0.9rem; padding: 1.25rem 0 1.5rem; text-align: center;"> <div id="auth-gate" class="auth-gate" style="display:none;">
<div style="margin-bottom: 1.5rem;"> <div class="auth-gate-head">
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">Access Required</div> <div class="auth-gate-title">Access Required</div>
<div style="color: var(--text-muted);">Enter passphrase to continue</div> <div class="auth-gate-sub">Enter passphrase to continue</div>
</div> </div>
<form id="auth-form" style="margin: 1rem 0 0.35rem;"> <form id="auth-form" class="auth-form">
<input type="password" id="auth-passphrase" placeholder="Passphrase" autocomplete="off" style="width: 100%; padding: 0.65rem 0.75rem; margin-bottom: 1rem; border: 1px solid var(--border-light); border-radius: 0.45rem; background: var(--input-bg); color: var(--text); font-size: 1rem; box-sizing: border-box;" /> <input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
<button type="submit" style="width: 100%; padding: 0.65rem 0.75rem; background: var(--accent-green); color: #fff; border: none; border-radius: 0.45rem; font-weight: 700; cursor: pointer; font-size: 1rem; box-sizing: border-box;">Login</button> <button type="submit" class="auth-submit">Login</button>
</form> </form>
<button id="auth-guest-btn" type="button" style="width: 100%; padding: 0.65rem 0.75rem; background: var(--btn-bg); color: var(--text); border: 1px solid var(--border-light); border-radius: 0.45rem; font-weight: 600; cursor: pointer; font-size: 1rem; box-sizing: border-box; margin-top: 0; display: none;">Continue as Guest</button> <button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
<div id="auth-error" style="color: #ff6b6b; font-size: 0.9rem; margin-top: 1rem; display: none;"></div> <div id="auth-error" class="auth-error" style="display: none;"></div>
<div id="auth-role" style="margin-top: 1rem; color: var(--text-muted); font-size: 0.85rem; display: none;"></div> <div id="auth-role" class="auth-role" style="display: none;"></div>
</div> </div>
<div id="tab-main" class="tab-panel"> <div id="tab-main" class="tab-panel">
<div id="server-lost-banner" aria-live="assertive"><span class="banner-dot"></span>trx-server connection lost — waiting for reconnect</div> <div id="server-lost-banner" aria-live="assertive"><span class="banner-dot"></span>trx-server connection lost — waiting for reconnect</div>
<div id="loading" role="status" aria-live="polite" style="text-align:center; padding:2rem 0;"> <div id="loading" role="status" aria-live="polite" style="text-align:center; padding:2rem 0;">
<div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div> <div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div>
<div id="loading-sub" style="color:#9aa4b5;"></div> <div id="loading-sub" style="color:var(--text-muted);"></div>
</div> </div>
<div id="content" style="display:none;"> <div id="content" style="display:none;">
<div class="signal-visual-block"> <div class="signal-visual-block">
@@ -1624,6 +1624,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script> <script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
<script defer src="/vendor/leaflet.js"></script>
<script defer src="/leaflet-ais-tracksymbol.js"></script>
<script defer src="/webgl-renderer.js"></script> <script defer src="/webgl-renderer.js"></script>
<script defer src="/app.js"></script> <script defer src="/app.js"></script>
<script> <script>
@@ -1632,7 +1634,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
var pluginScripts = { var pluginScripts = {
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'], 'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'],
'map-data': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js'], 'map-data': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js'],
'map': ['/map-core.js', '/leaflet-ais-tracksymbol.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'], 'map': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
'statistics': ['/map-core.js'], 'statistics': ['/map-core.js'],
'bookmarks': ['/bookmarks.js'], 'bookmarks': ['/bookmarks.js'],
'recorder': [], 'recorder': [],
@@ -1667,6 +1669,5 @@ SPDX-License-Identifier: GPL-2.0-or-later
})(); })();
</script> </script>
<!-- Template cloning is handled by navigateToTab() in app.js --> <!-- Template cloning is handled by navigateToTab() in app.js -->
<script defer src="/vendor/leaflet.js"></script>
</body> </body>
</html> </html>
@@ -47,6 +47,50 @@ SPDX-License-Identifier: GPL-2.0-or-later
--border-hover: color-mix(in srgb, var(--border-light) 80%, var(--text) 20%); --border-hover: color-mix(in srgb, var(--border-light) 80%, var(--text) 20%);
--accent-green-hover: color-mix(in srgb, var(--accent-green) 85%, #fff 15%); --accent-green-hover: color-mix(in srgb, var(--accent-green) 85%, #fff 15%);
--accent-green-active: color-mix(in srgb, var(--accent-green) 75%, #fff 25%); --accent-green-active: color-mix(in srgb, var(--accent-green) 75%, #fff 25%);
/* Accent tuned for text/links: mixes toward the theme's text colour,
which is always the higher-contrast direction (lighter on dark
themes, darker on light). Use for accent-coloured text; keep
--accent-green for fills, indicators and meters. */
--accent-text: color-mix(in srgb, var(--accent-green) 82%, var(--text) 18%);
/* ── Design tokens ──────────────────────────────────────────────────
Derived tokens use color-mix over themed variables, so they
re-resolve per active [data-style]/[data-theme] automatically. */
/* Elevated surface, one step lighter than --card-bg. Consumed by
.controls-tray and other raised panels. */
--surface: color-mix(in srgb, var(--card-bg) 90%, var(--text) 7%);
/* Type scale */
--fs-xs: 0.75rem;
--fs-sm: 0.85rem;
--fs-base: 1rem;
--fs-md: 1.125rem;
--fs-lg: 1.25rem;
--fs-xl: 1.5rem;
--fs-2xl: 2rem;
/* Spacing scale (4px grid) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* Corner radii */
--radius-sm: 6px;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-pill: 999px;
/* Motion */
--dur-fast: 100ms;
--dur-base: 180ms;
--dur-slow: 300ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
} }
[data-theme="light"] { [data-theme="light"] {
@@ -89,6 +133,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
body { body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: var(--fs-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
@@ -120,6 +168,57 @@ body {
); );
} }
} }
/* ── Auth gate ────────────────────────────────────────────────────────
Class-based; #display toggling stays inline (driven by app.js). */
.auth-gate {
max-width: 30rem;
margin: 0 auto 0.9rem;
padding: var(--space-5) 0 var(--space-6);
text-align: center;
}
.auth-gate-head { margin-bottom: var(--space-6); }
.auth-gate-title { font-size: var(--fs-md); font-weight: 600; margin-bottom: var(--space-2); }
.auth-gate-sub { color: var(--text-muted); }
.auth-form { margin: var(--space-4) 0 0.35rem; }
.auth-input {
width: 100%;
padding: 0.65rem 0.75rem;
margin-bottom: var(--space-4);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--input-bg);
color: var(--text);
font-size: var(--fs-base);
box-sizing: border-box;
}
.auth-submit,
.auth-guest {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-md);
font-size: var(--fs-base);
cursor: pointer;
box-sizing: border-box;
transition: background-color var(--dur-fast) var(--ease-standard);
}
.auth-submit {
background: var(--accent-green);
color: #fff;
border: none;
font-weight: 700;
}
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
.auth-guest {
background: var(--btn-bg);
color: var(--text);
border: 1px solid var(--border-light);
font-weight: 600;
margin-top: var(--space-4);
}
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; } .label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
#tab-main .label > span { #tab-main .label > span {
display: inline-block; display: inline-block;
@@ -220,7 +319,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
align-items: stretch; align-items: stretch;
gap: 0.22rem; gap: 0.22rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.72rem; font-size: 0.75rem;
line-height: 1.1; line-height: 1.1;
white-space: nowrap; white-space: nowrap;
} }
@@ -408,7 +507,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
border-radius: 0; border-radius: 0;
height: 100%; height: 100%;
padding: 0 0.65rem; padding: 0 0.65rem;
font-size: 0.92rem; font-size: 0.9rem;
background: var(--input-bg); background: var(--input-bg);
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
@@ -486,7 +585,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
} }
.scheduler-cycle-status { .scheduler-cycle-status {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.74rem; font-size: 0.75rem;
text-align: left; text-align: left;
opacity: 0.88; opacity: 0.88;
} }
@@ -1357,7 +1456,7 @@ small { color: var(--text-muted); }
} }
.header-logo { height: 4.6em; width: auto; flex-shrink: 0; filter: drop-shadow(0 4px 12px rgba(0,0,0,0.35)); } .header-logo { height: 4.6em; width: auto; flex-shrink: 0; filter: drop-shadow(0 4px 12px rgba(0,0,0,0.35)); }
.subtitle { color: var(--text-muted); font-size: 0.95rem; } .subtitle { color: var(--text-muted); font-size: 0.95rem; }
.subtitle a { color: var(--accent-green); text-decoration: none; } .subtitle a { color: var(--accent-text); text-decoration: none; }
.subtitle a:hover { text-decoration: underline; } .subtitle a:hover { text-decoration: underline; }
.band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: var(--btn-bg); color: var(--text); font-size: 0.82rem; border: 1px solid var(--border-light); margin-left: 6px; } .band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: var(--btn-bg); color: var(--text); font-size: 0.82rem; border: 1px solid var(--border-light); margin-left: 6px; }
.signal { display: flex; gap: 0.6rem; align-items: center; } .signal { display: flex; gap: 0.6rem; align-items: center; }
@@ -1433,7 +1532,7 @@ small { color: var(--text-muted); }
font-size: 0.95rem; font-size: 0.95rem;
height: auto; height: auto;
} }
.tab.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; } .tab.active { border-bottom-color: var(--accent-green); color: var(--accent-text); font-weight: 600; }
.tab:hover:not(.active) { color: var(--text); } .tab:hover:not(.active) { color: var(--text); }
/* Tab icons — hidden on desktop, shown on mobile bottom nav */ /* Tab icons — hidden on desktop, shown on mobile bottom nav */
.tab-icon { .tab-icon {
@@ -1458,7 +1557,7 @@ small { color: var(--text-muted); }
.footer { display: flex; justify-content: space-between; align-items: baseline; margin-top: auto; padding-top: 1rem; flex-shrink: 0; } .footer { display: flex; justify-content: space-between; align-items: baseline; margin-top: auto; padding-top: 1rem; flex-shrink: 0; }
.full-row { grid-column: 1 / -1; } .full-row { grid-column: 1 / -1; }
.copyright { color: var(--text-muted); font-size: 0.75rem; opacity: 0.7; } .copyright { color: var(--text-muted); font-size: 0.75rem; opacity: 0.7; }
.copyright a { color: var(--accent-green); text-decoration: none; } .copyright a { color: var(--accent-text); text-decoration: none; }
.copyright a:hover { text-decoration: underline; } .copyright a:hover { text-decoration: underline; }
.gh-link-wrap { .gh-link-wrap {
display: inline-flex; display: inline-flex;
@@ -1499,7 +1598,7 @@ small { color: var(--text-muted); }
white-space: nowrap; white-space: nowrap;
} }
.vol-pct { .vol-pct {
font-size: 0.72rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
line-height: 1; line-height: 1;
} }
@@ -1602,7 +1701,7 @@ small { color: var(--text-muted); }
} }
.shortcut-table td { .shortcut-table td {
padding: 0.32rem 0.4rem; padding: 0.32rem 0.4rem;
font-size: 0.88rem; font-size: 0.85rem;
color: var(--text); color: var(--text);
border-bottom: 1px solid color-mix(in srgb, var(--border-light) 40%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--border-light) 40%, transparent);
} }
@@ -1632,7 +1731,7 @@ small { color: var(--text-muted); }
.shortcut-overlay-hint { .shortcut-overlay-hint {
margin-top: 0.7rem; margin-top: 0.7rem;
text-align: center; text-align: center;
font-size: 0.76rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -1706,7 +1805,7 @@ small { color: var(--text-muted); }
text-align: center; text-align: center;
} }
.decode-history-overlay-title { .decode-history-overlay-title {
font-size: 0.98rem; font-size: 1rem;
font-weight: 800; font-weight: 800;
color: var(--text-heading); color: var(--text-heading);
} }
@@ -1756,7 +1855,7 @@ small { color: var(--text-muted); }
gap: 0.75rem; gap: 0.75rem;
} }
.map-qso-summary-title { .map-qso-summary-title {
font-size: 0.88rem; font-size: 0.85rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
@@ -1841,7 +1940,7 @@ button.map-qso-card:focus-visible {
border-radius: 999px; border-radius: 999px;
background: color-mix(in srgb, var(--accent-green) 16%, transparent); background: color-mix(in srgb, var(--accent-green) 16%, transparent);
color: var(--accent-green); color: var(--accent-green);
font-size: 0.76rem; font-size: 0.75rem;
font-weight: 800; font-weight: 800;
} }
.map-qso-card-distance { .map-qso-card-distance {
@@ -1858,7 +1957,7 @@ button.map-qso-card:focus-visible {
min-width: 0; min-width: 0;
} }
.map-qso-card-pair { .map-qso-card-pair {
font-size: 0.92rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--text);
white-space: nowrap; white-space: nowrap;
@@ -1948,7 +2047,7 @@ button.map-qso-card:focus-visible {
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent); border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
background: color-mix(in srgb, var(--card-bg) 82%, transparent); background: color-mix(in srgb, var(--card-bg) 82%, transparent);
color: var(--text); color: var(--text);
font-size: 0.76rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
cursor: pointer; cursor: pointer;
@@ -1967,7 +2066,7 @@ button.map-qso-card:focus-visible {
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent); border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
background: color-mix(in srgb, var(--card-bg) 82%, transparent); background: color-mix(in srgb, var(--card-bg) 82%, transparent);
color: var(--text); color: var(--text);
font-size: 0.76rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
cursor: pointer; cursor: pointer;
@@ -2000,7 +2099,7 @@ button.map-qso-card:focus-visible {
display: none; display: none;
} }
.map-band-legend-title { .map-band-legend-title {
font-size: 0.72rem; font-size: 0.75rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
@@ -2027,7 +2126,7 @@ button.map-qso-card:focus-visible {
} }
.map-band-legend-text { .map-band-legend-text {
min-width: 0; min-width: 0;
font-size: 0.76rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--text);
white-space: nowrap; white-space: nowrap;
@@ -2084,7 +2183,7 @@ body.map-fake-fullscreen-active {
} }
.aprs-summary-label { .aprs-summary-label {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.72rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
@@ -2142,7 +2241,7 @@ body.map-fake-fullscreen-active {
} }
.ais-summary-label { .ais-summary-label {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.72rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
@@ -2260,7 +2359,7 @@ body.map-fake-fullscreen-active {
} }
.aprs-meta-text { .aprs-meta-text {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.76rem; font-size: 0.75rem;
} }
.aprs-inline-btn { .aprs-inline-btn {
display: inline-flex; display: inline-flex;
@@ -2273,7 +2372,7 @@ body.map-fake-fullscreen-active {
background: var(--filter-bg); background: var(--filter-bg);
color: var(--filter-fg); color: var(--filter-fg);
font: inherit; font: inherit;
font-size: 0.74rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
} }
.aprs-inline-btn:hover { .aprs-inline-btn:hover {
@@ -2286,7 +2385,7 @@ body.map-fake-fullscreen-active {
.aprs-details summary { .aprs-details summary {
cursor: pointer; cursor: pointer;
color: var(--accent-green); color: var(--accent-green);
font-size: 0.76rem; font-size: 0.75rem;
user-select: none; user-select: none;
} }
.aprs-details[open] summary { .aprs-details[open] summary {
@@ -2364,7 +2463,7 @@ body.map-fake-fullscreen-active {
} }
.ais-meta-text { .ais-meta-text {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.76rem; font-size: 0.75rem;
} }
.ais-pos-link { .ais-pos-link {
color: var(--accent-red); color: var(--accent-red);
@@ -2406,7 +2505,7 @@ body.map-fake-fullscreen-active {
} }
.vdes-raw { .vdes-raw {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.76rem; font-size: 0.75rem;
word-break: break-all; word-break: break-all;
} }
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; } .aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
@@ -2465,18 +2564,18 @@ body.map-fake-fullscreen-active {
} }
.decode-locator-tip-meta { .decode-locator-tip-meta {
margin-top: 0.12rem; margin-top: 0.12rem;
font-size: 0.74rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
} }
.decode-locator-tip-rx { .decode-locator-tip-rx {
margin-top: 0.12rem; margin-top: 0.12rem;
font-size: 0.72rem; font-size: 0.75rem;
color: var(--accent-yellow); color: var(--accent-yellow);
opacity: 0.85; opacity: 0.85;
} }
.decode-locator-tip-note { .decode-locator-tip-note {
margin-top: 0.2rem; margin-top: 0.2rem;
font-size: 0.76rem; font-size: 0.75rem;
line-height: 1.3; line-height: 1.3;
color: var(--text); color: var(--text);
word-break: break-word; word-break: break-word;
@@ -2503,7 +2602,7 @@ body.map-fake-fullscreen-active {
background: color-mix(in srgb, var(--card-bg) 88%, transparent); background: color-mix(in srgb, var(--card-bg) 88%, transparent);
color: color-mix(in srgb, var(--accent-green) 72%, var(--accent-yellow)); color: color-mix(in srgb, var(--accent-green) 72%, var(--accent-yellow));
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22); box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
font-size: 0.72rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.03em; letter-spacing: 0.03em;
white-space: nowrap; white-space: nowrap;
@@ -2576,7 +2675,7 @@ body.map-fake-fullscreen-active {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 4.4rem; min-width: 4.4rem;
padding-top: 0.35rem; padding-top: 0.35rem;
font-size: 0.72rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.03em; letter-spacing: 0.03em;
text-transform: uppercase; text-transform: uppercase;
@@ -2604,7 +2703,7 @@ body.map-fake-fullscreen-active {
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent); border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
background: color-mix(in srgb, var(--input-bg) 92%, transparent); background: color-mix(in srgb, var(--input-bg) 92%, transparent);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.76rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-transform: uppercase; text-transform: uppercase;
@@ -2621,7 +2720,7 @@ body.map-fake-fullscreen-active {
color: var(--accent-green); color: var(--accent-green);
} }
.map-locator-empty { .map-locator-empty {
font-size: 0.74rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
opacity: 0.8; opacity: 0.8;
align-self: flex-start; align-self: flex-start;
@@ -2655,7 +2754,7 @@ body.map-fake-fullscreen-active {
background: color-mix(in srgb, var(--input-bg) 96%, transparent); background: color-mix(in srgb, var(--input-bg) 96%, transparent);
} }
.map-locator-chip-text { .map-locator-chip-text {
font-size: 0.77rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
} }
.map-search-input { .map-search-input {
@@ -2712,7 +2811,7 @@ body.map-fake-fullscreen-active {
.rds-raw-header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.3rem; } .rds-raw-header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.3rem; }
.rds-raw-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 0.3rem; } .rds-raw-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 0.3rem; }
.rds-raw-header .rds-raw-label { margin-bottom: 0; } .rds-raw-header .rds-raw-label { margin-bottom: 0; }
#rds-raw-copy-btn { height: 1.65rem; padding: 0 0.55rem; font-size: 0.72rem; } #rds-raw-copy-btn { height: 1.65rem; padding: 0 0.55rem; font-size: 0.75rem; }
.rds-raw { background: var(--input-bg); border: 1px solid var(--border-light); border-radius: 6px; padding: 0.5rem 0.6rem; font-size: 0.8rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--text); white-space: pre; overflow-x: auto; margin: 0; } .rds-raw { background: var(--input-bg); border: 1px solid var(--border-light); border-radius: 6px; padding: 0.5rem 0.6rem; font-size: 0.8rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--text); white-space: pre; overflow-x: auto; margin: 0; }
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; } .cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
@@ -2733,7 +2832,9 @@ body.map-fake-fullscreen-active {
.cw-config input[type="number"][readonly] { opacity: 0.6; } .cw-config input[type="number"][readonly] { opacity: 0.6; }
.cw-config input[type="number"]:disabled { opacity: 0.58; } .cw-config input[type="number"]:disabled { opacity: 0.58; }
button:focus-visible, input:focus-visible, select:focus-visible { button:focus-visible, input:focus-visible, select:focus-visible,
textarea:focus-visible, a:focus-visible,
canvas:focus-visible, [tabindex]:focus-visible {
outline: 2px solid var(--accent-green); outline: 2px solid var(--accent-green);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -2844,7 +2945,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
} }
/* Collapse verbose subtitles — the About tab has this info */ /* Collapse verbose subtitles — the About tab has this info */
.tab-bar .subtitle { display: none; } .tab-bar .subtitle { display: none; }
.tab-bar .title { font-size: 0.92rem; } .tab-bar .title { font-size: 0.9rem; }
.tab-bar-nav { .tab-bar-nav {
position: fixed; position: fixed;
left: calc(0.6rem + env(safe-area-inset-left)); left: calc(0.6rem + env(safe-area-inset-left));
@@ -3506,7 +3607,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
will-change: transform; will-change: transform;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
font-size: 0.66rem; font-size: 0.65rem;
background: var(--bm-cat-bg, var(--accent-yellow)); background: var(--bm-cat-bg, var(--accent-yellow));
color: var(--bm-cat-fg, #1a202c); color: var(--bm-cat-fg, #1a202c);
border: 1px solid var(--bm-cat-bg, var(--accent-yellow)); border: 1px solid var(--bm-cat-bg, var(--accent-yellow));
@@ -3598,7 +3699,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
} }
.spectrum-bookmark-chip-side .spectrum-bookmark-name { .spectrum-bookmark-chip-side .spectrum-bookmark-name {
width: 100%; width: 100%;
font-size: 0.64rem; font-size: 0.65rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -3662,7 +3763,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
height: 1.5rem; height: 1.5rem;
min-height: 0; min-height: 0;
padding: 0 8px; padding: 0 8px;
font-size: 0.73rem; font-size: 0.75rem;
} }
#spectrum-level-row { #spectrum-level-row {
display: flex; display: flex;
@@ -3709,7 +3810,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
height: 1.5rem; height: 1.5rem;
min-height: 0; min-height: 0;
padding: 0 8px; padding: 0 8px;
font-size: 0.73rem; font-size: 0.75rem;
} }
#spectrum-gamma-label { #spectrum-gamma-label {
display: flex; display: flex;
@@ -3724,7 +3825,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
cursor: pointer; cursor: pointer;
} }
#spectrum-gamma-value { #spectrum-gamma-value {
font-size: 0.72rem; font-size: 0.75rem;
min-width: 1.6rem; min-width: 1.6rem;
text-align: right; text-align: right;
} }
@@ -3882,7 +3983,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.bm-label input, .bm-label input,
.bm-label select { .bm-label select {
font-size: 0.88rem; font-size: 0.85rem;
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
} }
@@ -3938,7 +4039,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
color: var(--text); color: var(--text);
font-size: 0.84rem; font-size: 0.85rem;
} }
.bm-decoder-check input { .bm-decoder-check input {
@@ -4048,7 +4149,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.bm-page-summary, .bm-page-summary,
.bm-page-indicator { .bm-page-indicator {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.88rem; font-size: 0.85rem;
} }
.bm-page-controls { .bm-page-controls {
display: flex; display: flex;
@@ -4266,7 +4367,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
color: var(--text); color: var(--text);
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.55rem 1rem; padding: 0.55rem 1rem;
font-size: 0.88rem; font-size: 0.85rem;
} }
#sch-sat-form-cancel:hover { #sch-sat-form-cancel:hover {
background: var(--border); background: var(--border);
@@ -4478,7 +4579,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.sch-log-action { .sch-log-action {
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.72rem; font-size: 0.75rem;
} }
.sch-log-bm { .sch-log-bm {
color: var(--accent); color: var(--accent);
@@ -4687,7 +4788,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
} }
.bgd-checklist-meta { .bgd-checklist-meta {
margin-left: 0; margin-left: 0;
font-size: 0.72rem; font-size: 0.75rem;
} }
} }
/* ── SAT panel ──────────────────────────────────────────────────────── */ /* ── SAT panel ──────────────────────────────────────────────────────── */
@@ -4756,7 +4857,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
gap: 0.25rem; gap: 0.25rem;
} }
.stats-control-label { .stats-control-label {
font-size: 0.72rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -4796,7 +4897,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
line-height: 1.1; line-height: 1.1;
} }
.stats-counter-label { .stats-counter-label {
font-size: 0.74rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -4819,7 +4920,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
gap: 0.18rem; gap: 0.18rem;
} }
.stats-section-title { .stats-section-title {
font-size: 0.88rem; font-size: 0.85rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
@@ -4884,7 +4985,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
#tab-statistics { padding: 0.75rem; } #tab-statistics { padding: 0.75rem; }
.stats-counters { grid-template-columns: repeat(2, 1fr); } .stats-counters { grid-template-columns: repeat(2, 1fr); }
.stats-counter-value { font-size: 1.3rem; } .stats-counter-value { font-size: 1.3rem; }
.stats-bar-label { min-width: 4rem; font-size: 0.72rem; } .stats-bar-label { min-width: 4rem; font-size: 0.75rem; }
.stats-section { padding: 0.8rem 0.85rem 0.9rem; } .stats-section { padding: 0.8rem 0.85rem 0.9rem; }
} }
@@ -4945,3 +5046,18 @@ button:focus-visible, input:focus-visible, select:focus-visible {
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
} }
} }
/* ── Panel-switch fade ────────────────────────────────────────────────
Panels are shown by clearing inline display (app.js), so this fires on
every tab/sub-tab change. Opacity-only to avoid transforming canvases
and the Leaflet map. Gated so reduced-motion users get instant switches. */
@media (prefers-reduced-motion: no-preference) {
.tab-panel,
.sub-tab-panel {
animation: trx-fade-in var(--dur-base) var(--ease-out);
}
}
@keyframes trx-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
+22 -4
View File
@@ -185,20 +185,38 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
let _ = db.dump(); let _ = db.dump();
} }
fn flush_all_rigs(db: &Mutex<PickleDb>, rig_histories: &[(String, Arc<DecoderHistories>)]) {
let Ok(mut guard) = db.lock() else {
tracing::warn!("history database mutex poisoned; skipping periodic flush");
return;
};
for (rig_id, histories) in rig_histories {
flush_all(&mut guard, rig_id, histories);
}
}
/// Spawn a Tokio task that flushes all rigs' histories to disk every 60 seconds. /// Spawn a Tokio task that flushes all rigs' histories to disk every 60 seconds.
///
/// Snapshot cloning, JSON serialization, and disk I/O run on Tokio's blocking
/// pool so a large history database cannot stall an async runtime worker.
pub fn spawn_flush_task( pub fn spawn_flush_task(
db: Arc<Mutex<PickleDb>>, db: Arc<Mutex<PickleDb>>,
rig_histories: Vec<(String, Arc<DecoderHistories>)>, rig_histories: Vec<(String, Arc<DecoderHistories>)>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
let rig_histories = Arc::new(rig_histories);
let mut interval = tokio::time::interval(Duration::from_secs(60)); let mut interval = tokio::time::interval(Duration::from_secs(60));
interval.tick().await; // consume the immediate first tick interval.tick().await; // consume the immediate first tick
loop { loop {
interval.tick().await; interval.tick().await;
if let Ok(mut guard) = db.lock() { let db = Arc::clone(&db);
for (rig_id, histories) in &rig_histories { let rig_histories = Arc::clone(&rig_histories);
flush_all(&mut guard, rig_id, histories); if let Err(err) = tokio::task::spawn_blocking(move || {
} flush_all_rigs(&db, rig_histories.as_slice());
})
.await
{
tracing::warn!(error = %err, "history flush worker failed");
} }
} }
}); });
@@ -1596,7 +1596,7 @@ mod tests {
let t = i as f32 / fs; let t = i as f32 / fs;
adj_phase += adj_mod_index * adj_composite[i]; adj_phase += adj_mod_index * adj_composite[i];
let adj = Complex::from_polar(0.5, adj_phase + TAU * adj_freq_offset * t); let adj = Complex::from_polar(0.5, adj_phase + TAU * adj_freq_offset * t);
*s = *s + adj; *s += adj;
} }
let mut decoder = WfmStereoDecoder::new( let mut decoder = WfmStereoDecoder::new(
@@ -1673,7 +1673,7 @@ mod tests {
// Mix at 70 % of the main signal's amplitude — strong enough to // Mix at 70 % of the main signal's amplitude — strong enough to
// overcome the FM capture effect and visibly degrade pilot coherence. // overcome the FM capture effect and visibly degrade pilot coherence.
for (s, intf) in iq.iter_mut().zip(intf_iq.iter()) { for (s, intf) in iq.iter_mut().zip(intf_iq.iter()) {
*s = *s + intf * 0.7; *s += intf * 0.7;
} }
let mut decoder = WfmStereoDecoder::new( let mut decoder = WfmStereoDecoder::new(