Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7572e215 | ||
|
|
061738a63b | ||
|
|
e8bd97655f | ||
|
|
7d0b36450d | ||
|
|
b12c83e8b5 | ||
|
|
ff4e2a5c5d | ||
|
|
2ef9f80fc1 | ||
|
|
6fe549e34e | ||
|
|
406a8f86b1 | ||
|
|
4b17b4ac1d | ||
|
|
977f7b709e | ||
|
|
a2838b06a2 | ||
|
|
c6cd661676 | ||
|
|
bf08c7ebc0 | ||
|
|
c7470acc1c | ||
|
|
bcde03de38 | ||
|
|
63d46562c7 | ||
|
|
e575b3b365 | ||
|
|
2121817c04 | ||
|
|
d0bd38d7df | ||
|
|
bf9617e24d | ||
|
|
3c235fce5e |
@@ -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
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Sync docs to Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
wiki:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout wiki
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}.wiki
|
||||
path: wiki
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Sync docs to wiki
|
||||
run: |
|
||||
rsync -av --delete --exclude='.git' docs/ wiki/
|
||||
cd wiki
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No wiki changes to commit."
|
||||
else
|
||||
git commit -m "Sync docs from ${GITHUB_SHA::8}"
|
||||
git push
|
||||
fi
|
||||
+21
-1
@@ -18,7 +18,7 @@ When contributing to the project, please follow these guidelines:
|
||||
- Use a maximum of 80 characters per line.
|
||||
- Use a blank line between the commit message and the body.
|
||||
- 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:
|
||||
[<type>](<crate>): <description>
|
||||
@@ -39,3 +39,23 @@ Allowed types:
|
||||
- chore: build or maintenance changes
|
||||
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
SIL OPEN FONT LICENSE
|
||||
|
||||
Version 1.1 - 26 February 2007
|
||||
|
||||
PREAMBLE
|
||||
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -64,7 +64,7 @@ brew install soapysdr
|
||||
```
|
||||
</details>
|
||||
|
||||
See [Build Requirements](https://github.com/sgrams/trx-rs/wiki/User-Manual#build-requirements)
|
||||
See [Build Requirements](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual#build-requirements)
|
||||
in the wiki for details on each library.
|
||||
|
||||
> **Note:** `cmake` is required even when a system Opus library is installed.
|
||||
@@ -133,10 +133,10 @@ a unified set of frontends.
|
||||
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| [User Manual](https://github.com/sgrams/trx-rs/wiki/User-Manual) | Configuration, features, and usage |
|
||||
| [Architecture](https://github.com/sgrams/trx-rs/wiki/Architecture) | System design, crate layout, data flow, and internals |
|
||||
| [Optimization Guidelines](https://github.com/sgrams/trx-rs/wiki/Optimization-Guidelines) | Performance guidelines for the real-time DSP pipeline |
|
||||
| [Planned Features](https://github.com/sgrams/trx-rs/wiki/Planned-Features) | Roadmap and design notes |
|
||||
| [User Manual](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual) | Configuration, features, and usage |
|
||||
| [Architecture](https://git.haxx.space/sjg/trx-rs/wiki/Architecture) | System design, crate layout, data flow, and internals |
|
||||
| [Optimization Guidelines](https://git.haxx.space/sjg/trx-rs/wiki/Optimization-Guidelines) | Performance guidelines for the real-time DSP pipeline |
|
||||
| [Planned Features](https://git.haxx.space/sjg/trx-rs/wiki/Planned-Features) | Roadmap and design notes |
|
||||
| [Contributing](CONTRIBUTING.md) | Commit conventions, workflow, and code style |
|
||||
|
||||
## License
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
version = 1
|
||||
|
||||
# Project-owned files without an in-file SPDX header (docs, config,
|
||||
# repo metadata, logos, and bespoke web assets).
|
||||
[[annotations]]
|
||||
path = [
|
||||
".gitattributes",
|
||||
".gitignore",
|
||||
"CLAUDE.md",
|
||||
"CONTRIBUTING.md",
|
||||
"README.md",
|
||||
"trx-rs.toml.example",
|
||||
"docs/**",
|
||||
"aidocs/**",
|
||||
"src/decoders/trx-ftx/README.md",
|
||||
"src/decoders/trx-wxsat/README.md",
|
||||
"assets/trx-logo.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js",
|
||||
]
|
||||
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||
|
||||
# Vendored Leaflet 1.9.4 (https://leafletjs.com), distributed under BSD-2-Clause.
|
||||
[[annotations]]
|
||||
path = [
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.js",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.css",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers-2x.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon-2x.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-shadow.png",
|
||||
]
|
||||
SPDX-FileCopyrightText = "2010-2023 Vladimir Agafonkin, 2010-2011 CloudMade"
|
||||
SPDX-License-Identifier = "BSD-2-Clause"
|
||||
|
||||
# Vendored DSEG14 font (https://github.com/keshikan/DSEG), SIL OFL 1.1.
|
||||
[[annotations]]
|
||||
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
|
||||
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
|
||||
SPDX-License-Identifier = "OFL-1.1"
|
||||
@@ -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`, P0–P3) 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.
|
||||
@@ -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"]
|
||||
@@ -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; 1–2 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"
|
||||
```
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -243,7 +243,7 @@ fn parse_frame(frame: RawFrame, channel: &str) -> Option<AisMessage> {
|
||||
|
||||
let message_type = get_uint(&bits, 0, 6)? 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 {
|
||||
rig_id: None,
|
||||
|
||||
@@ -638,7 +638,7 @@ mod tests {
|
||||
for (i, &ch) in b"N0CALL".iter().enumerate() {
|
||||
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);
|
||||
assert_eq!(decoded.call, "N0CALL");
|
||||
@@ -652,7 +652,7 @@ mod tests {
|
||||
for (i, &ch) in b"SP2SJG".iter().enumerate() {
|
||||
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);
|
||||
assert_eq!(decoded.call, "SP2SJG");
|
||||
@@ -667,7 +667,7 @@ mod tests {
|
||||
for (i, &ch) in b"W1AW ".iter().enumerate() {
|
||||
addr[i] = ch << 1;
|
||||
}
|
||||
addr[6] = (0 << 1) | 1;
|
||||
addr[6] = 1;
|
||||
|
||||
let decoded = decode_ax25_address(&addr, 0);
|
||||
assert_eq!(decoded.call, "W1AW");
|
||||
@@ -691,7 +691,7 @@ mod tests {
|
||||
for &ch in src_bytes.as_bytes().iter().take(6) {
|
||||
frame.push(ch << 1);
|
||||
}
|
||||
frame.push((0 << 1) | 1); // SSID=0, last=true
|
||||
frame.push(1); // SSID=0, last=true
|
||||
// Control + PID
|
||||
frame.push(0x03); // UI frame
|
||||
frame.push(0xF0); // No layer-3 protocol
|
||||
|
||||
@@ -160,8 +160,7 @@ impl CallsignHashTable {
|
||||
let mut idx = start_idx;
|
||||
|
||||
loop {
|
||||
match &self.entries[idx] {
|
||||
Some(entry) => {
|
||||
let entry = self.entries[idx].as_ref()?;
|
||||
let stored = (entry.hash & HASH22_MASK) >> shift;
|
||||
if stored == target {
|
||||
return Some(entry.callsign.clone());
|
||||
@@ -171,9 +170,6 @@ impl CallsignHashTable {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Age all entries and remove those older than `max_age`.
|
||||
|
||||
@@ -42,8 +42,8 @@ pub(crate) fn ldpc_check(codeword: &[u8; FTX_LDPC_N]) -> i32 {
|
||||
for m in 0..FTX_LDPC_M {
|
||||
let mut x: u8 = 0;
|
||||
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||
for i in 0..num_rows {
|
||||
x ^= codeword[FTX_LDPC_NM[m][i] as usize - 1];
|
||||
for &nm in FTX_LDPC_NM[m].iter().take(num_rows) {
|
||||
x ^= codeword[nm as usize - 1];
|
||||
}
|
||||
if x != 0 {
|
||||
errors += 1;
|
||||
@@ -81,11 +81,11 @@ pub fn ldpc_decode(
|
||||
for j in 0..FTX_LDPC_M {
|
||||
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
|
||||
let m_row = j * FTX_LDPC_N;
|
||||
for ii1 in 0..num_rows {
|
||||
let i1 = FTX_LDPC_NM[j][ii1] as usize - 1;
|
||||
for &nm1 in FTX_LDPC_NM[j].iter().take(num_rows) {
|
||||
let i1 = nm1 as usize - 1;
|
||||
let mut a = 1.0f32;
|
||||
for ii2 in 0..num_rows {
|
||||
let i2 = FTX_LDPC_NM[j][ii2] as usize - 1;
|
||||
for &nm2 in FTX_LDPC_NM[j].iter().take(num_rows) {
|
||||
let i2 = nm2 as usize - 1;
|
||||
if i2 != i1 {
|
||||
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
|
||||
}
|
||||
@@ -97,8 +97,8 @@ pub fn ldpc_decode(
|
||||
// Hard decisions
|
||||
for i in 0..FTX_LDPC_N {
|
||||
let mut l = codeword[i];
|
||||
for j in 0..3 {
|
||||
l += e_matrix[(FTX_LDPC_MN[i][j] as usize - 1) * FTX_LDPC_N + i];
|
||||
for &mn in FTX_LDPC_MN[i].iter().take(3) {
|
||||
l += e_matrix[(mn as usize - 1) * FTX_LDPC_N + i];
|
||||
}
|
||||
plain[i] = if l > 0.0 { 1 } else { 0 };
|
||||
}
|
||||
@@ -113,12 +113,12 @@ pub fn ldpc_decode(
|
||||
|
||||
// Update m[][] from e[][]
|
||||
for i in 0..FTX_LDPC_N {
|
||||
for ji1 in 0..3 {
|
||||
let j1 = FTX_LDPC_MN[i][ji1] as usize - 1;
|
||||
for (ji1, &mn1) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
|
||||
let j1 = mn1 as usize - 1;
|
||||
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 {
|
||||
let j2 = FTX_LDPC_MN[i][ji2] as usize - 1;
|
||||
let j2 = mn2 as usize - 1;
|
||||
l += e_matrix[j2 * FTX_LDPC_N + i];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,11 +64,9 @@ pub fn charn(mut c: i32, table: CharTable) -> char {
|
||||
return EXTRAS[c as usize];
|
||||
}
|
||||
}
|
||||
CharTable::AlphanumSpaceSlash => {
|
||||
if c == 0 {
|
||||
CharTable::AlphanumSpaceSlash if c == 0 => {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -116,11 +114,9 @@ pub fn nchar(c: char, table: CharTable) -> Option<i32> {
|
||||
'?' => return Some(n + 4),
|
||||
_ => {}
|
||||
},
|
||||
CharTable::AlphanumSpaceSlash => {
|
||||
if c == '/' {
|
||||
CharTable::AlphanumSpaceSlash if c == '/' => {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -632,33 +632,18 @@ impl Candidate {
|
||||
}
|
||||
let segment = usize::from((block_b & 0x0003) as u8);
|
||||
let di = ((block_b >> 2) & 0x1) != 0;
|
||||
match segment {
|
||||
0 => {
|
||||
if self.state.dynamic_pty != Some(di) {
|
||||
self.state.dynamic_pty = Some(di);
|
||||
let di_flag = Some(di);
|
||||
let slot = match segment {
|
||||
0 => &mut self.state.dynamic_pty,
|
||||
1 => &mut self.state.compressed,
|
||||
2 => &mut self.state.artificial_head,
|
||||
3 => &mut self.state.stereo,
|
||||
_ => unreachable!("segment is masked to two bits"),
|
||||
};
|
||||
if *slot != di_flag {
|
||||
*slot = di_flag;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
if self.state.compressed != Some(di) {
|
||||
self.state.compressed = Some(di);
|
||||
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();
|
||||
self.ps_bytes[segment * 2] = sanitize_text_byte(b0);
|
||||
self.ps_bytes[segment * 2 + 1] = sanitize_text_byte(b1);
|
||||
@@ -1458,9 +1443,9 @@ mod tests {
|
||||
}
|
||||
|
||||
// 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;
|
||||
shaped[t] *= phase.cos();
|
||||
*sample *= phase.cos();
|
||||
}
|
||||
shaped
|
||||
}
|
||||
|
||||
@@ -134,9 +134,7 @@ mod tests {
|
||||
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
||||
.collect();
|
||||
// Append wrong CRC
|
||||
for _ in 0..16 {
|
||||
bits.push(0);
|
||||
}
|
||||
bits.resize(bits.len() + 16, 0);
|
||||
assert!(!check_crc16(&bits));
|
||||
}
|
||||
|
||||
|
||||
@@ -346,8 +346,8 @@ mod tests {
|
||||
write_bits(&mut bits, 12, 32, 123456); // source_id
|
||||
write_bits(&mut bits, 44, 11, 20); // data_count = 20
|
||||
// Fill some payload
|
||||
for i in 55..75 {
|
||||
bits[i] = (i % 2) as u8;
|
||||
for (i, bit) in bits.iter_mut().enumerate().take(75).skip(55) {
|
||||
*bit = (i % 2) as u8;
|
||||
}
|
||||
append_crc(&mut bits);
|
||||
|
||||
|
||||
@@ -274,7 +274,8 @@ mod tests {
|
||||
|
||||
// Pseudo-random noise vs gradient — correlation should be low.
|
||||
let noise: Vec<u8> = (0..256)
|
||||
.map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
|
||||
.map(|i| (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345))
|
||||
.map(|value| ((value >> 8) & 0xff) as u8)
|
||||
.collect();
|
||||
let r = asm.correlation_with_last(&noise).expect("r");
|
||||
assert!(
|
||||
@@ -374,8 +375,8 @@ mod tests {
|
||||
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
|
||||
assert_eq!(y, 2026);
|
||||
// Just verify reasonable values without asserting exact date.
|
||||
assert!(m >= 1 && m <= 12);
|
||||
assert!(d >= 1 && d <= 31);
|
||||
assert!((1..=12).contains(&m));
|
||||
assert!((1..=31).contains(&d));
|
||||
assert!(h < 24);
|
||||
assert!(mi < 60);
|
||||
}
|
||||
|
||||
@@ -161,10 +161,8 @@ mod tests {
|
||||
|
||||
for line_idx in 0..20 {
|
||||
let mut line = vec![1.0f32; spl];
|
||||
for j in pulse_start..pulse_start + pw {
|
||||
if j < spl {
|
||||
line[j] = 0.0;
|
||||
}
|
||||
for slot in line.iter_mut().skip(pulse_start).take(pw) {
|
||||
*slot = 0.0;
|
||||
}
|
||||
let result = det.process(&line);
|
||||
if let Some(offset) = result {
|
||||
|
||||
@@ -483,7 +483,7 @@ mod tests {
|
||||
let c4 = idx27(b'T');
|
||||
let c5 = idx27(b' ');
|
||||
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 mut input_bits = [0u8; NBITS];
|
||||
@@ -530,8 +530,8 @@ mod tests {
|
||||
fn interleave_deinterleave_roundtrip() {
|
||||
// Create a sequence of distinguishable values
|
||||
let mut original = [0u8; NSYMS];
|
||||
for i in 0..NSYMS {
|
||||
original[i] = (i % 256) as u8;
|
||||
for (i, slot) in original.iter_mut().enumerate() {
|
||||
*slot = (i % 256) as u8;
|
||||
}
|
||||
|
||||
let interleaved = interleave(&original);
|
||||
|
||||
@@ -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`
|
||||
/// but publishes to per-rig channels directly and mirrors to global when selected.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -767,3 +714,56 @@ async fn handle_single_rig_connection(
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,8 +1110,8 @@ url = "remote.example.com:4530"
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_duplicate_remote_names() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.remotes = vec![
|
||||
let config = ClientConfig {
|
||||
remotes: vec![
|
||||
RemoteEntry {
|
||||
name: "dup".to_string(),
|
||||
url: "a:4530".to_string(),
|
||||
@@ -1126,20 +1126,24 @@ url = "remote.example.com:4530"
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 750,
|
||||
},
|
||||
];
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(config.validate().unwrap_err().contains("duplicate name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_empty_remote_name() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.remotes = vec![RemoteEntry {
|
||||
let config = ClientConfig {
|
||||
remotes: vec![RemoteEntry {
|
||||
name: "".to_string(),
|
||||
url: "a:4530".to_string(),
|
||||
rig_id: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 750,
|
||||
}];
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(config
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
@@ -1148,14 +1152,16 @@ url = "remote.example.com:4530"
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_empty_remote_url() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.remotes = vec![RemoteEntry {
|
||||
let config = ClientConfig {
|
||||
remotes: vec![RemoteEntry {
|
||||
name: "hf".to_string(),
|
||||
url: " ".to_string(),
|
||||
rig_id: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 750,
|
||||
}];
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(config
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
@@ -1164,14 +1170,16 @@ url = "remote.example.com:4530"
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_zero_remote_poll_interval() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.remotes = vec![RemoteEntry {
|
||||
let config = ClientConfig {
|
||||
remotes: vec![RemoteEntry {
|
||||
name: "hf".to_string(),
|
||||
url: "a:4530".to_string(),
|
||||
rig_id: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 0,
|
||||
}];
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(config
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
|
||||
@@ -1678,7 +1678,7 @@ mod tests {
|
||||
#[test]
|
||||
fn global_target_for_snapshot_skips_other_server_selection() {
|
||||
let snapshot = sample_snapshot();
|
||||
let rigs = vec![RigEntry {
|
||||
let rigs = [RigEntry {
|
||||
rig_id: "hf".to_string(),
|
||||
display_name: Some("Gdansk HF".to_string()),
|
||||
state: snapshot,
|
||||
|
||||
@@ -104,24 +104,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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 style="margin-bottom: 1.5rem;">
|
||||
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">Access Required</div>
|
||||
<div style="color: var(--text-muted);">Enter passphrase to continue</div>
|
||||
<div id="auth-gate" class="auth-gate" style="display:none;">
|
||||
<div class="auth-gate-head">
|
||||
<div class="auth-gate-title">Access Required</div>
|
||||
<div class="auth-gate-sub">Enter passphrase to continue</div>
|
||||
</div>
|
||||
<form id="auth-form" style="margin: 1rem 0 0.35rem;">
|
||||
<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;" />
|
||||
<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>
|
||||
<form id="auth-form" class="auth-form">
|
||||
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
|
||||
<button type="submit" class="auth-submit">Login</button>
|
||||
</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>
|
||||
<div id="auth-error" style="color: #ff6b6b; font-size: 0.9rem; margin-top: 1rem; display: none;"></div>
|
||||
<div id="auth-role" style="margin-top: 1rem; color: var(--text-muted); font-size: 0.85rem; display: none;"></div>
|
||||
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
|
||||
<div id="auth-error" class="auth-error" style="display: none;"></div>
|
||||
<div id="auth-role" class="auth-role" style="display: none;"></div>
|
||||
</div>
|
||||
<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="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-sub" style="color:#9aa4b5;"></div>
|
||||
<div id="loading-sub" style="color:var(--text-muted);"></div>
|
||||
</div>
|
||||
<div id="content" style="display:none;">
|
||||
<div class="signal-visual-block">
|
||||
@@ -1581,7 +1581,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="copyright">
|
||||
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://github.com/sgrams/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs on GitHub"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0.2a8 8 0 0 0-2.53 15.59c0.4 0.07 0.55-0.17 0.55-0.39l-0.01-1.37c-2.23 0.49-2.7-0.95-2.7-0.95-0.36-0.91-0.89-1.15-0.89-1.15-0.73-0.49 0.06-0.48 0.06-0.48 0.8 0.06 1.22 0.82 1.22 0.82 0.72 1.22 1.88 0.87 2.34 0.67 0.07-0.51 0.28-0.86 0.5-1.06-1.78-0.2-3.64-0.89-3.64-3.95 0-0.87 0.31-1.58 0.81-2.14-0.08-0.2-0.35-1.02 0.08-2.12 0 0 0.67-0.21 2.2 0.82a7.56 7.56 0 0 1 4.01 0c1.53-1.03 2.2-0.82 2.2-0.82 0.43 1.1 0.16 1.92 0.08 2.12 0.51 0.56 0.81 1.27 0.81 2.14 0 3.07-1.87 3.75-3.66 3.95 0.29 0.25 0.54 0.73 0.54 1.48l-0.01 2.2c0 0.22 0.14 0.47 0.55 0.39A8 8 0 0 0 8 0.2Z"></path></svg><span>trx-rs on GitHub</span></a></span> — <span id="copyright-year"></span>
|
||||
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span> — <span id="copyright-year"></span>
|
||||
</div>
|
||||
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
|
||||
</div>
|
||||
@@ -1624,6 +1624,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</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="/vendor/leaflet.js"></script>
|
||||
<script defer src="/leaflet-ais-tracksymbol.js"></script>
|
||||
<script defer src="/webgl-renderer.js"></script>
|
||||
<script defer src="/app.js"></script>
|
||||
<script>
|
||||
@@ -1632,41 +1634,67 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
var pluginScripts = {
|
||||
'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': ['/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'],
|
||||
'bookmarks': ['/bookmarks.js'],
|
||||
'recorder': [],
|
||||
'settings': ['/vchan.js', '/scheduler.js']
|
||||
};
|
||||
var loaded = new Set();
|
||||
function loadPlugins(tab) {
|
||||
var scripts = pluginScripts[tab];
|
||||
if (!scripts) return;
|
||||
scripts.forEach(function(src) {
|
||||
if (loaded.has(src)) return;
|
||||
loaded.add(src);
|
||||
var loading = new Map();
|
||||
|
||||
function loadScript(src) {
|
||||
if (loaded.has(src)) return Promise.resolve();
|
||||
if (loading.has(src)) return loading.get(src);
|
||||
|
||||
var request = new Promise(function(resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.defer = true;
|
||||
s.onload = function() {
|
||||
loaded.add(src);
|
||||
loading.delete(src);
|
||||
resolve();
|
||||
};
|
||||
s.onerror = function() {
|
||||
loading.delete(src);
|
||||
reject(new Error('Failed to load plugin script: ' + src));
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
loading.set(src, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function loadPlugins(tab) {
|
||||
var scripts = pluginScripts[tab];
|
||||
if (!scripts) return Promise.resolve();
|
||||
return scripts.reduce(function(sequence, src) {
|
||||
return sequence.then(function() { return loadScript(src); });
|
||||
}, Promise.resolve());
|
||||
}
|
||||
|
||||
function requestPlugins(tab) {
|
||||
return loadPlugins(tab).catch(function(err) {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
// Eager plugin loading is triggered by app.js (after window.trx is set up)
|
||||
// via window.loadEagerPlugins(). Dynamic scripts are effectively async, so
|
||||
// loading them before app.js would cause map-core.js to crash when
|
||||
// window.trx is not yet defined.
|
||||
window.loadEagerPlugins = function() {
|
||||
['digital-modes', 'map-data', 'bookmarks', 'settings'].forEach(loadPlugins);
|
||||
return Promise.all(
|
||||
['digital-modes', 'map-data', 'bookmarks', 'settings'].map(requestPlugins)
|
||||
);
|
||||
};
|
||||
// Load others on tab switch
|
||||
document.addEventListener('click', function(e) {
|
||||
var tab = e.target.closest('[data-tab]');
|
||||
if (tab) loadPlugins(tab.dataset.tab);
|
||||
if (tab) requestPlugins(tab.dataset.tab);
|
||||
});
|
||||
window.loadPluginsForTab = loadPlugins;
|
||||
})();
|
||||
</script>
|
||||
<!-- Template cloning is handled by navigateToTab() in app.js -->
|
||||
<script defer src="/vendor/leaflet.js"></script>
|
||||
</body>
|
||||
</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%);
|
||||
--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 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"] {
|
||||
@@ -89,6 +133,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
body {
|
||||
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;
|
||||
min-height: 100vh;
|
||||
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; }
|
||||
#tab-main .label > span {
|
||||
display: inline-block;
|
||||
@@ -220,7 +319,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
||||
align-items: stretch;
|
||||
gap: 0.22rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -408,7 +507,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
padding: 0 0.65rem;
|
||||
font-size: 0.92rem;
|
||||
font-size: 0.9rem;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
@@ -486,7 +585,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
||||
}
|
||||
.scheduler-cycle-status {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
font-size: 0.75rem;
|
||||
text-align: left;
|
||||
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)); }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -1433,7 +1532,7 @@ small { color: var(--text-muted); }
|
||||
font-size: 0.95rem;
|
||||
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 icons — hidden on desktop, shown on mobile bottom nav */
|
||||
.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; }
|
||||
.full-row { grid-column: 1 / -1; }
|
||||
.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; }
|
||||
.gh-link-wrap {
|
||||
display: inline-flex;
|
||||
@@ -1499,7 +1598,7 @@ small { color: var(--text-muted); }
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vol-pct {
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -1602,7 +1701,7 @@ small { color: var(--text-muted); }
|
||||
}
|
||||
.shortcut-table td {
|
||||
padding: 0.32rem 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
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 {
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1706,7 +1805,7 @@ small { color: var(--text-muted); }
|
||||
text-align: center;
|
||||
}
|
||||
.decode-history-overlay-title {
|
||||
font-size: 0.98rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
@@ -1756,7 +1855,7 @@ small { color: var(--text-muted); }
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.map-qso-summary-title {
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -1841,7 +1940,7 @@ button.map-qso-card:focus-visible {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent-green) 16%, transparent);
|
||||
color: var(--accent-green);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.map-qso-card-distance {
|
||||
@@ -1858,7 +1957,7 @@ button.map-qso-card:focus-visible {
|
||||
min-width: 0;
|
||||
}
|
||||
.map-qso-card-pair {
|
||||
font-size: 0.92rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
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);
|
||||
background: color-mix(in srgb, var(--card-bg) 82%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
@@ -1967,7 +2066,7 @@ button.map-qso-card:focus-visible {
|
||||
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
|
||||
background: color-mix(in srgb, var(--card-bg) 82%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
@@ -2000,7 +2099,7 @@ button.map-qso-card:focus-visible {
|
||||
display: none;
|
||||
}
|
||||
.map-band-legend-title {
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -2027,7 +2126,7 @@ button.map-qso-card:focus-visible {
|
||||
}
|
||||
.map-band-legend-text {
|
||||
min-width: 0;
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
@@ -2084,7 +2183,7 @@ body.map-fake-fullscreen-active {
|
||||
}
|
||||
.aprs-summary-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -2142,7 +2241,7 @@ body.map-fake-fullscreen-active {
|
||||
}
|
||||
.ais-summary-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -2260,7 +2359,7 @@ body.map-fake-fullscreen-active {
|
||||
}
|
||||
.aprs-meta-text {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.aprs-inline-btn {
|
||||
display: inline-flex;
|
||||
@@ -2273,7 +2372,7 @@ body.map-fake-fullscreen-active {
|
||||
background: var(--filter-bg);
|
||||
color: var(--filter-fg);
|
||||
font: inherit;
|
||||
font-size: 0.74rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.aprs-inline-btn:hover {
|
||||
@@ -2286,7 +2385,7 @@ body.map-fake-fullscreen-active {
|
||||
.aprs-details summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent-green);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
user-select: none;
|
||||
}
|
||||
.aprs-details[open] summary {
|
||||
@@ -2364,7 +2463,7 @@ body.map-fake-fullscreen-active {
|
||||
}
|
||||
.ais-meta-text {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ais-pos-link {
|
||||
color: var(--accent-red);
|
||||
@@ -2406,7 +2505,7 @@ body.map-fake-fullscreen-active {
|
||||
}
|
||||
.vdes-raw {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.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 {
|
||||
margin-top: 0.12rem;
|
||||
font-size: 0.74rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.decode-locator-tip-rx {
|
||||
margin-top: 0.12rem;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-yellow);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.decode-locator-tip-note {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
@@ -2503,7 +2602,7 @@ body.map-fake-fullscreen-active {
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
color: color-mix(in srgb, var(--accent-green) 72%, var(--accent-yellow));
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
@@ -2576,7 +2675,7 @@ body.map-fake-fullscreen-active {
|
||||
flex: 0 0 auto;
|
||||
min-width: 4.4rem;
|
||||
padding-top: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
@@ -2604,7 +2703,7 @@ body.map-fake-fullscreen-active {
|
||||
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--input-bg) 92%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
@@ -2621,7 +2720,7 @@ body.map-fake-fullscreen-active {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.map-locator-empty {
|
||||
font-size: 0.74rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.8;
|
||||
align-self: flex-start;
|
||||
@@ -2655,7 +2754,7 @@ body.map-fake-fullscreen-active {
|
||||
background: color-mix(in srgb, var(--input-bg) 96%, transparent);
|
||||
}
|
||||
.map-locator-chip-text {
|
||||
font-size: 0.77rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.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-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-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; }
|
||||
|
||||
.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"]: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-offset: 2px;
|
||||
}
|
||||
@@ -2844,7 +2945,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
}
|
||||
/* Collapse verbose subtitles — the About tab has this info */
|
||||
.tab-bar .subtitle { display: none; }
|
||||
.tab-bar .title { font-size: 0.92rem; }
|
||||
.tab-bar .title { font-size: 0.9rem; }
|
||||
.tab-bar-nav {
|
||||
position: fixed;
|
||||
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;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.66rem;
|
||||
font-size: 0.65rem;
|
||||
background: var(--bm-cat-bg, var(--accent-yellow));
|
||||
color: var(--bm-cat-fg, #1a202c);
|
||||
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 {
|
||||
width: 100%;
|
||||
font-size: 0.64rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -3662,7 +3763,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
height: 1.5rem;
|
||||
min-height: 0;
|
||||
padding: 0 8px;
|
||||
font-size: 0.73rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
#spectrum-level-row {
|
||||
display: flex;
|
||||
@@ -3709,7 +3810,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
height: 1.5rem;
|
||||
min-height: 0;
|
||||
padding: 0 8px;
|
||||
font-size: 0.73rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
#spectrum-gamma-label {
|
||||
display: flex;
|
||||
@@ -3724,7 +3825,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
cursor: pointer;
|
||||
}
|
||||
#spectrum-gamma-value {
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
min-width: 1.6rem;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -3882,7 +3983,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
|
||||
.bm-label input,
|
||||
.bm-label select {
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
@@ -3938,7 +4039,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--text);
|
||||
font-size: 0.84rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bm-decoder-check input {
|
||||
@@ -4048,7 +4149,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
.bm-page-summary,
|
||||
.bm-page-indicator {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.bm-page-controls {
|
||||
display: flex;
|
||||
@@ -4266,7 +4367,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
#sch-sat-form-cancel:hover {
|
||||
background: var(--border);
|
||||
@@ -4478,7 +4579,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
.sch-log-action {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.sch-log-bm {
|
||||
color: var(--accent);
|
||||
@@ -4687,7 +4788,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
}
|
||||
.bgd-checklist-meta {
|
||||
margin-left: 0;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
/* ── SAT panel ──────────────────────────────────────────────────────── */
|
||||
@@ -4756,7 +4857,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.stats-control-label {
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -4796,7 +4897,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stats-counter-label {
|
||||
font-size: 0.74rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -4819,7 +4920,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
gap: 0.18rem;
|
||||
}
|
||||
.stats-section-title {
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -4884,7 +4985,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
#tab-statistics { padding: 0.75rem; }
|
||||
.stats-counters { grid-template-columns: repeat(2, 1fr); }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -4945,3 +5046,18 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
-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; }
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ pub struct DecoderHistories {
|
||||
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
||||
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
|
||||
/// Approximate total entry count across all decoders, maintained
|
||||
/// atomically so `estimated_total_count()` avoids 9 lock acquisitions.
|
||||
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
|
||||
total_count: AtomicUsize,
|
||||
}
|
||||
|
||||
@@ -845,6 +845,23 @@ impl DecoderHistories {
|
||||
pub fn estimated_total_count(&self) -> usize {
|
||||
self.total_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Rebuild the aggregate count after bulk restoration bypasses the normal
|
||||
/// record methods.
|
||||
pub(crate) fn recalculate_total_count(&self) {
|
||||
let total = lock_or_recover(&self.ais, "ais_history").len()
|
||||
+ lock_or_recover(&self.vdes, "vdes_history").len()
|
||||
+ lock_or_recover(&self.aprs, "aprs_history").len()
|
||||
+ lock_or_recover(&self.hf_aprs, "hf_aprs_history").len()
|
||||
+ lock_or_recover(&self.cw, "cw_history").len()
|
||||
+ lock_or_recover(&self.ft8, "ft8_history").len()
|
||||
+ lock_or_recover(&self.ft4, "ft4_history").len()
|
||||
+ lock_or_recover(&self.ft2, "ft2_history").len()
|
||||
+ lock_or_recover(&self.wspr, "wspr_history").len()
|
||||
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
|
||||
+ lock_or_recover(&self.wefax, "wefax_history").len();
|
||||
self.total_count.store(total, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the audio capture thread.
|
||||
|
||||
@@ -19,7 +19,7 @@ use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, Ft8Message, VdesMessage, WefaxMessage, WsprMessage,
|
||||
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage,
|
||||
};
|
||||
|
||||
use crate::audio::DecoderHistories;
|
||||
@@ -118,6 +118,11 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.hf_aprs.lock() {
|
||||
for e in load_key::<AprsPacket>(db, &k("hf_aprs")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.cw.lock() {
|
||||
for e in load_key::<CwEvent>(db, &k("cw")) {
|
||||
h.push_back(e);
|
||||
@@ -128,16 +133,32 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.ft4.lock() {
|
||||
for e in load_key::<Ft8Message>(db, &k("ft4")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.ft2.lock() {
|
||||
for e in load_key::<Ft8Message>(db, &k("ft2")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.wspr.lock() {
|
||||
for e in load_key::<WsprMessage>(db, &k("wspr")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.lrpt.lock() {
|
||||
for e in load_key::<LrptImage>(db, &k("lrpt")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.wefax.lock() {
|
||||
for e in load_key::<WefaxMessage>(db, &k("wefax")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
histories.recalculate_total_count();
|
||||
}
|
||||
|
||||
/// Flush `histories` to the database under `rig_id`-prefixed keys and sync.
|
||||
@@ -162,6 +183,11 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
|
||||
drop(h);
|
||||
save_key(db, &k("aprs"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.hf_aprs.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("hf_aprs"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.cw.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
@@ -172,11 +198,26 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
|
||||
drop(h);
|
||||
save_key(db, &k("ft8"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.ft4.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("ft4"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.ft2.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("ft2"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.wspr.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("wspr"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.lrpt.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("lrpt"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.wefax.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
@@ -185,20 +226,38 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
|
||||
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.
|
||||
///
|
||||
/// 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(
|
||||
db: Arc<Mutex<PickleDb>>,
|
||||
rig_histories: Vec<(String, Arc<DecoderHistories>)>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let rig_histories = Arc::new(rig_histories);
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
interval.tick().await; // consume the immediate first tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Ok(mut guard) = db.lock() {
|
||||
for (rig_id, histories) in &rig_histories {
|
||||
flush_all(&mut guard, rig_id, histories);
|
||||
}
|
||||
let db = Arc::clone(&db);
|
||||
let rig_histories = Arc::clone(&rig_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");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -302,4 +361,79 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&db_file);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_and_load_all_restores_previously_omitted_histories() {
|
||||
let db_file = std::env::temp_dir().join(format!(
|
||||
"trx_history_all_{}_{}.db",
|
||||
std::process::id(),
|
||||
now_unix_ms()
|
||||
));
|
||||
let mut db = PickleDb::new(
|
||||
&db_file,
|
||||
PickleDbDumpPolicy::DumpUponRequest,
|
||||
SerializationMethod::Json,
|
||||
);
|
||||
let source = DecoderHistories::new();
|
||||
let now = Instant::now();
|
||||
|
||||
source.hf_aprs.lock().unwrap().push_back((
|
||||
now,
|
||||
AprsPacket {
|
||||
rig_id: Some("rig-a".into()),
|
||||
ts_ms: Some(now_unix_ms()),
|
||||
src_call: "TEST".into(),
|
||||
dest_call: "APRS".into(),
|
||||
path: String::new(),
|
||||
info: "history".into(),
|
||||
info_bytes: Vec::new(),
|
||||
packet_type: "position".into(),
|
||||
crc_ok: true,
|
||||
lat: None,
|
||||
lon: None,
|
||||
symbol_table: None,
|
||||
symbol_code: None,
|
||||
},
|
||||
));
|
||||
for (queue, mode) in [(&source.ft4, "FT4"), (&source.ft2, "FT2")] {
|
||||
queue.lock().unwrap().push_back((
|
||||
now,
|
||||
Ft8Message {
|
||||
rig_id: Some("rig-a".into()),
|
||||
ts_ms: now_unix_ms(),
|
||||
snr_db: -10.0,
|
||||
dt_s: 0.1,
|
||||
freq_hz: 1_000.0,
|
||||
message: mode.into(),
|
||||
},
|
||||
));
|
||||
}
|
||||
source.lrpt.lock().unwrap().push_back((
|
||||
now,
|
||||
LrptImage {
|
||||
rig_id: Some("rig-a".into()),
|
||||
pass_start_ms: now_unix_ms(),
|
||||
pass_end_ms: now_unix_ms(),
|
||||
mcu_count: 1,
|
||||
path: "/tmp/lrpt.png".into(),
|
||||
ts_ms: Some(now_unix_ms()),
|
||||
satellite: None,
|
||||
channels: None,
|
||||
geo_bounds: None,
|
||||
ground_track: None,
|
||||
},
|
||||
));
|
||||
|
||||
flush_all(&mut db, "rig-a", &source);
|
||||
let restored = DecoderHistories::new();
|
||||
load_all(&db, "rig-a", &restored);
|
||||
|
||||
assert_eq!(restored.hf_aprs.lock().unwrap().len(), 1);
|
||||
assert_eq!(restored.ft4.lock().unwrap().len(), 1);
|
||||
assert_eq!(restored.ft2.lock().unwrap().len(), 1);
|
||||
assert_eq!(restored.lrpt.lock().unwrap().len(), 1);
|
||||
assert_eq!(restored.estimated_total_count(), 4);
|
||||
|
||||
let _ = std::fs::remove_file(db_file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1596,7 +1596,7 @@ mod tests {
|
||||
let t = i as f32 / fs;
|
||||
adj_phase += adj_mod_index * adj_composite[i];
|
||||
let adj = Complex::from_polar(0.5, adj_phase + TAU * adj_freq_offset * t);
|
||||
*s = *s + adj;
|
||||
*s += adj;
|
||||
}
|
||||
|
||||
let mut decoder = WfmStereoDecoder::new(
|
||||
@@ -1673,7 +1673,7 @@ mod tests {
|
||||
// Mix at 70 % of the main signal's amplitude — strong enough to
|
||||
// overcome the FM capture effect and visibly degrade pilot coherence.
|
||||
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(
|
||||
|
||||
@@ -251,9 +251,9 @@ fn mul_freq_domain(buf: &mut [FftComplex<f32>], h_freq: &[FftComplex<f32>], scal
|
||||
unsafe {
|
||||
mul_freq_domain_neon(buf, h_freq, scale);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
mul_freq_domain_scalar(buf, h_freq, scale);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user