10 Commits
Author SHA1 Message Date
Claude 880ae5b0f0 Fail cleanly when the registry path has no directory
SpotBugs flagged a null passed to Files.createTempFile: the null check guarded
only createDirectories, while the temporary file creation would still have
dereferenced it. The path is resolved absolute so this is practically
unreachable, but bailing out with a log line is cheaper than the latent NPE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 08:04:38 +00:00
Claude 0d740374e2 Make the terrain profile cache multi operator safe
The cache stored a single owner identity in a meta table and dropped the whole
TerrainProfileCache table whenever the configured callsign or locator differed
from it. With several operator profiles that turns the cache into a permanent
miss: every switch between two operators with different locators would discard
every computed profile.

Entries are separated by owner identity through the primary key already, so the
wipe is replaced by an owner table that simply records which identities are in
use. A different owner now misses the cache instead of clearing everybody's.

The cache also moves out of the worked station database into its own global
terrainprofilecache.db. Terrain profiles are pure geometry derived from two
locators and a sample count; at a multi operator station both operators share
one location, so a per profile copy would only double the traffic against the
terrain service. No migration is needed, the new file refills itself, and the
old tables stay readable for older releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 08:01:54 +00:00
Claude 3d30eddf62 Document operator profiles
Adds a manual section in both languages covering where profile files live, the
choice between shared and own worked stations, managing profiles, the startup
selection and the --profile argument, switching while running, and the fact
that passwords stay in clear text so profiles are not an access boundary.

Records the architecture in PROJECT_CONTEXT: lazy registry, derived paths, why
an additional profile database is created empty, why the login callsign default
is empty, and the constraints of rebuilding the runtime for a switch.

Adds a v1.50 changelog entry in both languages including the upgrade notes: no
file is moved, and going back to an older release stays possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 07:58:42 +00:00
Claude 5892ce09d2 Manage operator profiles from the settings window
Adds a "Profiles" tab, appended last so no established tab position shifts,
offering create, duplicate, rename, delete, a switch between shared and own
worked stations, and activation of another profile.

Duplicating copies the whole configuration except callsign and password. The
antenna, locator, layout and integration settings are exactly the work nobody
wants to enter twice, while the credentials belong to one operator only. The
root profile can neither be deleted nor moved off the common station database,
because its files are the installation itself.

The login callsign and its raw form now default to empty instead of a real
callsign. The XML reader treats an empty element as "not set" and falls back to
the field default, so without this change a profile created without credentials
would come up carrying the callsign compiled into the defaults - and an
operator could transmit under someone else's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 07:54:32 +00:00
Claude 5bff7a119e Switch the operator profile while the application is running
Splits the teardown out of stop() into a reusable, idempotent shutdownRuntime()
and adds File - Switch operator profile..., which tears the current runtime
down and builds a fresh one for the selected profile.

The new runtime is a new application instance rather than a second start() on
the existing one. Many controls are instance fields created once, so reusing
the instance would re-parent mounted nodes and register every listener twice.
A fresh instance is safe because the class keeps no mutable static state.

Closing every window during a switch would end the process under the JavaFX
default, so the application takes over the exit decision: implicit exit is
turned off, the main window gets an explicit close handler, and every exit path
runs through the launcher. That also fixes losing the window layout on exit
after a switch, because JavaFX only calls stop() on the instance it launched.

The two view timers are now cancelled null-safe; stop() used to dereference
them unguarded, which would fail if shutdown happened before they were created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 07:50:03 +00:00
Claude 11912f4492 Release the background resources a discarded runtime owns
Several resources outlived a disconnect on purpose, which was harmless while a
process ran exactly one session for its whole life. They are now released when
the controller itself is closed:

- the ON4KST connection supervisor thread, which stopByUser did not touch
- the sked reminder scheduler, which had no shutdown at all
- the reachability executor, whose shutdown method existed but was never called
- the PSTRotator retry scheduler and its pending retry
- the map tile proxy, whose stop method existed but was never called, leaving a
  server socket and a twelve thread pool behind
- the station map bridge listeners and its coalescing animation, which would
  otherwise keep firing into a dead user interface

These are real leaks today; they only become visible when a second runtime is
built in the same process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 07:50:03 +00:00
Claude eeee11e4e7 Select the operator profile at startup
Resolves the active operator profile before the chat controller is built and
passes its two file names on, so preferences, layout and worked data follow the
profile.

The resolution is deliberately quiet for existing installations. With no
registry or exactly one profile nothing is asked and nothing is written, so a
single operator start is unchanged. Only from two profiles on does a small
picker appear with the last used profile preselected, where Enter or a double
click starts immediately. A "--profile" argument, or the equivalent system
property, skips the picker; an unknown name warns and falls back to the normal
selection instead of refusing to start.

The startup decision itself lives in OperatorProfileBootstrap and contains no
user interface code, so it is covered by headless tests. The window title gains
the profile name only when a second profile exists.

Command line parsing happens in init() and is kept in a process wide holder,
because JavaFX only knows the parameters of the instance it launched itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 07:44:22 +00:00
Claude 290ff1849d Introduce the operator profile model and its registry
Adds the descriptor, the path derivation and the registry persistence for
operator profiles. Nothing calls them yet, so behaviour is unchanged.

The descriptor stores only a shared/own flag, never a path. All file names are
derived in OperatorProfilePaths, so a stored path can never drift apart from
the flag that produced it. A profile identifier is a stable, file system safe
slug assigned once, so renaming a profile never moves a directory.

The registry is created lazily. An installation that only has the historic flat
layout gets no registry file and no profiles directory; the root profile is
synthesised in memory instead. That keeps a single operator installation
byte for byte the one it was before, and it keeps a downgrade to an older
release a no-op. A missing, unreadable or malformed registry is logged and
treated like an installation without additional profiles, never as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 07:39:32 +00:00
Claude ac850e339b Allow preferences and chat controller to be bound to a file set
ChatPreferences gains a constructor taking a preferences file name relative to
the application directory, so "profiles/OP2/preferences.xml" is as valid as the
historic flat "preferences.xml". A missing file is still seeded from the
bundled template, which gives an additional operator the same clean defaults a
first installation gets.

ChatController gains a constructor that passes both relative file names and the
seed flag through to ChatPreferences and DBController. The existing
constructors delegate to the historic file names, so nothing changes yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 07:36:27 +00:00
Claude f27cca27e0 Make DBController work on one database file per instance
The controller was a static singleton: an eagerly created static instance
opened the root database during class initialization, and both the connection
and the path were static fields. A second operator profile in the same process
was therefore impossible, and ChatController's own "new DBController()" never
opened anything - it silently adopted the eagerly opened root connection.

- drop the eager static instance in favour of a lazily created default instance
- turn connection and path into instance state
- add a constructor taking a database file name relative to the application
  directory plus a flag whether a missing file is seeded from the bundled
  template
- create additional profile databases empty instead of seeding them: the
  bundled template carries 3452 foreign callsigns and user_version 0, which
  would show a new operator foreign data and trigger the full callsign
  normalization rebuild. The schema is created by the existing table setup.
- remember the shutdown hook so closeDBConnection can deregister it; otherwise
  every profile switch would leave another hook holding a dead connection

All SQL statements keep referencing the plain field and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-07 07:36:27 +00:00
81 changed files with 4035 additions and 5173 deletions
@@ -1,416 +0,0 @@
From afb77c6a8674ff5571a9c57cbe7108b9a5189407 Mon Sep 17 00:00:00 2001
From: Codex <codex@openai.com>
Date: Fri, 4 Sep 2026 00:17:15 +0300
Subject: [PATCH] Document v1.43 and fix release metadata
---
.github/workflows/tagged-release.yml | 84 +++++++++----------
docs/PROJECT_CONTEXT.md | 4 +
github_docs/de-Changelog.md | 80 +++++++++++++++---
github_docs/en-Changelog.md | 80 +++++++++++++++---
.../kst4contest/ApplicationConstants.java | 2 +-
website/src/news/2026-09-03-version-1-43.md | 29 +++++++
6 files changed, 212 insertions(+), 67 deletions(-)
create mode 100644 website/src/news/2026-09-03-version-1-43.md
diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml
index d9133550..be7bf536 100644
--- a/.github/workflows/tagged-release.yml
+++ b/.github/workflows/tagged-release.yml
@@ -786,45 +786,45 @@ jobs:
release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
-
- # The update feed is generated only after GitHub has published the
- # release. Otherwise the Releases API cannot return the release notes
- # belonging to the tag which triggered this workflow.
- - name: Set up Node.js for website build
- uses: actions/setup-node@v4
- with:
- node-version: "24"
- cache: npm
- cache-dependency-path: website/package-lock.json
-
- - name: Build and validate website after release publication
- working-directory: website
- run: |
- npm ci
- npm test
- npm run build
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Verify Stable release in update feed
- if: ${{ !startsWith(github.ref_name, 'beta-') }}
- working-directory: website
- run: npm run validate:version-info
- env:
- EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
-
- - name: Attach verified version info to tagged release
- run: >-
- gh release upload "${GITHUB_REF_NAME}"
- website/_site/kst4ContestVersionInfo.xml
- --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Upload verified website artifact
- uses: actions/upload-artifact@v4.3.4
- with:
- name: kst4contest-website-${{ github.ref_name }}
- path: website/_site/
- if-no-files-found: error
- retention-days: 14
\ No newline at end of file
+
+ # The update feed is generated only after GitHub has published the
+ # release. Otherwise the Releases API cannot return the release notes
+ # belonging to the tag which triggered this workflow.
+ - name: Set up Node.js for website build
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: website/package-lock.json
+
+ - name: Build and validate website after release publication
+ working-directory: website
+ run: |
+ npm ci
+ npm test
+ npm run build
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Verify Stable release in update feed
+ if: ${{ !startsWith(github.ref_name, 'beta-') }}
+ working-directory: website
+ run: npm run validate:version-info
+ env:
+ EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
+
+ - name: Attach verified version info to tagged release
+ run: >-
+ gh release upload "${GITHUB_REF_NAME}"
+ website/_site/kst4ContestVersionInfo.xml
+ --clobber
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload verified website artifact
+ uses: actions/upload-artifact@v4.3.4
+ with:
+ name: kst4contest-website-${{ github.ref_name }}
+ path: website/_site/
+ if-no-files-found: error
+ retention-days: 14
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index b0ca4b63..c4d70f1f 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -162,6 +162,10 @@ The repository contains the KST4Contest website under `website/`, published sepa
Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions.
+- `APPLICATION_CURRENT_VERSION` is the user-visible semantic version and must use the dotted `major.minor.patch` form. `APPLICATION_CURRENTVERSIONNUMBER` is retained only for older feeds and encodes patch releases by appending the patch digit, for example `1.43.1` as `1.431`.
+- The tagged-release workflow creates the GitHub Release before building the website update feed. This ordering is required because `versionInfo.js` reads the published release body through the GitHub Releases API.
+- After publication, the workflow tests and builds the website, validates the expected Stable version, attaches `kst4ContestVersionInfo.xml` to the release and uploads the complete website build as a workflow artifact.
+
## Important Decisions and Workarounds
- Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.
diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md
index 2022f904..622bb446 100644
--- a/github_docs/de-Changelog.md
+++ b/github_docs/de-Changelog.md
@@ -8,6 +8,72 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git
---
+## v1.43.1 (2026-09-03)
+
+**Korrigierte Versionsmetadaten**
+
+v1.43.1 enthält dieselben funktionalen Änderungen wie v1.43.0. Korrigiert wurden die Anwendungs- und Build-Metadaten für Anzeige, ON4KST-Kennung und Update-Vergleich. Ein Teil der Metadaten des ersten v1.43.0-Pakets wies den Build noch als Version 1.42 aus.
+
+### Behoben
+
+- **Einheitliche semantische Version:** Die sichtbare Anwendungsversion verwendet jetzt vollständig `1.43.1`. Der kompakte Wert `1.431` bleibt ausschließlich im veralteten numerischen Feld für die Kompatibilität mit älteren Update-Feeds erhalten.
+
+- **Update-Feed im Tagged-Release-Workflow:** Website-Build, Prüfung des Versionsfeeds und Upload der Artefakte laufen jetzt tatsächlich nach der Veröffentlichung des GitHub Releases. Diese Schritte waren zuvor versehentlich in die Artefaktliste der Release-Action eingerückt und wurden deshalb übersprungen.
+
+Wer v1.43.0 installiert hat, sollte v1.43.1 verwenden. Die Funktionen bleiben unverändert; korrigiert werden nur die Versionsmetadaten und der Release-Workflow.
+
+Die korrigierte Version ist als [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1) verfügbar.
+
+---
+
+## v1.43.0 (2026-09-03)
+
+**Zuverlässigere Log-Synchronisation, gespeicherte Tabellenlayouts und bessere DX-Cluster-Kompatibilität**
+
+v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und den stabilen Betrieb während längerer Conteste. Hinzu kommen praktische Einstellmöglichkeiten für Tabellenlayouts und die Stationsgruppierung auf der Karte.
+
+### Neu
+
+- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
+
+- **Tabellenlayout automatisch gesichert:** Tabellen erhalten beim ersten brauchbaren Inhalt sinnvolle Breiten. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden nach kurzer Verzögerung in `preferences.xml` geschrieben. Haupt- und Monitorfenster behalten getrennte Layouts für DXCluster und QSO of the other.
+
+- **Tooltips für gekürzte Tabellenwerte:** Normale Tabellenzellen zeigen ihren vollständigen Inhalt, wenn die sichtbare Spalte zu schmal ist. Funktionale Tooltips und anklickbare Links bleiben davon unberührt.
+
+- **Hinweis bei neu angelegtem Simplelogfile:** Fehlt die ausgewählte Datei, legt KST4Contest sie an und zeigt einen Hinweis mit Dateipfad, konkretem Testablauf und Link zum passenden Abschnitt des Handbuchs.
+
+### Geändert
+
+- **Robuste Simplelogfile-Auswertung:** Die ausgewählte Datei wird einmal pro Minute ausgewertet und nach jedem Durchlauf geschlossen, damit das Logprogramm sie ersetzen oder rotieren kann. Erkannte Rufzeichen setzen den globalen Worked-Status für alle aktiven Suffixvarianten des Basisrufzeichens. Bei deaktivierter Funktion findet kein Dateizugriff statt; Lese- oder Erstellungsfehler beenden die periodische Aufgabe nicht mehr.
+
+- **Einheitliche Bandwerte externer Logger:** UCXLog-kompatible Pakete und Win-Test-Ereignisse verwenden eine gemeinsame Bandnormalisierung. Numerische Werte, Meter- und Zentimeterangaben sowie die vorhandenen Win-Test-IDs setzen damit dieselben Worked-Markierungen und Worked-Großfelder. Insbesondere `2320`, `5760` und `10368` werden zuverlässig verarbeitet; bei fehlendem oder unbekanntem Band wird nur der globale Worked-Status gesetzt.
+
+- **Kompakte vollständige Frequenzen erkannt:** Vollständige Frequenzangaben ohne Dezimaltrenner werden auf allen unterstützten Bändern akzeptiert; die letzten drei Ziffern bilden den kHz-Anteil. Nackte dreistellige Zahlen benötigen weiterhin einen erkennbaren Frequenzkontext, damit Signalrapporte und andere Zahlen nicht als QRG behandelt werden.
+
+- **DXSpider-kompatibles Spotformat:** Lokale DX-Cluster-Spots verwenden eine feste 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt unbemerkt abgeschnitten zu werden. Damit ist [Issue #86](https://github.com/praktimarc/kst4contest/issues/86) behoben.
+
+- **Aktive ON4KST-Verbindungsprüfung:** Ein ruhiger Chatserver wird mit einer expliziten, sitzungsweiten Abfrage geprüft, bevor die Verbindung als unterbrochen gilt. Heartbeat und Prüftelegramme behalten das erforderliche CR/LF-Framing.
+
+- **Zuverlässige Altersmarkierung privater Nachrichten:** Eingehende Privatnachrichten verwenden bis zu fünf Minuten lang die definierten grünen Altersstufen. Eigene Nachrichten behalten ihre separate Darstellung; leere oder wiederverwendete Tabellenzeilen kehren zum normalen Design zurück und behalten keine veraltete Hervorhebung.
+
+### Behoben
+
+- **Worked-Status nach dem Login:** Persistierte SQLite-Informationen werden vor der Veröffentlichung jeder initialen ON4KST-Benutzerliste geladen und angewendet. Erneute Verbindungen, beide Kategorien und alle aktiven Varianten eines Basisrufzeichens starten dadurch mit dem richtigen Status. Damit ist [Issue #85](https://github.com/praktimarc/kst4contest/issues/85) behoben.
+
+- **Fehlerhafte Trennung bei ruhigem Server:** Eine gültige ON4KST-Verbindung wird nicht mehr beendet, nur weil der Server gerade keine Aktivitätszeilen überträgt.
+
+### Dokumentation und Auslieferung
+
+- Das deutsche und englische Handbuch wurden mit der Implementierung abgeglichen und überarbeitet. Ein neues Kapitel zum Contest-Workflow verbindet die einzelnen Funktionen zu einem praktischen Betriebsablauf; außerdem wurden die Abschnitte zu Dual Chat, Privatnachrichten, QRG-Synchronisation, Simplelogfile-Auswertung und Konfiguration präzisiert.
+
+- Die Website beschreibt Band- und Richtungsgelegenheiten, Stationskarte, QRG-Verarbeitung, Filter, globale Nachrichtenansichten, Privatnachrichten und Log-Synchronisation jetzt ausführlicher.
+
+- Die AUR-Paketdefinitionen wurden auf v1.43.0 aktualisiert.
+
+Die vollständige Funktionalität von v1.43.0 steht im [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0) bereit. Wegen der inkonsistenten eingebetteten Versionsmetadaten ist v1.43.1 der empfohlene Paketstand.
+
+---
+
## v1.42.0 (2026-08-22)
**Gemeinsamer Bandkontext, sitzungsbasierte ON4KST-Verbindung und signierte macOS-Pakete**
@@ -38,21 +104,17 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **Filter zurücksetzen:** Ein eigener Reset-Button entfernt die aktiven Filterprädikate der Benutzerliste zuverlässig.
-- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die automatisch gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
+- **Kartencluster:** Räumlich dicht beieinanderliegende Stationen werden bei niedrigen Zoomstufen zusammengefasst. Die ausgewählte Station und relevante Richtungsgelegenheiten bleiben einzeln sichtbar.
- **Ausblendbare Streckenanalyse:** Geländeprofil und Analysebereich der Stationskarte können vollständig ausgeblendet werden. Die Auswahl wird gespeichert und beim nächsten Programmstart wiederhergestellt.
### Geändert
-- **Tabellenlayout automatisch gesichert:** Tabellen werden beim ersten brauchbaren Inhalt sinnvoll dimensioniert. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden verzögert in `preferences.xml` gespeichert; Haupt- und Monitorfenster behalten für DXCluster und QSO of the other getrennte Layouts. Gekürzte normale Zellwerte zeigen ihren Volltext im Tooltip, ohne funktionale Tooltips oder anklickbare Links zu verdrängen.
-
-- **DX-Cluster-Zeilenformat vereinheitlicht:** Lokale Spots verwenden jetzt eine feste, DXSpider-kompatible 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt auch bei Mikrowellenfrequenzen bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt still abgeschnitten zu werden; AirScout- und Testkommentare sind entsprechend kompakter.
-
- **Sessionbezogene ON4KST-Verbindungssteuerung:** Socket, Reader, Writer, Messagebus und Warteschlangen gehören jetzt zu einer eindeutig identifizierten Verbindungssession. Veraltete Threads einer abgelösten Verbindung können dadurch keine Daten mehr verarbeiten oder die neue Verbindung schließen. `ONLINE` wird erst nach bestätigtem Login und vollständig empfangenen Benutzerlisten gemeldet. Verbindungsaufbau, Login und Synchronisation besitzen feste Zeitlimits; Heartbeats, ausbleibende Eingangsdaten, EOF sowie Lese- und Schreibfehler werden überwacht und lösen bei Bedarf einen kontrollierten Neuaufbau mit Backoff aus.
- **ON4KST-Protokollbefehle abgesichert:** Ausgehende Befehle werden zentral aufgebaut und auf gültige Kategorien, Locatoren und unerlaubte Frame-Trennzeichen geprüft. Da ON4KST pro TCP-Session nur einen Locator verwaltet, wird für beide Chat-Kategorien der Hauptlocator verwendet und eine abweichende zweite Konfiguration protokolliert, statt widersprüchliche Befehle an den Server zu senden.
-- **QRG-Erkennung präzisiert:** Vollständige Frequenzangaben werden auch ohne Dezimaltrenner erkannt; die letzten drei Ziffern bilden dabei den kHz-Anteil. Relative Frequenzen bleiben unverändert, und nackte dreistellige Zahlen gelten weiterhin nur bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
+- **QRG-Erkennung präzisiert:** Vollständige und relative Frequenzangaben werden weiterhin erkannt. Nackte dreistellige Zahlen gelten nur noch bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
- **Stationsbezogener Frequenzkontext:** Bei relativen QRGs verwendet KST4Contest zuerst einen höchstens 30 Minuten alten Bandkontext derselben Station. Erst wenn dieser fehlt, wird das global konfigurierte Fallback-Band verwendet.
@@ -86,10 +148,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **DXLog-Gesamtlog übernommen:** Der UCXLog-kompatible UDP-Listener verarbeitet neben `contactinfo` auch `contactreplace`. Dadurch kann ein von DXLog.net als vollständiges Log ausgesendeter Datenbestand eingelesen werden.
-- **Logger-Bandwerte vereinheitlicht:** Numerische sowie Meter- und Zentimeterangaben aus UCXLog-kompatiblen QSO-Paketen und die Band-IDs von Win-Test werden einmal normalisiert und danach einheitlich für Worked-Markierungen und Worked-Großfelder verwendet. Dadurch setzen insbesondere `2320`, `5760` und `10368` zuverlässig ihre vorhandenen Bandmarkierungen. Bei einem fehlenden oder unbekannten Band bleibt es beim globalen Worked-Status.
-
-- **Simplelogfile-Verhalten präzisiert:** Die ausgewählte Textdatei wird einmal pro Minute mit einem festen Rufzeichenmuster ausgewertet. Treffer setzen den globalen Worked-Status aller aktiven Varianten des Basisrufzeichens, werden aber nicht in SQLite persistiert. Eine fehlende Datei wird angelegt; Lese- und Erstellungsfehler beenden die periodische Auswertung nicht. Ein Datenbank-Reset verändert die Datei nicht, sodass enthaltene Rufzeichen bei der nächsten Auswertung erneut als gearbeitet markiert werden.
-
- **Automatische QRG-Übernahme abgesichert:** `MYQRG` wird nur von einer aktivierten Schnittstelle aktualisiert, die tatsächlich gültige `RadioInfo`- beziehungsweise Win-Test-`STATUS`-Pakete liefert. Eine aktivierte, aber nicht liefernde Quelle ersetzt die notwendige Funktionsprüfung oder manuelle QRG-Pflege nicht.
- **Versionserkennung verbessert:** Versionsnummern werden semantisch verglichen, damit beispielsweise Patch-Versionen und Nightly-Stände nicht mehr durch eine einfache Fließkommazahl falsch eingeordnet werden.
@@ -98,8 +156,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **Zuverlässige Benutzerliste beim Login:** Ungültige oder unvollständige `UA0`-Teilnehmerdatensätze werden einzeln verworfen und protokolliert, ohne die Verarbeitung der alphabetisch folgenden Teilnehmer abzubrechen. Die gültigen Einträge werden zunächst pro Kategorie gesammelt und erst mit dem ersten zugehörigen `UE`-Abschlussframe vollständig veröffentlicht.
-- **Persistierter Worked-Status beim Listenaufbau:** Beim Abschluss jeder initialen ON4KST-Benutzerliste wird der SQLite-Zustand einmal geladen und vor der Veröffentlichung auf die neuen Chatmember angewendet. Das gilt für beide Kategorien, erneute Verbindungen und alle aktiven Varianten eines Basisrufzeichens.
-
- **Benutzerliste verschwindet nach dem Login:** ON4KST kann nach Namens-, Status- oder anderen Live-Änderungen weitere `UE`-Frames für dieselbe Kategorie senden. Wiederholte Abschlussframes werden jetzt erkannt und ignoriert, damit eine bereits gefüllte Benutzerliste nicht durch eine leere Momentaufnahme ersetzt wird.
- **Fehlgeschlagener Erstaufbau und Verbindungsverlust:** Wenn beim Programmstart keine Verbindung zum Server hergestellt werden kann, läuft KST4Contest nicht mehr in eine Endlos- oder Busy-Wait-Schleife. Die Oberfläche bleibt bedienbar und weitere Versuche erfolgen mit begrenztem Backoff. Auch ein vom Server geschlossener oder über längere Zeit stummer Socket wird zuverlässig erkannt.
diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md
index 1635ce44..af1cb47b 100644
--- a/github_docs/en-Changelog.md
+++ b/github_docs/en-Changelog.md
@@ -8,6 +8,72 @@ Published Stable versions and their application packages are available under [Gi
---
+## v1.43.1 (2026-09-03)
+
+**Corrected version metadata**
+
+v1.43.1 contains the same functional changes as v1.43.0. It corrects the application and build metadata used for display, ON4KST identification and update comparison. Some metadata in the first v1.43.0 package set still identified the build as version 1.42.
+
+### Fixed
+
+- **Consistent semantic version:** The user-visible application version now uses the complete `1.43.1` form. The compact value `1.431` remains only in the deprecated numeric field required for compatibility with older update feeds.
+
+- **Tagged-release update feed:** The website build, version-feed validation and artifact upload now run as actual workflow steps after the GitHub Release has been published. They were previously indented into the release action's artifact list and therefore skipped.
+
+Users of v1.43.0 should install v1.43.1. The functionality is unchanged; the update only corrects the version metadata and release workflow.
+
+The corrected version is available as [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
+
+---
+
+## v1.43.0 (2026-09-03)
+
+**More reliable log synchronisation, persistent layouts and better DX Cluster compatibility**
+
+v1.43 concentrates on reliability around external log data and long-running contest operation. It also adds practical control over table layouts and station grouping on the map.
+
+### Added
+
+- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
+
+- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay. The main and monitor windows keep separate DXCluster and QSO-of-the-other layouts.
+
+- **Tooltips for truncated table values:** Normal table cells expose their complete text when the visible column is too narrow, without replacing functional tooltips or clickable links.
+
+- **Simplelogfile creation notice:** When the selected file does not exist, KST4Contest creates it and displays a notice with the file path, a concrete test procedure and a link to the relevant manual section.
+
+### Changed
+
+- **Robust Simplelogfile evaluation:** The selected file is evaluated once per minute and closed after every pass so that the logging application can replace or rotate it. Detected callsigns apply the global Worked state to every active suffix variant of the base callsign. Disabling the function prevents any file access, while read or creation errors no longer terminate the periodic task.
+
+- **Consistent external logger bands:** UCXLog-compatible packets and Win-Test events use one shared band normalisation. Numeric values, metre and centimetre designators and the existing Win-Test IDs now set the same Worked flags and worked grid squares. Values including `2320`, `5760` and `10368` are handled reliably; a missing or unknown band sets only the global Worked state.
+
+- **Compact full-frequency recognition:** Complete frequencies without a decimal separator are accepted across the supported bands, with the final three digits interpreted as the kHz part. Bare three-digit numbers still require recognisable frequency context so that signal reports and unrelated numbers are not treated as QRGs.
+
+- **DXSpider-compatible spot format:** Local DX Cluster spots use a fixed 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The format remains stable up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated. This resolves [Issue #86](https://github.com/praktimarc/kst4contest/issues/86).
+
+- **Active ON4KST connection probe:** A quiet chat server is checked with an explicit session-wide probe before the connection is treated as dead. Heartbeats and probe frames retain the required CR/LF framing.
+
+- **Reliable private-message age highlighting:** Incoming private messages use the defined green age levels for up to five minutes. Locally sent messages retain their separate style, and empty or reused table rows return to the normal design instead of keeping an obsolete highlight.
+
+### Fixed
+
+- **Worked state after login:** Persisted SQLite Worked information is loaded and applied before each initial ON4KST user list is published. Reconnects, both categories and all active variants of a base callsign therefore start with the correct state. This resolves [Issue #85](https://github.com/praktimarc/kst4contest/issues/85).
+
+- **False disconnect during quiet periods:** A valid ON4KST connection is no longer closed merely because the server currently has no activity lines to send.
+
+### Documentation and packaging
+
+- The German and English manuals were revised against the implementation. A new contest-workflow chapter connects the individual functions into a practical operating sequence, while the sections on dual chat, private-message handling, QRG synchronisation, Simplelogfile evaluation and configuration were clarified.
+
+- The website feature pages now describe band and direction opportunities, the station map, QRG handling, filters, global message views, private-message handling and logger synchronisation in more detail.
+
+- The AUR package definitions were updated for v1.43.0.
+
+The complete v1.43.0 functionality is available in [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0). Because its embedded version metadata is inconsistent, v1.43.1 is the recommended package set.
+
+---
+
## v1.42.0 (2026-08-22)
**Shared band context, session-based ON4KST connection and signed macOS packages**
@@ -38,21 +104,17 @@ v1.42 brings several previously separate calculations together. Band information
- **Filter reset:** A dedicated reset button reliably removes the active user-list filter predicates.
-- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The automatically stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
+- **Map clustering:** Stations close to each other are grouped at lower zoom levels. The selected station and relevant direction opportunities remain individually visible.
- **Hideable path analysis:** The terrain profile and analysis section of the station map can be hidden completely. The selected state is stored and restored at the next application start.
### Changed
-- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay; the main and monitor windows keep separate DXCluster and QSO-of-the-other layouts. Truncated normal cell values expose their full text in a tooltip without replacing functional tooltips or clickable links.
-
-- **Unified DX Cluster line format:** Local spots now use a fixed, DXSpider-compatible 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The layout remains stable for microwave frequencies up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated; AirScout and test comments are correspondingly more compact.
-
- **Session-based ON4KST connection lifecycle:** Each socket, reader, writer, message bus and queue now belongs to an explicitly identified connection session. Delayed threads from an obsolete connection can therefore no longer process data or close its replacement. `ONLINE` is reported only after the login has been accepted and all requested user lists have been received. Connection setup, login and synchronisation use bounded timeouts, while heartbeats, missing inbound traffic, EOF and read or write failures trigger controlled reconnect attempts with backoff where appropriate.
- **Validated ON4KST protocol commands:** Outgoing frames are built centrally and checked for valid categories, locators and prohibited frame delimiters. Because ON4KST maintains one locator per TCP session, the main locator is used for both chat categories and a conflicting secondary configuration is logged instead of sending contradictory commands to the server.
-- **More precise QRG recognition:** Complete frequencies are also recognised without a decimal separator, with the final three digits interpreted as the kHz part. Relative frequencies remain unchanged, and bare three-digit numbers still require recognisable frequency context. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
+- **More precise QRG recognition:** Complete and relative frequency references continue to be recognised. Bare three-digit numbers are treated as QRGs only when a frequency context is available. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
- **Station-specific frequency context:** For relative QRGs, KST4Contest first uses a band context for the same station which is no more than 30 minutes old. The globally configured fallback band is used only when this context is unavailable.
@@ -86,10 +148,6 @@ v1.42 brings several previously separate calculations together. Band information
- **DXLog full-log import:** In addition to `contactinfo`, the UCXLog-compatible UDP listener processes `contactreplace`. This allows a complete log broadcast by DXLog.net to be imported.
-- **Consistent logger band values:** Numeric, metre and centimetre values from UCXLog-compatible QSO packets and Win-Test band IDs are normalised once and then used consistently for Worked marks and worked grid squares. In particular, `2320`, `5760` and `10368` now reliably set their existing band marks. A missing or unknown band continues to set only the global Worked status.
-
-- **Defined Simplelogfile behaviour:** The selected text file is evaluated once per minute using a fixed callsign pattern. Matches set the global Worked status for all active variants of the base callsign but are not persisted in SQLite. A missing file is created, and read or creation errors do not terminate the periodic task. A database reset does not change the file, so callsigns contained in it are marked as worked again during the next evaluation.
-
- **Guarded automatic QRG updates:** `MYQRG` is updated only by an enabled interface which actually supplies valid `RadioInfo` or Win-Test `STATUS` packets. An enabled source which provides no data does not remove the need for a functional check or manual QRG maintenance.
- **Improved version comparison:** Versions are compared semantically so that patch releases and Nightly versions are not misclassified by conversion to a floating-point number.
@@ -98,8 +156,6 @@ v1.42 brings several previously separate calculations together. Band information
- **Reliable initial user list:** Invalid or incomplete `UA0` member records are rejected and logged individually without preventing alphabetically following members from being processed. Valid entries are staged per category and published as one complete snapshot when the first corresponding `UE` end marker is received.
-- **Persisted Worked state during initial-list setup:** At the end of each initial ON4KST user list, the SQLite state is loaded once and applied to the new chat members before publication. This covers both categories, reconnects and every active variant of a base callsign.
-
- **User list disappearing after login:** ON4KST may send additional `UE` frames for the same category after name, state or other live updates. Repeated end markers are now detected and ignored so that an already populated user list cannot be replaced by an empty snapshot.
- **Failed initial connection and lost sockets:** An unavailable server during startup no longer sends KST4Contest into an endless or busy-wait loop. The user interface remains responsive and further attempts use bounded reconnect backoff. Sockets closed by the server, or connections without inbound traffic for an excessive period, are also detected reliably.
diff --git a/src/main/java/kst4contest/ApplicationConstants.java b/src/main/java/kst4contest/ApplicationConstants.java
index 6da42ff4..71f64575 100644
--- a/src/main/java/kst4contest/ApplicationConstants.java
+++ b/src/main/java/kst4contest/ApplicationConstants.java
@@ -20,7 +20,7 @@ public class ApplicationConstants {
/**
* Version shown to the user and used for semantic version comparison.
*/
- public static final String APPLICATION_CURRENT_VERSION = "1.431";
+ public static final String APPLICATION_CURRENT_VERSION = "1.43.1";
/**
* Legacy numeric representation used only while older update feeds and
diff --git a/website/src/news/2026-09-03-version-1-43.md b/website/src/news/2026-09-03-version-1-43.md
new file mode 100644
index 00000000..30754c3d
--- /dev/null
+++ b/website/src/news/2026-09-03-version-1-43.md
@@ -0,0 +1,29 @@
+---
+title: Version 1.43.1 released
+summary: Reliable log synchronisation, persistent layouts and better DX Cluster compatibility
+date: 2026-09-03
+---
+
+## Version 1.43.1
+
+Version 1.43 focuses on the parts of KST4Contest that have to keep working during a long contest: ON4KST connection monitoring, log synchronisation, Worked information and the data sent to external logging software.
+
+The first v1.43.0 packages already contained all functional changes described below, but some embedded version metadata still identified the build as version 1.42. v1.43.1 corrects this. If you already downloaded v1.43.0, use v1.43.1 instead.
+
+### What changed
+
+- **More reliable log synchronisation:** The Simplelogfile parser now closes the selected file after every pass, handles suffix variants by their base callsign and reports when a missing file has been created. UCXLog-compatible packets and Win-Test events share the same band normalisation, including the existing microwave bands.
+- **Correct Worked state after login:** Persisted Worked information is applied before the initial ON4KST user list becomes visible. Reconnects and both chat categories therefore start with the correct state.
+- **No false disconnect on a quiet server:** KST4Contest actively checks a quiet ON4KST session before treating it as dead. A period without new activity lines no longer causes an unnecessary reconnect.
+- **DX Cluster compatibility:** Local spots now use a fixed, DXSpider-compatible 75-character line format that is also accepted reliably by DXLog. Frequencies up to 24 GHz keep the required column positions.
+- **Layouts that stay where you put them:** Table column widths, window sizes and relevant dividers are saved automatically. Truncated table values expose their full text in a tooltip.
+- **Controllable map grouping:** **Group nearby stations** switches map clustering on or off without changing the current viewport or selected station.
+- **Smaller corrections:** Complete QRGs without a decimal separator are recognised across the supported bands, and private-message age highlighting no longer remains attached to reused table rows.
+
+The complete technical list is available in the [changelog](/manual/en/changelog/) and the [GitHub release notes](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
+
+### Getting it
+
+Packages for Windows, Linux and macOS are available on the [download page](/download/) and in the [GitHub release](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1). The AUR packages are updated through their normal release workflow.
+
+The German and English manuals have also been revised. The new contest-workflow chapter connects the individual functions into a practical operating sequence instead of describing them only in isolation. 73
--
2.51.1
Binary file not shown.
Binary file not shown.
+29 -45
View File
@@ -1,6 +1,6 @@
# KST4Contest Project Context
Last reviewed: 2026-09-13
Last reviewed: 2026-09-03
This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous.
@@ -52,7 +52,7 @@ JavaFX ObservableList / UI state
## Configuration and Layout Persistence
- The current `preferences.xml` configuration version is 7. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns.
- Configuration and layout live in the `preferences.xml` of the **active operator profile**; see "Operator Profiles and Per-Profile Persistence" below. The current `preferences.xml` configuration version is 7. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns.
- `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility.
- Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value.
- Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores.
@@ -61,6 +61,27 @@ JavaFX ObservableList / UI state
- Full and selective writes are synchronized and replace `preferences.xml` atomically. Missing, unknown or malformed width entries do not prevent loading and fall back to initial sizing.
- Older configuration files require no migration. Older KST4Contest versions can ignore the additional elements; a complete rewrite by such a version may discard column widths without invalidating the remaining file.
## Operator Profiles and Per-Profile Persistence
- One operator profile owns one `preferences.xml` and one worked-station database. Everything else under `~/.praktiKST/` stays global: CSS, audio files, DEM and terrain packages, the error log and the version-info feed.
- The **root profile** is the historic flat installation: `preferences.xml` and `praktiKST.db` directly below the application directory. It is never moved, and it always uses the common station database, because that database is the installation's own.
- Additional profiles live under `profiles/<profileId>/`. `profileId` is a stable, file-system-safe slug assigned once; renaming a profile changes only its display name and never moves a directory.
- The registry `profiles.xml` is created lazily. An installation that has only the flat layout gets no registry and no `profiles/` directory; the root profile is synthesised in memory. Startup with no or exactly one profile therefore asks nothing and writes nothing, and a downgrade to an older release is a no-op.
- The registry stores `profileId`, `displayName`, `rootProfile` and `sharedWorkedDatabase`, never a path. Both file names are derived in `OperatorProfilePaths` alone, so a stored path cannot drift apart from the flag that produced it.
- `sharedWorkedDatabase` resolves to a path, not to a schema change: a sharing profile points at the flat `praktiKST.db`, an owning profile at its own file. There is no owner column, and no SQL statement in `DBController` knows about profiles.
- A profile database is created **empty**. The bundled `/praktiKST.db` resource carries several thousand foreign callsigns and `user_version = 0`; seeding an additional profile from it would show a new operator foreign data and trigger the full callsign-normalization rebuild. Only the root installation is seeded from the resource. `preferences.xml` of a new profile *is* seeded from `/praktiKSTpreferences.xml`, because its defaults are what a first installation gets.
- Worked-state semantics are unchanged and now apply per database file: normalized base callsign as key, worked state shared across suffix variants, three-day expiry, manual reset.
- `stn_loginCallSign` and `stn_loginCallSignRaw` default to empty. The preferences reader treats an empty element as "not set" and falls back to the field default, so a non-empty default would make a profile created without credentials come up carrying a compiled-in callsign.
- Passwords remain plaintext per profile. Profiles separate configuration; they are explicitly not an access-control boundary. This is documented in both manuals.
### Runtime profile switching
- A switch tears the current runtime down through `Kst4ContestApplication.shutdownRuntime()` and builds a **new** `Kst4ContestApplication` instance. Reusing the instance is not possible: many controls are inline-initialised instance fields, so a second `start()` would re-parent mounted nodes and register every listener twice. The approach is only sound because the class holds no mutable static state.
- `Platform.setImplicitExit(false)` is required, because closing every window during a switch would otherwise end the process. All exits therefore run through `ApplicationRuntimeLauncher.exitApplication()`, including the main window's close handler; JavaFX calls `stop()` only on the instance it launched itself.
- `shutdownRuntime()` is idempotent and must release everything that outlives a disconnect: the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler, the map tile proxy, the station map bridge listeners and its coalescing animation, both view timers and every owned stage. Several of these were real leaks before; they only became visible once a second runtime could exist.
- `ApplicationConstants.sessionRuntimeUniqueId` must not be regenerated during a switch, so UDP readers started earlier still recognise their own poison pill.
- The layout autosave is flushed and then cancelled before a switch, so a pending debounced write cannot land after the profile changed.
## External Interfaces
Treat current implementation/tests and authoritative upstream documentation as source of truth before modifying any interface.
@@ -114,6 +135,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
### Terrain data providers
- The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data.
- The terrain profile cache lives in its own global database `~/.praktiKST/terrainprofilecache.db`. It is deliberately not part of an operator profile: terrain profiles are pure geometry derived from two locators and a sample count, and at a multi operator station both operators share one location, so a per-profile copy would only double the traffic against the terrain service.
- Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself.
- `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain.
### ON4KST session and authentication
@@ -126,11 +149,11 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
### ON4KST session liveness
- Only after the session is fully authenticated and synchronized, more than 90 seconds without inbound server data trigger one client-side `CK|\r\n` liveness probe. The trailing pipe matches the framing used by the server for its own `CK|\r\n` probe. The probe state belongs to the TCP session, so a two-category session still sends only one probe per idle phase.
- The live ON4KST test returned `OK|\r\n` for `CK|\r\n`. KST4Contest treats both that confirmed frame and the originally specified `OK\r\n` form as internal responses: it records inbound activity, confirms the outstanding probe and does not publish the response as chat content. Any other inbound server frame also confirms reachability and starts a new idle phase.
- A `CK` initiated by the server remains a separate protocol case and receives the established empty CRLF response. It must not be confused with the client-side probe.
- After 90 seconds without inbound data, the application keeps the established empty CRLF heartbeat.
- At about 180 seconds of inbound idle time, the TCP session sends one `RDXQ|<main chat id>|` probe. The probe state belongs to the session, so a two-category session still sends only one probe per idle phase.
- Any subsequent inbound server frame confirms the probe. `DXQ` is accepted as the expected internal response and is not published as chat content.
- If no inbound frame arrives by about 210 seconds, the existing reconnect flow remains responsible for replacing the session.
- Liveness diagnostics contain the session id, opcode and timing only. They must not include credentials, complete server frames or normal chat messages.
- Probe diagnostics contain the session id, main category, opcode and timing only. They must not include credentials, complete server frames or normal chat messages.
## User Workflow / UI Invariants
@@ -170,51 +193,12 @@ After implementation use targeted documentation-impact checks. Do not run a comp
The repository contains the KST4Contest website under `website/`, published separately from the desktop application build.
- Historical GHz-Tagung papers are published as German and English PDF assets below `website/src/assets/papers/` and explained on `/background/`. Their editable ODT sources remain outside the public Eleventy input tree and must never be copied into the generated site.
- The home-page news teaser is derived from the date-sorted Eleventy `news` tag collection. It deliberately renders nothing when that collection is empty; it must not be pinned to a release version.
Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions.
- `APPLICATION_CURRENT_VERSION` is the user-visible semantic version and must use the dotted `major.minor.patch` form. `APPLICATION_CURRENTVERSIONNUMBER` is retained only for older feeds and encodes patch releases by appending the patch digit, for example `1.43.1` as `1.431`.
- The tagged-release workflow creates the GitHub Release before building the website update feed. This ordering is required because `versionInfo.js` reads the published release body through the GitHub Releases API.
- After publication, the workflow tests and builds the website, validates the expected Stable version, attaches `kst4ContestVersionInfo.xml` to the release and uploads the complete website build as a workflow artifact.
### Server-side website statistics
The detailed operator runbook is
[`website/ops/analytics/README.md`](../website/ops/analytics/README.md). This
section records only the durable architecture and operational boundaries.
#### Architecture and data flow
- Production runs on Ubuntu Server 24.04 with Nginx 1.24, Node.js 18.19.1 and GoAccess 1.8.1. GoAccess has GeoIP2/MMDB and OpenSSL support but no Zlib support; missing Zlib is intentional for this operating model.
- Nginx writes eligible `GET` page requests to a reduced, tab-separated analytics log in addition to the normal operational log. It records server name, client IP, time, method, normalized path without query string, protocol, status, bytes and user agent; referrer and remote user are omitted.
- Nginx removes assets, downloads, status/monitoring paths, update feed, counter requests and known crawler clients before logging. GoAccess supplies the second crawler-classification layer, anonymises IP addresses at the configured level and performs Country lookup locally through GeoLite2-Country.
- The hourly systemd timer starts the Node.js generator. Each registered site is processed from its optional uncompressed `.1` rotation followed by the current log; older `.gz` files are not part of regular processing. Logrotate retains raw analytics logs for 14 days and requires `delaycompress` for this handover.
- GoAccess maintains one private report/database per registered site and one combined report/database. The generator then updates its separate daily counter state and publishes `visitor-count.json` for enabled sites. Private reports are served with Basic Auth from `stats.hamradioonline.de`; the public home page requests only its same-origin counter file.
#### Durable invariants and persistence
- GoAccess defines a visit by IP address, date and user agent. The public value is therefore an approximate visit total, not a count of unique people, and remains separate from page views.
- The public contract contains only `schemaVersion`, `visits`, `since` and `updatedAt`. The browser uses no analytics cookie, local storage or third-party request; failed or invalid responses leave the counter hidden.
- The site registry at `/etc/hamradioonline-analytics/sites.json` owns stable site IDs, hostnames, current uncompressed log paths, activation dates and output targets. `stats.hamradioonline.de` is never registered as an analytics site. Existing counter state cannot be continued with a changed hostname or `activatedOn` without an explicit migration or reset decision.
- GoAccess databases below `/var/lib/hamradioonline-analytics/db` retain rolling 395-day detail through `--persist` and `--restore`. `/var/lib/hamradioonline-analytics/public-counter-state.json` retains daily values from activation onward and is the durable business source for the lifetime public total. Reports and public JSON files are derived outputs.
- GoAccess 1.8.1 exposes Country data as `geolocation`; combined jobs explicitly enable `VIRTUAL_HOSTS` and require `vhosts`. Individual site jobs do not enable that panel. `geo_location` and `virtual_hosts` are invalid interface names.
- Generator inputs and staged GoAccess outputs are validated before publication. Files are replaced atomically, dry-runs use temporary state without the production lock, and productive runs use `/run/hamradioonline-analytics/generator.lock`. Exit codes are 1 for runtime/publication errors, 2 for configuration errors and 3 when the production lock cannot be acquired.
#### Components, roles and external services
- `hamradio-analytics` is the locked, non-login service account which reads configuration, Country MMDB and analytics logs and writes service state. Nginx runs as `www-data`, writes the analytics log and can read only reports and the public counter, not private databases or counter state. `stats-reader` is a local Nginx Basic Auth login whose password file remains outside Git.
- Root-managed program/configuration lives below `/opt/hamradioonline-analytics` and `/etc/hamradioonline-analytics`; service state lives below `/var/lib/hamradioonline-analytics`. Report/public directories are shared read-only with Nginx through group ownership; no ACL dependency exists.
- MaxMind is used only by `geoipupdate` to download GeoLite2-Country. Visitor lookups are local; Account ID and License Key remain in the protected server configuration. Let's Encrypt supplies TLS for the statistics vhost through the permanent ACME webroot and `/snap/bin/certbot`. GitHub supplies website source and deployment, not analytics processing.
- The website deploy checks out `origin/main`, builds the static site and publishes it to the Nginx document root. It does not install or overwrite analytics programs/configuration, systemd, Nginx, Logrotate, GeoIP, Basic Auth or Certbot state; those remain separate manual server operations.
- Passwords, hashes, deployment credentials, MaxMind credentials, ACME account data and private TLS keys must remain outside the repository and ordinary diagnostic output.
#### Open operational work
- The server has no comprehensive automated backup plan yet. A future server-wide plan must include the non-regenerable counter state, GoAccess databases and protected operational configuration without extending the published 14-day raw-log retention.
- The first real rotation of the dedicated analytics log still requires an explicit operational check of `.1`, ownership/readability, subsequent generator success and absence of duplicate counting. This is not a current service blocker.
## Important Decisions and Workarounds
- Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.
+35
View File
@@ -8,6 +8,41 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git
---
## v1.50 (in Entwicklung)
**Operator-Profile**
Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators und Layouts verwenden, ohne sich gegenseitig die Konfiguration zu überschreiben. Damit ist [Issue #57](https://github.com/praktimarc/kst4contest/issues/57) umgesetzt.
### Neu
- **Operator-Profile:** Jedes Profil hat seine eigene `preferences.xml` und damit eigene fachliche Einstellungen und einen eigenen Layoutstand. Verwaltet werden Profile im neuen Reiter **Profiles** des Einstellungsfensters: anlegen, duplizieren, umbenennen, löschen und aktivieren.
- **Gemeinsame oder eigene gearbeitete Stationen:** Pro Profil wird entschieden, ob es eine eigene Worked-Datenbank bekommt oder die gemeinsame Stationsdatenbank benutzt. Eine Multi-OP-Station mit einem einzigen Stationslog teilt den Worked-Status, zwei OMs mit verschiedenen Rufzeichen an einem Rechner trennen ihn.
- **Profilwahl beim Start:** Mit nur einem Profil fragt KST4Contest beim Start nichts und verhält sich unverändert. Ab zwei Profilen erscheint eine kleine Auswahl mit vorausgewähltem letzten Profil; Enter oder Doppelklick starten sofort. Der Aufrufparameter `--profile=<Name>` überspringt die Auswahl.
- **Profilwechsel im laufenden Betrieb:** **File → Switch operator profile...** trennt die Verbindung und baut die Oberfläche mit den Einstellungen des gewählten Profils neu auf, ohne Programmneustart.
### Geändert
- **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen.
- **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten.
### Behoben
- **Freigegebene Hintergrundressourcen:** Der ON4KST-Überwachungsthread, der Sked-Erinnerungs-Scheduler, der Reachability-Executor, der PSTRotator-Wiederholungs-Scheduler und der Kachel-Proxy der Karte werden beim Schließen des Chatcontrollers freigegeben. Bisher liefen sie bis zum Programmende weiter.
### Hinweise zur Aktualisierung
- Bestehende Installationen werden **nicht** verändert: `preferences.xml` und `praktiKST.db` bleiben genau dort liegen, wo sie sind, und werden zum Profil **Default**. Es wird keine Datei verschoben oder kopiert.
- Eine Profil-Registry entsteht erst beim Anlegen des zweiten Profils. Wer nur ein Profil benutzt, merkt von der Änderung nichts.
- Eine Rückkehr zu einer älteren KST4Contest-Version bleibt möglich; sie findet ihre Dateien unverändert vor.
- Passwörter stehen weiterhin im Klartext in der `preferences.xml` des jeweiligen Profils. Profile trennen die Konfiguration, sie sind kein Zugriffsschutz.
---
## v1.43.1 (2026-09-03)
**Korrigierte Versionsmetadaten**
-18
View File
@@ -240,24 +240,6 @@ Prüfe zuerst, welche Frequenzen für die betreffende Station innerhalb der letz
Ist kein aktueller Stationskontext vorhanden, prüfe die Auswahl unter **Fallback band for relative QRG detection**. Das Fallback wird nur benötigt, wenn sich das Band weder aus einer vollständigen Frequenz noch aus dem aktuellen Kontext des Absenders ergibt.
### Das Logprogramm läuft in einer eigenen Sandbox
Ein Logprogramm, das als Flatpak oder über eine Wine-Umgebung wie Bottles gestartet wird, benutzt die Netzwerkrechte dieser Sandbox. Teilt die Sandbox das Netzwerk des Rechners nicht, ist `127.0.0.1` darin nicht das `127.0.0.1`, auf dem KST4Contest lauscht. Die Verbindung wird dann abgewiesen, obwohl KST4Contest den Port korrekt meldet.
Bei einem Flatpak-Logprogramm lassen sich die Rechte so prüfen:
```bash
flatpak info --show-permissions <Anwendungs-ID>
```
Im Abschnitt `[Context]` muss `shared=network` stehen. Nachträglich vergeben lässt es sich mit:
```bash
flatpak override --user --share=network <Anwendungs-ID>
```
Das Gleiche gilt, wenn KST4Contest selbst als Flatpak läuft. Dessen veröffentlichtes Manifest enthält `--share=network` bereits, ein lauschender Port ist daher vom Rechner selbst und von anderen Anwendungen darauf erreichbar.
### Der Spot wird vom Logger ausgeblendet
Verwende ein Spotter-Rufzeichen, das nicht mit dem eigenen Contest-Rufzeichen identisch ist. Abhängig vom Logger können eigene Spots gefiltert oder besonders behandelt werden.
+55
View File
@@ -878,6 +878,61 @@ Anzeige und Herleitung: [Gearbeitete Rufzeichen, neue Bänder und neue Großfeld
---
## Operator-Profile (ab v1.50)
Mehrere Operateure an einem Rechner brauchen unterschiedliche Rufzeichen, Locators und Layouts. Ein Operator-Profil bündelt genau das: **jedes Profil hat seine eigene `preferences.xml` und damit seine eigenen fachlichen Einstellungen und seinen eigenen Layoutstand.**
### Wo die Profile liegen
| Profil | Einstellungen | Gearbeitete Stationen |
|---|---|---|
| **Default** | `~/.praktiKST/preferences.xml` | `~/.praktiKST/praktiKST.db` |
| weitere Profile | `~/.praktiKST/profiles/<Profil-ID>/preferences.xml` | je nach Einstellung gemeinsam oder `~/.praktiKST/profiles/<Profil-ID>/praktiKST.db` |
Unter Windows entsprechend unterhalb von `%USERPROFILE%\.praktiKST\`.
Alle übrigen Daten bleiben gemeinsam: Klangdateien, Farbschemata, DEM- und Terrainpakete, der Zwischenspeicher der Geländeprofile (`terrainprofilecache.db`), das Fehlerprotokoll und die Versionsinformationen.
Das Profil **Default** benutzt weiterhin genau die Dateien, die eine bestehende Installation schon hat. **Bei der Aktualisierung auf v1.50 wird keine Datei verschoben, kopiert oder umgeschrieben.** Wer eine ältere KST4Contest-Version wieder installiert, findet seine Konfiguration und seine gearbeiteten Stationen unverändert vor.
### Gemeinsame oder eigene gearbeitete Stationen
Beim Anlegen eines Profils wird entschieden, woher dessen Worked-, NOT-QRV- und Großfeld-Daten kommen:
- **Eigene gearbeitete Stationen** (Vorgabe): Das Profil bekommt eine eigene, zunächst leere Datenbank. Sinnvoll, wenn sich zwei OMs mit verschiedenen Rufzeichen einen Rechner teilen.
- **Gemeinsame Stationsdatenbank**: Das Profil benutzt `~/.praktiKST/praktiKST.db`, also dieselbe Datenbank wie das Profil **Default**. Das ist der Fall der **Multi-OP-Station**: es gibt nur ein Stationslog, also soll auch der Worked-Status für alle Operateure derselbe sein.
Ein Wechsel zwischen beiden Einstellungen verschiebt keine Daten. Bereits gesammelte Worked-Daten bleiben dort liegen, wo sie entstanden sind. Der Drei-Tage-Ablauf und die Reset-Schaltfläche wirken jeweils auf die Datenbank des gerade aktiven Profils.
### Profile verwalten
Der Reiter **Profiles** im Einstellungsfenster zeigt alle Profile mit Name, Art der Worked-Daten und letzter Benutzung.
- **New profile...** legt ein Profil an. Es startet **ohne Rufzeichen und ohne Passwort**; beides wird anschließend im Reiter **Station** eingetragen.
- **Duplicate...** übernimmt die komplette Konfiguration des gewählten Profils — Antenne, Locator, Layout, Beacon, Integrationen — **außer Rufzeichen und Passwort**. Gearbeitete Stationen werden nie mitkopiert.
- **Rename...** ändert nur den angezeigten Namen. Verzeichnisse und Dateien bleiben unberührt.
- **Delete...** entfernt das Profilverzeichnis endgültig. Das Profil **Default** und das gerade aktive Profil lassen sich nicht löschen. Bei einem Profil mit gemeinsamer Stationsdatenbank bleibt diese unangetastet.
- **Change worked stations...** schaltet zwischen gemeinsamer und eigener Datenbank um.
- **Switch to selected profile...** wechselt das Profil im laufenden Betrieb.
### Profilwahl beim Start
- Solange nur **ein** Profil existiert, fragt KST4Contest beim Start **nichts** und startet wie bisher. Es wird auch keine Profil-Registry angelegt. Erst das Anlegen des zweiten Profils erzeugt `~/.praktiKST/profiles.xml`.
- Ab **zwei** Profilen erscheint beim Start eine kleine Auswahl. Das zuletzt benutzte Profil ist vorausgewählt, **Enter** oder ein Doppelklick starten sofort.
- Der Aufrufparameter `--profile=<Name>` überspringt die Auswahl und startet direkt das genannte Profil. Erlaubt sind die Profil-ID und der angezeigte Name, Groß- und Kleinschreibung spielen keine Rolle. Ein unbekannter Name führt zu einem Hinweis und danach zur normalen Auswahl — der Start wird nie verweigert.
### Profil im laufenden Betrieb wechseln
**File → Switch operator profile...** oder die Schaltfläche im Reiter **Profiles** wechseln ohne Programmneustart. Nach einer Sicherheitsabfrage wird die ON4KST-Verbindung getrennt und die Oberfläche mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut. Der Layoutstand des bisherigen Profils wird vorher gesichert; noch nicht mit **Save Settings** bestätigte fachliche Änderungen gehen dabei verloren.
Sobald mehr als ein Profil existiert, zeigt die Titelzeile des Hauptfensters zusätzlich den Profilnamen.
### Hinweis zu Passwörtern
Das ON4KST-Passwort steht wie bisher im Klartext in der `preferences.xml` des jeweiligen Profils. Auf einem gemeinsam genutzten Rechner kann jeder, der Zugriff auf das Benutzerkonto hat, die Passwörter aller Profile lesen. Profile trennen die Konfiguration, sie sind **kein** Zugriffsschutz.
---
## Dark Mode (ab v1.26)
Der Dark Mode wird über **Windows → Use dark mode design** aktiviert. Mit **Windows → Use default mode design** wird wieder das normale helle Farbschema geladen.
+35
View File
@@ -8,6 +8,41 @@ Published Stable versions and their application packages are available under [Gi
---
## v1.50 (in development)
**Operator profiles**
Several operators sharing one computer can now use their own callsigns, locators and layouts without overwriting each other's configuration. This implements [Issue #57](https://github.com/praktimarc/kst4contest/issues/57).
### New
- **Operator profiles:** every profile has its own `preferences.xml`, and therefore its own settings and its own window layout. Profiles are managed on the new **Profiles** tab of the settings window: create, duplicate, rename, delete and activate.
- **Shared or own worked stations:** each profile decides whether it gets its own worked database or uses the common station database. A multi operator station with a single station log shares the worked state; two operators with different callsigns on one computer keep it apart.
- **Choosing a profile at startup:** with only one profile, KST4Contest asks nothing at startup and behaves exactly as before. From two profiles on, a small selection appears with the last used profile preselected; Enter or a double click start immediately. The `--profile=<name>` argument skips the selection.
- **Switching profiles while running:** **File > Switch operator profile...** closes the connection and rebuilds the user interface with the settings of the selected profile, without restarting the program.
### Changed
- **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch.
- **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials.
### Fixed
- **Background resources are released:** the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler and the map tile proxy are released when the chat controller is closed. They used to keep running until the program ended.
### Upgrade notes
- Existing installations are **not** modified: `preferences.xml` and `praktiKST.db` stay exactly where they are and become the **Default** profile. No file is moved or copied.
- A profile registry only appears when the second profile is created. Anyone using a single profile will not notice the change.
- Going back to an older KST4Contest release stays possible; it finds its files unchanged.
- Passwords are still stored in clear text in each profile's `preferences.xml`. Profiles separate configuration; they are not an access control mechanism.
---
## v1.43.1 (2026-09-03)
**Corrected version metadata**
+55
View File
@@ -935,6 +935,61 @@ Display and derivation: [Worked Callsigns, New Bands and New Grid Squares](en-Fe
---
## Operator Profiles (from v1.50)
Several operators sharing one computer need different callsigns, locators and layouts. An operator profile bundles exactly that: **every profile has its own `preferences.xml`, and therefore its own settings and its own window layout.**
### Where the profiles live
| Profile | Settings | Worked stations |
|---|---|---|
| **Default** | `~/.praktiKST/preferences.xml` | `~/.praktiKST/praktiKST.db` |
| additional profiles | `~/.praktiKST/profiles/<profile ID>/preferences.xml` | shared, or `~/.praktiKST/profiles/<profile ID>/praktiKST.db` |
On Windows the same files live below `%USERPROFILE%\.praktiKST\`.
Everything else stays shared: audio files, colour schemes, DEM and terrain packages, the terrain profile cache (`terrainprofilecache.db`), the error log and the version information.
The **Default** profile keeps using exactly the files an existing installation already has. **Upgrading to v1.50 moves, copies and rewrites nothing.** Anyone reinstalling an older KST4Contest release finds their configuration and their worked stations unchanged.
### Shared or own worked stations
When a profile is created you decide where its worked, NOT-QRV and grid data come from:
- **Own worked stations** (default): the profile gets its own, initially empty database. This is what two operators with different callsigns sharing a private computer want.
- **Common station database**: the profile uses `~/.praktiKST/praktiKST.db`, the same database as the **Default** profile. This is the **multi operator station** case: there is only one station log, so the worked state should be the same for every operator.
Switching between the two settings moves no data. Worked data already collected stays where it was created. The three day expiry and the reset button always act on the database of the currently active profile.
### Managing profiles
The **Profiles** tab in the settings window lists all profiles with their name, the kind of worked data they use and when they were last used.
- **New profile...** creates a profile. It starts **without callsign and password**; both are entered afterwards on the **Station** tab.
- **Duplicate...** copies the complete configuration of the selected profile - antenna, locator, layout, beacon, integrations - **except callsign and password**. Worked stations are never copied.
- **Rename...** changes the displayed name only. Folders and files are untouched.
- **Delete...** removes the profile folder permanently. The **Default** profile and the currently active profile cannot be deleted. For a profile using the common station database, that database is left untouched.
- **Change worked stations...** switches between the common and an own database.
- **Switch to selected profile...** changes the profile while the application is running.
### Choosing a profile at startup
- As long as only **one** profile exists, KST4Contest asks **nothing** at startup and starts exactly as before. No profile registry is created either; only creating the second profile writes `~/.praktiKST/profiles.xml`.
- From **two** profiles on, a small selection appears at startup. The last used profile is preselected, and **Enter** or a double click start immediately.
- The `--profile=<name>` argument skips the selection and starts the named profile directly. Both the profile ID and the displayed name are accepted, case insensitively. An unknown name produces a note and then the normal selection - startup is never refused.
### Switching profiles while running
**File > Switch operator profile...**, or the button on the **Profiles** tab, switches without restarting the program. After a confirmation the ON4KST connection is closed and the user interface is rebuilt with the settings and layout of the selected profile. The layout of the previous profile is saved first; settings not yet confirmed with **Save Settings** are lost.
Once more than one profile exists, the main window title also shows the profile name.
### A note about passwords
As before, the ON4KST password is stored in clear text in the `preferences.xml` of each profile. On a shared computer, anyone with access to the user account can read the passwords of all profiles. Profiles separate configuration; they are **not** an access control mechanism.
---
## Dark Mode (from v1.26)
Enable Dark Mode through **Windows → Use dark mode design**. Use **Windows → Use default mode design** to restore the normal light colour scheme.
-18
View File
@@ -240,24 +240,6 @@ First check which frequencies were detected for the station during the previous
If no current station context exists, check **Fallback band for relative QRG detection**. The fallback is used only when the band cannot be determined from a complete frequency or the sender's current context.
### The Logger Runs in Its Own Sandbox
A logging programme started as a Flatpak, or through a Wine environment such as Bottles, uses the network permissions of that sandbox. If the sandbox does not share the host network, `127.0.0.1` inside it is not the `127.0.0.1` on which KST4Contest is listening, and the connection is refused even though KST4Contest reports the port correctly.
For a Flatpak logger, check its permission with:
```bash
flatpak info --show-permissions <application id>
```
The `[Context]` section has to contain `shared=network`. It can be granted with:
```bash
flatpak override --user --share=network <application id>
```
The same applies when KST4Contest itself runs as a Flatpak. Its published manifest already contains `--share=network`, so a listening port is reachable from the host and from other applications on the same computer.
### The Logger Hides the Spot
Try a spotter callsign which differs from the contest callsign. Depending on the logger, spots from the local callsign may be filtered or handled specially. KST4Contest itself does not require the two callsigns to differ.
@@ -0,0 +1,48 @@
package kst4contest.controller;
import kst4contest.model.OperatorProfileSelection;
/**
* Holds the operator profile the current runtime works with.
*
* <p>The state is deliberately static, which is safe here for reasons that did not apply
* to the former static database connection: the value is immutable, it holds no live
* resource, and it is set on the JavaFX Application Thread before anything reads it -
* during startup, and again during a profile switch after the previous runtime has been
* shut down completely.</p>
*/
public final class ActiveOperatorProfile {
private static volatile OperatorProfileSelection currentSelection;
private ActiveOperatorProfile() {
// Utility class.
}
/**
* Returns the active profile selection.
*
* @return the active selection, or null when startup has not resolved one yet
*/
public static OperatorProfileSelection get() {
return currentSelection;
}
/**
* Sets the active profile selection.
*
* @param selection selection to activate
*/
public static void set(final OperatorProfileSelection selection) {
currentSelection = selection;
}
/**
* Returns whether a profile has already been resolved for this runtime.
*
* @return true if a selection is present
*/
public static boolean isInitialized() {
return currentSelection != null;
}
}
@@ -1083,9 +1083,35 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
rotatorClient.stop();
rotatorClient = null;
}
releaseBackgroundExecutors();
}
}
/**
* Stops the background executors that live as long as this controller.
*
* <p>These are not bound to one ON4KST session, so disconnecting leaves them running
* on purpose. When the controller itself is discarded they have to go, otherwise a
* discarded controller stays reachable through its own threads.</p>
*/
private void releaseBackgroundExecutors() {
on4KstConnectionManager.shutdown();
skedReminderService.shutdown();
if (reachabilityService != null) {
reachabilityService.shutdown();
}
if (pendingRotatorRetry != null) {
pendingRotatorRetry.cancel(false);
pendingRotatorRetry = null;
}
rotatorCommandScheduler.shutdownNow();
}
private void cancelTimer(Timer timer) {
if (timer != null) {
timer.cancel();
@@ -2866,9 +2892,35 @@ private ObservableList<String>
* @param setOwnChatMemberObject
*/
public ChatController(ChatMember setOwnChatMemberObject,StatusUpdateListener listener) {
this(setOwnChatMemberObject,
listener,
ChatPreferences.PREFERENCES_FILE,
DBController.DATABASE_FILE,
true);
}
/**
* Creates a chat controller bound to the files of one operator profile.
*
* <p>Both file names are resolved below the application directory. This is the only
* place where the active operator profile enters the controller; everything below
* works on the resulting {@link ChatPreferences} and {@link DBController} instances
* without knowing about profiles at all.</p>
*
* @param setOwnChatMemberObject chat member object representing the local station
* @param listener callback for thread status updates
* @param preferencesRelativeFileName preferences file name relative to the application directory
* @param workedDatabaseRelativeFileName worked-station database file name relative to the application directory
* @param seedWorkedDatabaseFromResource true to seed a missing database from the bundled template
*/
public ChatController(ChatMember setOwnChatMemberObject,
StatusUpdateListener listener,
String preferencesRelativeFileName,
String workedDatabaseRelativeFileName,
boolean seedWorkedDatabaseFromResource) {
super();
chatPreferences = new ChatPreferences();
chatPreferences = new ChatPreferences(preferencesRelativeFileName);
chatPreferences.readPreferencesFromXmlFile();
// this.statusListener = listener;
lstNotify_QSOSniffer_sniffedCallSignList =
@@ -2930,7 +2982,7 @@ private ObservableList<String>
}
});
dbHandler = new DBController();
dbHandler = new DBController(workedDatabaseRelativeFileName, seedWorkedDatabaseFromResource);
reachabilityService = new ReachabilityService(this);
rebuildWorkedGrossFieldCacheFromDatabase();
@@ -6,9 +6,13 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember;
@@ -52,34 +56,128 @@ public class DBController {
*/
private static final long EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS = 60L * 1000L;
private static final DBController dbcontroller = new DBController();
private static Connection connection;
private static String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
/**
* Lazily created controller for the root installation database. It is created on
* first use only, because an eagerly created instance would open a database file
* before the application knows which operator profile is active.
*/
private static volatile DBController defaultInstance;
private Connection connection;
/**
* File name of this database relative to the application directory, for example
* "praktiKST.db" or "profiles/OP2/praktiKST.db".
*/
private final String databaseRelativeFileName;
/**
* Absolute path of the database file, resolved once during construction.
*/
private final String databaseFilePath;
/**
* True if a missing database file should be seeded from the shipped template.
*/
private final boolean seedFromResource;
/**
* Shutdown hook of this instance. It is remembered so it can be deregistered when
* the connection is closed. Without that, every operator profile switch would leave
* another hook behind that keeps a dead connection alive until the process ends.
*/
private Thread databaseShutdownHook;
/**
* Remembers the last timestamp at which the expiration cleanup had been executed.
*/
private long lastExpirationCleanupExecutionEpochMs = 0L;
/**
* Creates a controller for the worked-station database of the root installation.
*/
public DBController() {
initDBConnection();
}
public static DBController getInstance() {
return dbcontroller;
this(DATABASE_FILE, true);
}
/**
* Closes the database connection if it is still open.
* Creates a controller for the worked-station database of one operator profile.
*
* @param databaseRelativeFileName file name relative to the application directory
* @param seedFromResource true to copy the shipped template database when the file
* does not exist yet, false to create an empty database and
* let the schema creation build all required tables
*/
public DBController(final String databaseRelativeFileName, final boolean seedFromResource) {
this.databaseRelativeFileName =
Objects.requireNonNull(databaseRelativeFileName, "databaseRelativeFileName");
this.seedFromResource = seedFromResource;
this.databaseFilePath = ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
databaseRelativeFileName
);
initDBConnection();
}
/**
* Returns a controller for the root installation database, creating it on first use.
*
* @return the shared controller for the root installation database
*/
public static synchronized DBController getInstance() {
if (defaultInstance == null) {
defaultInstance = new DBController();
}
return defaultInstance;
}
/**
* Returns the absolute path of the database file this controller works on.
*
* @return absolute database file path
*/
public String getDatabaseFilePath() {
return databaseFilePath;
}
/**
* Closes the database connection if it is still open and deregisters the shutdown
* hook of this instance.
*/
public synchronized void closeDBConnection() {
closeConnectionQuietly();
if (databaseShutdownHook != null) {
try {
Runtime.getRuntime().removeShutdownHook(databaseShutdownHook);
} catch (IllegalStateException shutdownAlreadyInProgress) {
// Expected while the JVM is shutting down; the hook is running anyway.
}
databaseShutdownHook = null;
}
}
/**
* Closes the connection without touching the shutdown hook. This is also the body of
* the shutdown hook itself.
*/
private synchronized void closeConnectionQuietly() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
System.out.println("Connection to Database closed: " + databaseFilePath);
}
} catch (SQLException e) {
e.printStackTrace();
}
connection = null;
}
/**
@@ -91,22 +189,17 @@ public class DBController {
System.out.println("DBH: initiate new db connection");
try {
ApplicationFileUtils.copyResourceIfRequired(
ApplicationConstants.APPLICATION_NAME,
DATABASE_RESOURCE,
DATABASE_FILE
);
if (connection != null && !connection.isClosed()) {
return;
}
prepareDatabaseFile();
System.out.println("Creating Connection to Database...");
DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH);
connection = DriverManager.getConnection("jdbc:sqlite:" + databaseFilePath);
System.out.println("[DBH, Info]: Path = " + DB_PATH);
System.out.println("[DBH, Info]: Path = " + databaseFilePath);
if (!connection.isClosed()) {
System.out.println("...Connection established");
@@ -115,25 +208,50 @@ public class DBController {
throw new RuntimeException(e);
}
Runtime.getRuntime().addShutdownHook(new Thread() {
public void run() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
if (connection.isClosed()) {
System.out.println("Connection to Database closed");
}
}
} catch (SQLException e) {
e.printStackTrace();
}
}
});
databaseShutdownHook = new Thread(this::closeConnectionQuietly,
"DBController-shutdown-" + databaseRelativeFileName);
Runtime.getRuntime().addShutdownHook(databaseShutdownHook);
ensureChatMemberTableCompatibility();
}
/**
* Makes sure the database file can be opened.
*
* <p>The database of the root installation is seeded from the shipped template so
* existing installations keep their historic content. A database that belongs to an
* additional operator profile is created empty on purpose: the shipped template
* carries several thousand foreign callsigns and an outdated schema version, which
* would present a new operator with foreign data and trigger the full callsign
* normalization rebuild. The required tables are created by
* {@link #ensureChatMemberTableCompatibility()} in both cases.</p>
*/
private synchronized void prepareDatabaseFile() {
if (seedFromResource) {
ApplicationFileUtils.copyResourceIfRequired(
ApplicationConstants.APPLICATION_NAME,
DATABASE_RESOURCE,
databaseRelativeFileName
);
return;
}
Path parentDirectory = Path.of(databaseFilePath).getParent();
if (parentDirectory == null) {
return;
}
try {
Files.createDirectories(parentDirectory);
} catch (IOException e) {
throw new RuntimeException(
"[DBH, ERROR:] Could not create database directory " + parentDirectory, e);
}
}
/**
* Ensures that the ChatMember table exists, that all required columns are
* available for newer software versions, that existing old callsign keys are
@@ -877,9 +877,10 @@ public class MessageBusManagementThread extends Thread {
|| messageToProcess.getMessageText().isEmpty()) {
// No processable data.
} else {
if (On4KstProtocol.isInternalDxqResponse(
if (On4KstProtocol.isConnectionProbeResponse(
messageToProcess.getMessageText())) {
// Preserve the established internal handling of DXQ server data.
// DXQ is the internal response to the active connection probe.
// Liveness was already recorded by the session manager.
return;
}
@@ -2182,7 +2183,7 @@ public class MessageBusManagementThread extends Thread {
// e.printStackTrace();
// }
if (!On4KstProtocol.isInternalDxqResponse(
if (!On4KstProtocol.isConnectionProbeResponse(
messageTextRaw.getMessageText())) {
System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at
// Console#######################################################TODO:Wichtig
@@ -47,7 +47,9 @@ final class On4KstConnectionManager {
static final int CONNECT_TIMEOUT_MILLIS = 10_000; //TCP-Connect-Timeout
static final long LOGIN_FALLBACK_MILLIS = 2_000L; //Login-Fallback
static final long HANDSHAKE_TIMEOUT_MILLIS = 45_000L; //Handshake-Timeout
static final long CLIENT_LIVENESS_PROBE_AFTER_MILLIS = 90_000L;
static final long APPLICATION_HEARTBEAT_AFTER_MILLIS = 90_000L; //Application-Heartbeat
/** Idle duration after which the server is asked for current DX data. */
static final long CONNECTION_PROBE_AFTER_MILLIS = 180_000L; //Active connection probe
static final long INBOUND_STALE_AFTER_MILLIS = 210_000L; //Stale-Timeout - time without rxed data
static final List<Long> RECONNECT_DELAYS_MILLIS =
List.of(2_000L, 5_000L, 10_000L, 20_000L, 30_000L); //Reconnect-Backoff if no connection possible
@@ -137,6 +139,20 @@ final class On4KstConnectionManager {
scheduler.execute(() -> openConnection(token));
}
/**
* Stops the supervisor thread of this manager for good.
*
* <p>{@link #stopByUser()} only ends the current ON4KST session; the periodic
* session monitor keeps running. That is correct while the application lives, but a
* manager belonging to a discarded runtime must release its thread, otherwise every
* operator profile switch would leave another supervisor behind holding the dead
* controller.</p>
*/
void shutdown() {
scheduler.shutdownNow();
LOGGER.fine("ON4KST connection supervisor shut down");
}
/**
* Stops the current session and invalidates every scheduled callback or reconnect
* belonging to it.
@@ -179,10 +195,10 @@ final class On4KstConnectionManager {
session.lastProgressMillis.set(now);
String opcode = On4KstProtocol.opcode(line);
long probeResponseMillis = session.clientLivenessProbe.acknowledge(now);
long probeResponseMillis = session.connectionProbe.acknowledge(now);
if (probeResponseMillis >= 0L) {
LOGGER.log(Level.INFO,
"ON4KST client liveness probe confirmed: session {0}, "
"ON4KST connection probe confirmed: session {0}, "
+ "received opcode {1}, response time {2} ms",
new Object[] {
sessionId,
@@ -191,8 +207,8 @@ final class On4KstConnectionManager {
});
}
if (On4KstProtocol.isServerLivenessProbe(line)) {
sendServerLivenessProbeResponse(session);
if ("CK".equals(opcode)) {
sendHeartbeat(session);
}
if (!session.loginSent
@@ -263,7 +279,8 @@ final class On4KstConnectionManager {
token,
socket,
receiveQueue,
transmitQueue);
transmitQueue,
mainCategory);
ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession,
@@ -538,35 +555,42 @@ final class On4KstConnectionManager {
session.transmitQueue.offer(message);
}
private void sendServerLivenessProbeResponse(Session session) {
private void sendHeartbeat(Session session) {
if (session == null || !isActiveSession(session.id)) {
return;
}
long now = System.currentTimeMillis();
session.lastHeartbeatMillis.set(now);
LOGGER.log(Level.FINE,
"Responding to ON4KST server liveness probe: session {0}, "
+ "opcode CK",
"Sending application heartbeat for ON4KST session {0}",
session.id);
sendControl(session, On4KstProtocol.serverLivenessProbeResponse());
ChatMessage heartbeat = new ChatMessage();
heartbeat.setMessageDirectedToServer(true);
heartbeat.setMessageText("");
session.transmitQueue.offer(heartbeat);
}
private void sendClientLivenessProbe(
private void sendConnectionProbe(
Session session,
long now,
long inboundIdle
) {
if (session == null || !isActiveSession(session.id)
|| !session.clientLivenessProbe.tryStart(now)) {
|| !session.connectionProbe.tryStart(now)) {
return;
}
LOGGER.log(Level.INFO,
"Sending ON4KST client liveness probe: session {0}, "
+ "opcode CK, inbound idle {1} seconds",
"Sending ON4KST connection probe: session {0}, main category "
+ "{1}, inbound idle {2} seconds",
new Object[] {
session.id,
session.mainCategory,
inboundIdle / 1_000L
});
sendControl(session, On4KstProtocol.clientLivenessProbe());
sendControl(
session,
On4KstProtocol.connectionProbe(session.mainCategory));
}
private void onConnectionFailure(long sessionId, Throwable failure) {
@@ -672,8 +696,8 @@ final class On4KstConnectionManager {
long inboundIdle = now - lastInboundMillis;
IdleAction idleAction = determineIdleAction(
inboundIdle,
session.online,
session.clientLivenessProbe.isOutstanding());
session.lastHeartbeatMillis.get() >= lastInboundMillis,
session.connectionProbe.isOutstanding());
if (session.lastInboundMillis.get() != lastInboundMillis) {
return;
@@ -685,15 +709,16 @@ final class On4KstConnectionManager {
return;
}
long probeWaitMillis =
session.clientLivenessProbe.responseWaitMillis(now);
session.connectionProbe.responseWaitMillis(now);
if (probeWaitMillis >= 0L) {
LOGGER.log(Level.WARNING,
"ON4KST client liveness probe timed out: "
+ "session {0}, opcode CK, no response "
+ "for {1} ms, inbound idle {2} seconds; "
"ON4KST connection probe timed out: session "
+ "{0}, main category {1}, no response "
+ "for {2} ms, inbound idle {3} seconds; "
+ "reconnecting",
new Object[] {
session.id,
session.mainCategory,
probeWaitMillis,
inboundIdle / 1_000L
});
@@ -702,8 +727,9 @@ final class On4KstConnectionManager {
new SocketException("No ON4KST data received for "
+ inboundIdle / 1_000L + " seconds"));
}
case CLIENT_LIVENESS_PROBE ->
sendClientLivenessProbe(session, now, inboundIdle);
case CONNECTION_PROBE ->
sendConnectionProbe(session, now, inboundIdle);
case HEARTBEAT -> sendHeartbeat(session);
case NONE -> {
// The session is active or already has the required idle action.
}
@@ -719,18 +745,19 @@ final class On4KstConnectionManager {
*/
static IdleAction determineIdleAction(
long inboundIdleMillis,
boolean online,
boolean heartbeatSentForIdlePhase,
boolean probeOutstanding
) {
if (!online) {
return IdleAction.NONE;
}
if (inboundIdleMillis > INBOUND_STALE_AFTER_MILLIS) {
return IdleAction.TIMEOUT;
}
if (inboundIdleMillis > CLIENT_LIVENESS_PROBE_AFTER_MILLIS
if (inboundIdleMillis >= CONNECTION_PROBE_AFTER_MILLIS
&& !probeOutstanding) {
return IdleAction.CLIENT_LIVENESS_PROBE;
return IdleAction.CONNECTION_PROBE;
}
if (inboundIdleMillis > APPLICATION_HEARTBEAT_AFTER_MILLIS
&& !heartbeatSentForIdlePhase) {
return IdleAction.HEARTBEAT;
}
return IdleAction.NONE;
}
@@ -881,13 +908,15 @@ final class On4KstConnectionManager {
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final int mainCategory;
private final long connectedMillis = System.currentTimeMillis();
private final AtomicLong lastInboundMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastProgressMillis =
new AtomicLong(connectedMillis);
private final ClientLivenessProbeState clientLivenessProbe =
new ClientLivenessProbeState();
private final AtomicLong lastHeartbeatMillis = new AtomicLong();
private final ConnectionProbeState connectionProbe =
new ConnectionProbeState();
private final Map<Integer, Map<String, ChatMember>> initialMembers =
new ConcurrentHashMap<>();
@@ -905,24 +934,27 @@ final class On4KstConnectionManager {
long id,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> transmitQueue
LinkedBlockingQueue<ChatMessage> transmitQueue,
int mainCategory
) {
this.id = id;
this.socket = socket;
this.receiveQueue = receiveQueue;
this.transmitQueue = transmitQueue;
this.mainCategory = mainCategory;
}
}
/** Maintenance action selected by the session monitor. */
enum IdleAction {
NONE,
CLIENT_LIVENESS_PROBE,
HEARTBEAT,
CONNECTION_PROBE,
TIMEOUT
}
/** Tracks one client-initiated liveness probe for the complete TCP session. */
static final class ClientLivenessProbeState {
/** Tracks one outstanding liveness probe for the complete TCP session. */
static final class ConnectionProbeState {
private final AtomicLong sentMillis = new AtomicLong();
boolean tryStart(long now) {
@@ -54,32 +54,13 @@ final class On4KstProtocol {
+ "|0|";
}
/** Builds the session-wide liveness probe initiated by this client. */
static String clientLivenessProbe() {
return "CK|";
/** Builds the active liveness probe for the session's main chat. */
static String connectionProbe(int category) {
return "RDXQ|" + category(category) + "|";
}
/** Returns whether a server frame acknowledges a client-initiated probe. */
static boolean isClientLivenessProbeResponse(String frame) {
if (frame == null) {
return false;
}
String normalized = frame.trim().toUpperCase(Locale.ROOT);
return "OK".equals(normalized) || "OK|".equals(normalized);
}
/** Returns whether ON4KST initiated its own liveness check. */
static boolean isServerLivenessProbe(String frame) {
return "CK".equals(opcode(frame));
}
/** Builds the established empty response to a server-initiated {@code CK}. */
static String serverLivenessProbeResponse() {
return "";
}
/** Preserves the existing internal handling of DXQ server data. */
static boolean isInternalDxqResponse(String frame) {
/** Returns whether a server frame is the expected liveness-probe response. */
static boolean isConnectionProbeResponse(String frame) {
return "DXQ".equals(opcode(frame));
}
@@ -0,0 +1,312 @@
package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatPreferences;
import kst4contest.model.OperatorProfile;
import kst4contest.utils.ApplicationFileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
/**
* Creates, renames, duplicates and removes operator profiles.
*
* <p>Kept free of user interface code so the behaviour can be tested without a JavaFX
* runtime. All methods work on the registry and on the files below the application
* directory.</p>
*/
public class OperatorProfileManagementService {
private static final Logger LOGGER =
Logger.getLogger(OperatorProfileManagementService.class.getName());
private final OperatorProfileStore profileStore;
public OperatorProfileManagementService() {
this(new OperatorProfileStore());
}
public OperatorProfileManagementService(final OperatorProfileStore profileStore) {
this.profileStore = profileStore;
}
/**
* Lists all profiles, including the implicit root profile of a plain installation.
*
* @return the known profiles, never empty
*/
public List<OperatorProfile> listProfiles() {
List<OperatorProfile> knownProfiles = profileStore.loadProfiles();
if (knownProfiles.isEmpty()) {
knownProfiles = new ArrayList<>();
knownProfiles.add(profileStore.buildImplicitRootProfile());
}
return knownProfiles;
}
/**
* Creates a new operator profile with its own preferences file.
*
* <p>Creating the first additional profile is also the moment the registry appears:
* the root profile is written alongside, so both are selectable afterwards.</p>
*
* @param displayName name entered by the operator
* @param sharedWorkedDatabase true to use the common station worked database
* @return the created profile, or null when it could not be stored
*/
public OperatorProfile createProfile(final String displayName, final boolean sharedWorkedDatabase) {
List<OperatorProfile> knownProfiles = listProfiles();
Set<String> takenProfileIds = new LinkedHashSet<>();
for (OperatorProfile existingProfile : knownProfiles) {
takenProfileIds.add(existingProfile.getProfileId());
}
OperatorProfile createdProfile = new OperatorProfile(
OperatorProfilePaths.toProfileId(displayName, takenProfileIds),
displayName == null || displayName.isBlank() ? "New profile" : displayName.trim(),
false,
sharedWorkedDatabase);
knownProfiles.add(createdProfile);
if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) {
return null;
}
createPreferencesFile(createdProfile, null);
return createdProfile;
}
/**
* Creates a copy of an existing profile.
*
* <p>The preferences are taken over completely except for the login credentials:
* callsign and password are cleared on purpose, because a duplicate is meant for
* another operator. Antenna, locator, layout and integration settings are exactly
* what the operator does not want to enter twice.</p>
*
* <p>The worked-station database is never copied.</p>
*
* @param sourceProfile profile to copy
* @param displayName name of the new profile
* @return the created profile, or null when it could not be stored
*/
public OperatorProfile duplicateProfile(final OperatorProfile sourceProfile, final String displayName) {
if (sourceProfile == null) {
return null;
}
List<OperatorProfile> knownProfiles = listProfiles();
Set<String> takenProfileIds = new LinkedHashSet<>();
for (OperatorProfile existingProfile : knownProfiles) {
takenProfileIds.add(existingProfile.getProfileId());
}
OperatorProfile createdProfile = new OperatorProfile(
OperatorProfilePaths.toProfileId(displayName, takenProfileIds),
displayName == null || displayName.isBlank() ? "Copy" : displayName.trim(),
false,
sourceProfile.isSharedWorkedDatabase());
knownProfiles.add(createdProfile);
if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) {
return null;
}
createPreferencesFile(createdProfile, sourceProfile);
return createdProfile;
}
/**
* Changes the visible name of a profile. The identifier and all paths stay as they are.
*
* @param profile profile to rename
* @param newDisplayName new name
* @return true if the registry was updated
*/
public boolean renameProfile(final OperatorProfile profile, final String newDisplayName) {
if (profile == null || newDisplayName == null || newDisplayName.isBlank()) {
return false;
}
List<OperatorProfile> knownProfiles = listProfiles();
for (OperatorProfile currentProfile : knownProfiles) {
if (currentProfile.getProfileId().equals(profile.getProfileId())) {
currentProfile.setDisplayName(newDisplayName.trim());
}
}
return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null));
}
/**
* Switches a profile between the common station database and its own one.
*
* @param profile profile to change
* @param sharedWorkedDatabase true to use the common station worked database
* @return true if the registry was updated
*/
public boolean setSharedWorkedDatabase(final OperatorProfile profile, final boolean sharedWorkedDatabase) {
if (profile == null || profile.isRootProfile()) {
return false;
}
List<OperatorProfile> knownProfiles = listProfiles();
for (OperatorProfile currentProfile : knownProfiles) {
if (currentProfile.getProfileId().equals(profile.getProfileId())) {
currentProfile.setSharedWorkedDatabase(sharedWorkedDatabase);
}
}
return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null));
}
/**
* Removes a profile and its directory.
*
* <p>The root profile can never be removed, because its files are the installation
* itself. A profile using the common station database keeps that database untouched;
* only its own directory is deleted.</p>
*
* @param profile profile to remove
* @return true if the profile was removed
*/
public boolean deleteProfile(final OperatorProfile profile) {
if (profile == null || profile.isRootProfile()) {
return false;
}
List<OperatorProfile> remainingProfiles = new ArrayList<>();
for (OperatorProfile currentProfile : listProfiles()) {
if (!currentProfile.getProfileId().equals(profile.getProfileId())) {
remainingProfiles.add(currentProfile);
}
}
if (!profileStore.saveProfiles(remainingProfiles,
profileStore.loadLastUsedProfileId().orElse(null))) {
return false;
}
deleteProfileDirectory(profile);
return true;
}
/**
* Returns the absolute directory of a profile.
*
* @param profile profile to resolve
* @return absolute profile directory
*/
public String getProfileDirectory(final OperatorProfile profile) {
return ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
OperatorProfilePaths.profileRelativeDirectory(profile));
}
/**
* Creates the preferences file of a new profile.
*
* <p>The file is either seeded from the bundled template or copied from the source
* profile. In both cases the login credentials are cleared, so a new profile never
* carries another operator's callsign or password.</p>
*
* @param createdProfile profile that needs a preferences file
* @param sourceProfile profile to copy the preferences from, or null for the template
*/
private void createPreferencesFile(final OperatorProfile createdProfile,
final OperatorProfile sourceProfile) {
String createdRelativeFileName = OperatorProfilePaths.preferencesRelativeFileName(createdProfile);
if (sourceProfile != null) {
copyPreferencesFile(
OperatorProfilePaths.preferencesRelativeFileName(sourceProfile),
createdRelativeFileName);
}
// Seeds from the bundled template when nothing was copied, and always resolves
// the preferences of the new profile.
ChatPreferences createdPreferences = new ChatPreferences(createdRelativeFileName);
createdPreferences.readPreferencesFromXmlFile();
createdPreferences.setStn_loginCallSign("");
createdPreferences.setStn_loginPassword("");
createdPreferences.writePreferencesToXmlFile();
}
private void copyPreferencesFile(final String sourceRelativeFileName,
final String targetRelativeFileName) {
Path sourcePath = Path.of(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, sourceRelativeFileName));
Path targetPath = Path.of(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, targetRelativeFileName));
if (!Files.isRegularFile(sourcePath)) {
return;
}
try {
Path targetDirectory = targetPath.getParent();
if (targetDirectory != null) {
Files.createDirectories(targetDirectory);
}
Files.copy(sourcePath, targetPath);
} catch (IOException e) {
LOGGER.log(Level.WARNING,
"Could not copy the preferences of the source profile, using the defaults instead", e);
}
}
private void deleteProfileDirectory(final OperatorProfile profile) {
Path profileDirectory = Path.of(getProfileDirectory(profile));
if (!Files.isDirectory(profileDirectory)) {
return;
}
try (Stream<Path> containedPaths = Files.walk(profileDirectory)) {
List<Path> deepestFirst = containedPaths
.sorted(Comparator.reverseOrder())
.toList();
for (Path currentPath : deepestFirst) {
Files.deleteIfExists(currentPath);
}
} catch (IOException e) {
LOGGER.log(Level.WARNING,
"Could not remove the directory of the deleted operator profile", e);
}
}
}
@@ -0,0 +1,199 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import java.util.Collection;
import java.util.Locale;
/**
* Derives the file names of an operator profile.
*
* <p>This is the single place that knows how a profile maps onto files. The registry
* stores only the shared/own flag, never a path, so the two can never drift apart.</p>
*/
public final class OperatorProfilePaths {
/**
* Directory below the application directory that holds the additional profiles.
*/
public static final String PROFILES_DIRECTORY = "profiles";
/**
* Identifier of the profile that uses the historic flat installation layout.
*/
public static final String ROOT_PROFILE_ID = "default";
/**
* Maximum length of a generated profile identifier.
*/
private static final int MAX_PROFILE_ID_LENGTH = 32;
private OperatorProfilePaths() {
// Utility class.
}
/**
* Builds the profile descriptor of the historic flat installation.
*
* @param displayName name to show for the root profile
* @return the root profile descriptor
*/
public static OperatorProfile buildRootProfile(final String displayName) {
return new OperatorProfile(ROOT_PROFILE_ID, displayName, true, true);
}
/**
* Returns the profile directory relative to the application directory.
*
* @param profile profile to resolve
* @return relative directory name
*/
public static String profileRelativeDirectory(final OperatorProfile profile) {
return PROFILES_DIRECTORY + "/" + profile.getProfileId();
}
/**
* Returns the preferences file name relative to the application directory.
*
* @param profile profile to resolve
* @return relative preferences file name
*/
public static String preferencesRelativeFileName(final OperatorProfile profile) {
if (profile.isRootProfile()) {
return ChatPreferences.PREFERENCES_FILE;
}
return profileRelativeDirectory(profile) + "/" + ChatPreferences.PREFERENCES_FILE;
}
/**
* Returns the worked-station database file name relative to the application directory.
*
* <p>A profile using the common station database always resolves to the historic flat
* file, which is what a multi operator station wants: the existing contest state stays
* the shared one.</p>
*
* @param profile profile to resolve
* @return relative database file name
*/
public static String workedDatabaseRelativeFileName(final OperatorProfile profile) {
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
return DBController.DATABASE_FILE;
}
return profileRelativeDirectory(profile) + "/" + DBController.DATABASE_FILE;
}
/**
* Resolves a profile descriptor into the runtime selection used during startup.
*
* @param profile profile to resolve
* @return resolved selection
*/
public static OperatorProfileSelection resolve(final OperatorProfile profile) {
boolean usesSharedStationDatabase = profile.isRootProfile() || profile.isSharedWorkedDatabase();
return new OperatorProfileSelection(
profile,
preferencesRelativeFileName(profile),
workedDatabaseRelativeFileName(profile),
usesSharedStationDatabase
);
}
/**
* Derives a stable, file system safe identifier from a display name.
*
* <p>The identifier becomes a directory name and is never changed afterwards, so a
* later rename of the profile does not move any file.</p>
*
* @param displayName name entered by the operator
* @param takenProfileIds identifiers that are already in use
* @return an identifier that is not yet taken
*/
public static String toProfileId(final String displayName, final Collection<String> takenProfileIds) {
StringBuilder sanitized = new StringBuilder();
if (displayName != null) {
String foldedDisplayName = foldGermanUmlauts(displayName.toUpperCase(Locale.ROOT));
for (char currentCharacter : foldedDisplayName.toCharArray()) {
boolean isAcceptable = (currentCharacter >= 'A' && currentCharacter <= 'Z')
|| (currentCharacter >= '0' && currentCharacter <= '9')
|| currentCharacter == '-';
if (isAcceptable) {
sanitized.append(currentCharacter);
} else if (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) != '_') {
sanitized.append('_');
}
}
}
while (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) == '_') {
sanitized.setLength(sanitized.length() - 1);
}
if (sanitized.length() > MAX_PROFILE_ID_LENGTH) {
sanitized.setLength(MAX_PROFILE_ID_LENGTH);
}
String candidate = sanitized.toString();
if (candidate.isEmpty() || ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
candidate = "OP";
}
if (!isProfileIdTaken(candidate, takenProfileIds)) {
return candidate;
}
int suffix = 2;
while (isProfileIdTaken(candidate + "_" + suffix, takenProfileIds)) {
suffix++;
}
return candidate + "_" + suffix;
}
/**
* Folds German umlauts so a name like "Muller" written with an umlaut still produces a
* readable identifier instead of a placeholder character.
*
* @param upperCaseText already upper-cased text
* @return text with umlauts replaced by their base letters
*/
private static String foldGermanUmlauts(final String upperCaseText) {
return upperCaseText
.replace("\u00C4", "A")
.replace("\u00D6", "O")
.replace("\u00DC", "U")
.replace("\u00DF", "SS");
}
private static boolean isProfileIdTaken(final String candidate, final Collection<String> takenProfileIds) {
if (ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
return true;
}
if (takenProfileIds == null) {
return false;
}
for (String takenProfileId : takenProfileIds) {
if (candidate.equalsIgnoreCase(takenProfileId)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,386 @@
package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.OperatorProfile;
import kst4contest.utils.ApplicationFileUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.File;
import java.io.OutputStream;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Reads and writes the operator profile registry.
*
* <p>The registry file is created lazily. As long as an installation has only the
* historic flat layout, no registry exists and none is written, so a single operator
* installation behaves exactly as before. The file appears when the second profile is
* created; at that moment the root profile is materialised as well.</p>
*
* <p>A missing, unreadable or malformed registry is never fatal. It is logged and
* treated like an installation without additional profiles.</p>
*/
public class OperatorProfileStore {
private static final Logger LOGGER = Logger.getLogger(OperatorProfileStore.class.getName());
/**
* Name of the registry file inside the application directory.
*/
public static final String PROFILES_REGISTRY_FILE = "profiles.xml";
private static final String TAG_ROOT = "praktiKSTProfiles";
private static final String TAG_REGISTRY_VERSION = "registryVersion";
private static final String TAG_LAST_USED_PROFILE_ID = "lastUsedProfileId";
private static final String TAG_PROFILE = "profile";
private static final String TAG_PROFILE_ID = "profileId";
private static final String TAG_DISPLAY_NAME = "displayName";
private static final String TAG_ROOT_PROFILE = "rootProfile";
private static final String TAG_SHARED_WORKED_DATABASE = "sharedWorkedDatabase";
private static final String TAG_LAST_USED_EPOCH_MS = "lastUsedEpochMs";
private static final int REGISTRY_VERSION = 1;
private final String registryFilePath;
/**
* Creates a store working on the registry of the current installation.
*/
public OperatorProfileStore() {
this(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, PROFILES_REGISTRY_FILE));
}
/**
* Creates a store working on an explicit registry file.
*
* @param registryFilePath absolute path of the registry file
*/
public OperatorProfileStore(final String registryFilePath) {
this.registryFilePath = registryFilePath;
}
/**
* Returns whether a registry file exists at all.
*
* @return true if the installation already has more than the historic flat layout
*/
public boolean isRegistryPresent() {
return new File(registryFilePath).isFile();
}
/**
* Builds the in-memory descriptor of the historic flat installation.
*
* <p>Nothing is written. This keeps a single operator installation untouched.</p>
*
* @return the implicit root profile
*/
public OperatorProfile buildImplicitRootProfile() {
return OperatorProfilePaths.buildRootProfile("Default");
}
/**
* Reads all stored profiles.
*
* @return the stored profiles, or an empty list when no usable registry exists
*/
public List<OperatorProfile> loadProfiles() {
List<OperatorProfile> loadedProfiles = new ArrayList<>();
Document document = readRegistryDocument();
if (document == null) {
return loadedProfiles;
}
NodeList profileNodes = document.getElementsByTagName(TAG_PROFILE);
for (int profileIndex = 0; profileIndex < profileNodes.getLength(); profileIndex++) {
Node currentNode = profileNodes.item(profileIndex);
if (currentNode.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
Element profileElement = (Element) currentNode;
String profileId = readText(profileElement, TAG_PROFILE_ID);
if (profileId == null || profileId.isBlank()) {
LOGGER.log(Level.WARNING, "Skipping operator profile entry without an identifier");
continue;
}
OperatorProfile loadedProfile = new OperatorProfile();
loadedProfile.setProfileId(profileId.trim());
loadedProfile.setDisplayName(readText(profileElement, TAG_DISPLAY_NAME));
loadedProfile.setRootProfile(readBoolean(profileElement, TAG_ROOT_PROFILE, false));
loadedProfile.setSharedWorkedDatabase(
readBoolean(profileElement, TAG_SHARED_WORKED_DATABASE, true));
loadedProfile.setLastUsedEpochMs(readLong(profileElement, TAG_LAST_USED_EPOCH_MS));
if (loadedProfile.getDisplayName() == null || loadedProfile.getDisplayName().isBlank()) {
loadedProfile.setDisplayName(loadedProfile.getProfileId());
}
// The root profile always uses the common station database, because its
// database is the historic flat file itself.
if (loadedProfile.isRootProfile()) {
loadedProfile.setSharedWorkedDatabase(true);
}
loadedProfiles.add(loadedProfile);
}
return loadedProfiles;
}
/**
* Reads the identifier of the profile that was activated last.
*
* @return the identifier, or empty when unknown
*/
public Optional<String> loadLastUsedProfileId() {
Document document = readRegistryDocument();
if (document == null) {
return Optional.empty();
}
Element rootElement = document.getDocumentElement();
if (rootElement == null) {
return Optional.empty();
}
String lastUsedProfileId = readText(rootElement, TAG_LAST_USED_PROFILE_ID);
if (lastUsedProfileId == null || lastUsedProfileId.isBlank()) {
return Optional.empty();
}
return Optional.of(lastUsedProfileId.trim());
}
/**
* Writes the complete registry.
*
* @param profiles profiles to store
* @param lastUsedProfileId identifier of the profile that was activated last, may be null
* @return true if the registry was written
*/
public boolean saveProfiles(final List<OperatorProfile> profiles, final String lastUsedProfileId) {
try {
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
Document document = documentBuilder.newDocument();
Element rootElement = document.createElement(TAG_ROOT);
document.appendChild(rootElement);
appendTextElement(document, rootElement, TAG_REGISTRY_VERSION, String.valueOf(REGISTRY_VERSION));
if (lastUsedProfileId != null && !lastUsedProfileId.isBlank()) {
appendTextElement(document, rootElement, TAG_LAST_USED_PROFILE_ID, lastUsedProfileId);
}
for (OperatorProfile currentProfile : profiles) {
Element profileElement = document.createElement(TAG_PROFILE);
rootElement.appendChild(profileElement);
appendTextElement(document, profileElement, TAG_PROFILE_ID, currentProfile.getProfileId());
appendTextElement(document, profileElement, TAG_DISPLAY_NAME, currentProfile.getDisplayName());
appendTextElement(document, profileElement, TAG_ROOT_PROFILE,
String.valueOf(currentProfile.isRootProfile()));
appendTextElement(document, profileElement, TAG_SHARED_WORKED_DATABASE,
String.valueOf(currentProfile.isRootProfile() || currentProfile.isSharedWorkedDatabase()));
appendTextElement(document, profileElement, TAG_LAST_USED_EPOCH_MS,
String.valueOf(currentProfile.getLastUsedEpochMs()));
}
return writeDocumentAtomically(document);
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Could not write the operator profile registry", e);
return false;
}
}
/**
* Records that a profile has been activated.
*
* <p>Does nothing when no registry exists, so a single operator installation is not
* turned into a multi profile installation by merely starting the application.</p>
*
* @param profileId identifier of the activated profile
* @return true if the registry was updated
*/
public boolean recordLastUsed(final String profileId) {
if (!isRegistryPresent()) {
return false;
}
List<OperatorProfile> storedProfiles = loadProfiles();
if (storedProfiles.isEmpty()) {
return false;
}
for (OperatorProfile currentProfile : storedProfiles) {
if (currentProfile.getProfileId().equalsIgnoreCase(profileId)) {
currentProfile.setLastUsedEpochMs(System.currentTimeMillis());
}
}
return saveProfiles(storedProfiles, profileId);
}
/**
* Returns the absolute path of the registry file.
*
* @return absolute registry path
*/
public String getRegistryFilePath() {
return registryFilePath;
}
private Document readRegistryDocument() {
File registryFile = new File(registryFilePath);
if (!registryFile.isFile()) {
return null;
}
try {
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
return documentBuilderFactory.newDocumentBuilder().parse(registryFile);
} catch (Exception e) {
LOGGER.log(Level.WARNING,
"Could not read the operator profile registry, continuing without additional profiles", e);
return null;
}
}
private static String readText(final Element parentElement, final String tagName) {
NodeList matchingNodes = parentElement.getElementsByTagName(tagName);
if (matchingNodes.getLength() == 0) {
return null;
}
return matchingNodes.item(0).getTextContent();
}
private static boolean readBoolean(final Element parentElement,
final String tagName,
final boolean defaultValue) {
String rawValue = readText(parentElement, tagName);
if (rawValue == null || rawValue.isBlank()) {
return defaultValue;
}
return Boolean.parseBoolean(rawValue.trim());
}
private static long readLong(final Element parentElement, final String tagName) {
String rawValue = readText(parentElement, tagName);
if (rawValue == null || rawValue.isBlank()) {
return 0L;
}
try {
return Long.parseLong(rawValue.trim());
} catch (NumberFormatException e) {
return 0L;
}
}
private static void appendTextElement(final Document document,
final Element parentElement,
final String tagName,
final String textContent) {
Element createdElement = document.createElement(tagName);
createdElement.setTextContent(textContent == null ? "" : textContent);
parentElement.appendChild(createdElement);
}
/**
* Writes the registry through a temporary file so a crash can never leave a
* half-written registry behind. This mirrors the established preferences writer.
*
* @param document document to write
* @return true if the registry file was replaced
*/
private boolean writeDocumentAtomically(final Document document) {
Path targetPath = Path.of(registryFilePath).toAbsolutePath();
Path parentDirectory = targetPath.getParent();
if (parentDirectory == null) {
LOGGER.log(Level.SEVERE,
"The operator profile registry path has no directory: {0}", registryFilePath);
return false;
}
try {
Files.createDirectories(parentDirectory);
// The temporary file has to live next to the target so the final move can be
// atomic; both must be on the same file system.
Path temporaryPath = Files.createTempFile(
parentDirectory, PROFILES_REGISTRY_FILE, ".tmp");
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
try (OutputStream outputStream = Files.newOutputStream(temporaryPath)) {
transformer.transform(new DOMSource(document), new StreamResult(outputStream));
}
try {
Files.move(temporaryPath, targetPath,
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException atomicMoveUnsupported) {
Files.move(temporaryPath, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
return true;
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Could not store the operator profile registry", e);
return false;
}
}
}
@@ -86,20 +86,11 @@ public class ReadThread extends Thread {
throw new EOFException("ON4KST closed the TCP connection");
}
if (!sessionIsActive.test(sessionId)) {
break;
}
inboundActivity.accept(response);
if (!sessionIsActive.test(sessionId)) {
break;
}
if (On4KstProtocol.isClientLivenessProbeResponse(response)) {
// OK and OK| acknowledge the client-side CK| probe.
continue;
}
ChatMessage message = new ChatMessage();
message.setMessageText(response);
receiveQueue.put(message);
@@ -33,6 +33,24 @@ public final class SkedReminderService {
this.controller = controller;
}
/**
* Cancels every armed reminder and stops the scheduler thread.
*
* <p>Called when the runtime that owns this service is torn down, so a discarded
* runtime does not keep a thread and pending reminders alive.</p>
*/
public void shutdown() {
for (List<ScheduledFuture<?>> remindersOfOneCall : scheduledByCallRaw.values()) {
for (ScheduledFuture<?> armedReminder : remindersOfOneCall) {
armedReminder.cancel(false);
}
}
scheduledByCallRaw.clear();
scheduler.shutdownNow();
}
/**
* Arms reminders for one sked. Existing reminders for this call are cancelled.
*
@@ -79,7 +79,26 @@ public class ChatPreferences {
* TODO: delete this from the kst4contest.view/Main.java!
*/
public ChatPreferences() {
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, PREFERENCES_FILE);
this(PREFERENCES_FILE);
}
/**
* Creates preferences bound to one operator profile.
*
* <p>The file name is resolved below the application directory, so both
* "preferences.xml" for the root installation and "profiles/OP2/preferences.xml"
* for an additional operator profile are valid. A missing file is seeded from the
* bundled template, which gives a new profile the same clean defaults a first-ever
* installation gets.</p>
*
* @param applicationRelativeFileName preferences file name relative to the application directory
*/
public ChatPreferences(final String applicationRelativeFileName) {
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, applicationRelativeFileName);
this.storeAndRestorePreferencesFileName = ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
applicationRelativeFileName
);
// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB");
@@ -168,8 +187,14 @@ public class ChatPreferences {
int stn_pstRotatorPort = 12000;
boolean stn_loginAFKState = false; //always start as here
String stn_loginCallSign = "do5amf";
String stn_loginCallSignRaw = "do5amf"; //for example: do5amf instead of logincallsign do5amf-2
/*
* The login credentials default to empty on purpose. A missing or empty value in
* preferences.xml means "not configured yet", and falling back to a real callsign
* would let an operator transmit under someone else's call. This matters for every
* additional operator profile, whose preferences are created without credentials.
*/
String stn_loginCallSign = "";
String stn_loginCallSignRaw = ""; //for example: do5amf instead of logincallsign do5amf-2
String stn_loginPassword = "";
String stn_loginNameMainCat = "KST4Contest";
String stn_loginNameSecondCat = "KST4ContestSHF";
@@ -0,0 +1,133 @@
package kst4contest.model;
import java.util.Objects;
/**
* Descriptor of one operator profile.
*
* <p>A profile always owns its own preferences file. Whether it also owns its own
* worked-station database is decided by {@link #isSharedWorkedDatabase()}: a multi
* operator contest station keeps one common log and therefore shares the database,
* while two operators sharing a private computer usually want their worked data kept
* apart.</p>
*
* <p>The descriptor deliberately carries no file paths. They are derived in exactly one
* place, {@link kst4contest.controller.OperatorProfilePaths}, so a stored path can never
* drift apart from the flag that produced it.</p>
*/
public class OperatorProfile {
/**
* Stable identifier of the profile. It is assigned once and never changes, so
* renaming a profile never moves a directory.
*/
private String profileId;
/**
* Name shown in the profile picker and in the settings window.
*/
private String displayName;
/**
* True for the profile that uses the historic flat installation layout directly.
*/
private boolean rootProfile;
/**
* True if this profile uses the common station worked-station database.
*/
private boolean sharedWorkedDatabase;
/**
* Timestamp of the last activation, used to preselect an entry in the picker.
*/
private long lastUsedEpochMs;
public OperatorProfile() {
// Default constructor for stepwise construction while reading the registry.
}
public OperatorProfile(final String profileId,
final String displayName,
final boolean rootProfile,
final boolean sharedWorkedDatabase) {
this.profileId = profileId;
this.displayName = displayName;
this.rootProfile = rootProfile;
this.sharedWorkedDatabase = sharedWorkedDatabase;
}
public String getProfileId() {
return profileId;
}
public void setProfileId(final String profileId) {
this.profileId = profileId;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(final String displayName) {
this.displayName = displayName;
}
public boolean isRootProfile() {
return rootProfile;
}
public void setRootProfile(final boolean rootProfile) {
this.rootProfile = rootProfile;
}
public boolean isSharedWorkedDatabase() {
return sharedWorkedDatabase;
}
public void setSharedWorkedDatabase(final boolean sharedWorkedDatabase) {
this.sharedWorkedDatabase = sharedWorkedDatabase;
}
public long getLastUsedEpochMs() {
return lastUsedEpochMs;
}
public void setLastUsedEpochMs(final long lastUsedEpochMs) {
this.lastUsedEpochMs = lastUsedEpochMs;
}
@Override
public boolean equals(final Object other) {
if (this == other) {
return true;
}
if (!(other instanceof OperatorProfile)) {
return false;
}
return Objects.equals(profileId, ((OperatorProfile) other).profileId);
}
@Override
public int hashCode() {
return Objects.hashCode(profileId);
}
/**
* Returns the display name so the descriptor can be shown in a list control directly.
*
* @return the display name, or the profile id when no name was set
*/
@Override
public String toString() {
if (displayName == null || displayName.isBlank()) {
return String.valueOf(profileId);
}
return displayName;
}
}
@@ -0,0 +1,70 @@
package kst4contest.model;
import kst4contest.ApplicationConstants;
import kst4contest.utils.ApplicationFileUtils;
import java.util.Objects;
/**
* Resolved runtime view of the active operator profile.
*
* <p>This is the only profile information the rest of the application needs: two file
* names relative to the application directory plus the flag whether a missing
* worked-station database may be seeded from the bundled template. Everything else is
* derived from the descriptor.</p>
*/
public class OperatorProfileSelection {
private final OperatorProfile profile;
private final String preferencesRelativeFileName;
private final String workedDatabaseRelativeFileName;
private final boolean seedWorkedDatabaseFromResource;
public OperatorProfileSelection(final OperatorProfile profile,
final String preferencesRelativeFileName,
final String workedDatabaseRelativeFileName,
final boolean seedWorkedDatabaseFromResource) {
this.profile = Objects.requireNonNull(profile, "profile");
this.preferencesRelativeFileName =
Objects.requireNonNull(preferencesRelativeFileName, "preferencesRelativeFileName");
this.workedDatabaseRelativeFileName =
Objects.requireNonNull(workedDatabaseRelativeFileName, "workedDatabaseRelativeFileName");
this.seedWorkedDatabaseFromResource = seedWorkedDatabaseFromResource;
}
public OperatorProfile getProfile() {
return profile;
}
public String getPreferencesRelativeFileName() {
return preferencesRelativeFileName;
}
public String getWorkedDatabaseRelativeFileName() {
return workedDatabaseRelativeFileName;
}
public boolean isSeedWorkedDatabaseFromResource() {
return seedWorkedDatabaseFromResource;
}
/**
* Returns the absolute preferences path, for display in the settings window.
*
* @return absolute path of the preferences file
*/
public String getPreferencesAbsolutePath() {
return ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, preferencesRelativeFileName);
}
/**
* Returns the absolute worked-station database path, for display in the settings window.
*
* @return absolute path of the worked-station database
*/
public String getWorkedDatabaseAbsolutePath() {
return ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, workedDatabaseRelativeFileName);
}
}
@@ -0,0 +1,106 @@
package kst4contest.view;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import javafx.application.Platform;
import javafx.stage.Stage;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Owns the lifecycle of the running application.
*
* <p>Switching the operator profile tears the current runtime down completely and builds
* a fresh one in the same process. Rebinding the existing windows is not an option: the
* user interface is built from the chat controller outwards, with several hundred
* references to the active preferences, and many controls are instance fields created
* once. A new {@link Kst4ContestApplication} instance gets fresh controls, which is safe
* here because the class keeps no mutable static state.</p>
*/
public final class ApplicationRuntimeLauncher {
private static final Logger LOGGER = Logger.getLogger(ApplicationRuntimeLauncher.class.getName());
private static Kst4ContestApplication currentRuntime;
private ApplicationRuntimeLauncher() {
// Utility class.
}
/**
* Registers the runtime that is currently live.
*
* @param runtime the running application instance
*/
public static void setCurrent(final Kst4ContestApplication runtime) {
currentRuntime = runtime;
}
/**
* Returns the runtime that is currently live.
*
* @return the running application instance, or null before the first startup
*/
public static Kst4ContestApplication getCurrent() {
return currentRuntime;
}
/**
* Shuts the application down.
*
* <p>JavaFX only calls {@code stop()} on the instance it launched itself, so an exit
* after a profile switch has to release the resources explicitly.</p>
*/
public static void exitApplication() {
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
Platform.exit();
System.exit(0);
}
/**
* Replaces the running runtime with one bound to another operator profile.
*
* @param targetProfile profile to activate
* @return true if the new runtime was built
*/
public static boolean switchProfile(final OperatorProfile targetProfile) {
if (targetProfile == null) {
return false;
}
new OperatorProfileStore().recordLastUsed(targetProfile.getProfileId());
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
ActiveOperatorProfile.set(OperatorProfilePaths.resolve(targetProfile));
Kst4ContestApplication nextRuntime = new Kst4ContestApplication();
try {
nextRuntime.start(new Stage());
} catch (Exception e) {
// The previous runtime is already gone, so there is nothing left to return to.
LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e);
Kst4ContestApplication.alertWindowEvent(
"The operator profile could not be started: " + e.getMessage()
+ "\n\nKST4Contest has to be closed.");
Platform.exit();
System.exit(1);
return false;
}
setCurrent(nextRuntime);
return true;
}
}
@@ -0,0 +1,102 @@
package kst4contest.view;
import java.util.List;
/**
* Command line options of the application.
*
* <p>The parsed value is additionally kept in a process wide holder. JavaFX only knows
* the parameters of the {@code Application} instance it launched itself, so an instance
* created during a profile switch would see no parameters at all. Parsing once at
* startup and remembering the result avoids that entirely.</p>
*/
public class CommandLineOptions {
/**
* Command line switch selecting the operator profile to start with.
*/
public static final String PROFILE_ARGUMENT = "--profile";
/**
* System property used as an alternative to the command line switch.
*/
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null);
private final String requestedProfileName;
public CommandLineOptions(final String requestedProfileName) {
this.requestedProfileName = requestedProfileName;
}
/**
* Parses the raw application arguments.
*
* <p>Unknown arguments are ignored on purpose. A typo in a command line must never
* keep an operator out of the application shortly before a contest.</p>
*
* @param rawArguments raw arguments, may be null
* @return the parsed options
*/
public static CommandLineOptions parse(final List<String> rawArguments) {
String requestedProfileName = null;
if (rawArguments != null) {
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
String currentArgument = rawArguments.get(argumentIndex);
if (currentArgument == null) {
continue;
}
if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1);
} else if (PROFILE_ARGUMENT.equals(currentArgument)
&& argumentIndex + 1 < rawArguments.size()) {
requestedProfileName = rawArguments.get(argumentIndex + 1);
argumentIndex++;
}
}
}
if (requestedProfileName == null || requestedProfileName.isBlank()) {
requestedProfileName = System.getProperty(PROFILE_SYSTEM_PROPERTY);
}
if (requestedProfileName != null && requestedProfileName.isBlank()) {
requestedProfileName = null;
}
return new CommandLineOptions(
requestedProfileName == null ? null : requestedProfileName.trim());
}
/**
* Stores the parsed options for the lifetime of the process.
*
* @param options options to remember
*/
public static void remember(final CommandLineOptions options) {
rememberedOptions = options == null ? new CommandLineOptions(null) : options;
}
/**
* Returns the options parsed at application startup.
*
* @return the remembered options, never null
*/
public static CommandLineOptions remembered() {
return rememberedOptions;
}
/**
* Returns the operator profile requested on the command line.
*
* @return the requested profile name, or null when none was given
*/
public String getRequestedProfileName() {
return requestedProfileName;
}
}
@@ -15,6 +15,7 @@ import java.util.logging.SimpleFormatter;
import java.util.function.Consumer;
import java.util.function.Predicate;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.collections.FXCollections;
@@ -70,6 +71,12 @@ import javafx.stage.Screen;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.utils.ApplicationFileUtils;
import kst4contest.view.map.StationMapBridge;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.controller.OperatorProfileManagementService;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import kst4contest.view.map.StationMapView;
import kst4contest.view.map.OfflineDemImportService;
import kst4contest.controller.WorkedGrossFieldCache;
@@ -5500,6 +5507,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
});
MenuItem menuItemFileSwitchProfile = new MenuItem("Switch operator profile...");
menuItemFileSwitchProfile.setOnAction(event -> showOperatorProfileSwitchDialog());
MenuItem m10 = new MenuItem("Exit + disconnect");
m10.setOnAction(new EventHandler<ActionEvent>() {
public void handle(ActionEvent event) {
@@ -5510,6 +5520,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// add menu items to menu
fileMenu.getItems().add(menuItemFileConnect);
fileMenu.getItems().add(menuItemFileDisconnect);
fileMenu.getItems().add(menuItemFileSwitchProfile);
fileMenu.getItems().add(m10);
Menu optionsMenu = new Menu("Options");
@@ -6096,6 +6107,17 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
FlowPane flwPane_textSnippets;
FlowPane flwpne_StatusBar;
/**
* True once this runtime released its resources. Shutdown must stay idempotent
* because it is reached both through the JavaFX stop() callback and explicitly.
*/
private boolean runtimeShutdownDone;
/**
* The primary stage of this runtime, remembered so shutdown can close it.
*/
private Stage ownPrimaryStage;
Stage clusterAndQSOMonStage;
// Stage stage_selectedCallSignInfoStage;
ChatMember selectedCallSignInfoStageChatMember;
@@ -6227,29 +6249,264 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return txMessageButtons;
}
/**
* Resolves the operator profile this runtime works with.
*
* <p>Only executed on the very first launch. A profile switch sets the profile before
* building the new runtime, so the resolution is skipped there.</p>
*
* <p>An installation with no or exactly one profile is resolved without asking
* anything, which keeps the single operator startup exactly as it was.</p>
*
* @return true if the application may continue starting up
*/
private boolean resolveOperatorProfileIfRequired() {
if (ActiveOperatorProfile.isInitialized()) {
return true;
}
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
OperatorProfileSelection resolvedProfile = bootstrap.resolveAtStartup(
new OperatorProfileStore(),
CommandLineOptions.remembered(),
OperatorProfilePickerDialog::showAndSelect);
if (bootstrap.getStartupWarning() != null) {
Alert startupWarning = new Alert(AlertType.WARNING);
startupWarning.setTitle("Operator profile");
startupWarning.setHeaderText("The requested operator profile was not found.");
startupWarning.setContentText(bootstrap.getStartupWarning());
startupWarning.showAndWait();
}
if (resolvedProfile == null) {
Platform.exit();
System.exit(0);
return false;
}
ActiveOperatorProfile.set(resolvedProfile);
return true;
}
/**
* Returns the window title suffix naming the active operator profile.
*
* <p>Empty for the historic single profile installation, so nothing changes visually
* for operators who never create a second profile.</p>
*
* @return the suffix to append to a window title, never null
*/
private String buildOperatorProfileTitleSuffix() {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
if (activeProfile == null || activeProfile.getProfile().isRootProfile()) {
return "";
}
return " - " + activeProfile.getProfile().getDisplayName();
}
/**
* Lets the operator pick another profile and rebuilds the runtime for it.
*
* <p>Offers to create a second profile when only one exists, because the menu entry
* is the discoverable place to find the feature at all.</p>
*/
private void showOperatorProfileSwitchDialog() {
OperatorProfileStore profileStore = new OperatorProfileStore();
List<OperatorProfile> selectableProfiles = profileStore.loadProfiles();
if (selectableProfiles.size() < 2) {
Alert noProfilesYet = new Alert(AlertType.INFORMATION);
noProfilesYet.setTitle("Operator profiles");
noProfilesYet.setHeaderText("Only one operator profile is configured.");
noProfilesYet.setContentText(
"Additional profiles are created in the settings window on the "
+ "\"Profiles\" tab. Each profile keeps its own settings and layout, "
+ "and can either share the station worked database or use its own.");
noProfilesYet.showAndWait();
return;
}
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
String activeProfileId = activeProfile == null ? null : activeProfile.getProfile().getProfileId();
Optional<OperatorProfile> chosenProfile =
OperatorProfilePickerDialog.showAndSelect(selectableProfiles, activeProfileId);
if (chosenProfile.isEmpty()) {
return;
}
if (chosenProfile.get().getProfileId().equalsIgnoreCase(activeProfileId)) {
return;
}
requestOperatorProfileSwitch(chosenProfile.get());
}
/**
* Confirms and performs a switch to another operator profile.
*
* <p>Shared by the File menu and the profile settings tab, so both ask the same
* question before giving up the running session.</p>
*
* @param targetProfile profile to activate
*/
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) {
return;
}
ApplicationRuntimeLauncher.switchProfile(targetProfile);
}
/**
* Asks whether the running session may be given up for a profile switch.
*
* @param targetProfile profile the operator selected
* @return true if the switch may proceed
*/
private boolean confirmOperatorProfileSwitch(OperatorProfile targetProfile) {
Alert confirmation = new Alert(AlertType.CONFIRMATION);
confirmation.setTitle("Switch operator profile");
confirmation.setHeaderText("Switch to \"" + targetProfile.getDisplayName() + "\"?");
confirmation.setContentText(
"The ON4KST connection is closed and all windows are rebuilt with the "
+ "settings and layout of the selected profile.\n\n"
+ "Unsaved settings of the current profile are lost. Window sizes, "
+ "divider and column widths are saved automatically.");
ButtonType switchButton = new ButtonType("Switch profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
confirmation.getButtonTypes().setAll(switchButton, cancelButton);
return confirmation.showAndWait().orElse(cancelButton) == switchButton;
}
@Override
public void stop() {
System.out.println("[Main.java, Info:] Stage is closing, killing all resources");
if (layoutAutosave != null) {
layoutAutosave.flushPending();
shutdownRuntime();
System.exit(0);
}
/**
* Releases every resource this runtime owns, without terminating the process.
*
* <p>Separated from {@link #stop()} so the same teardown can be reused when the
* operator switches to another profile and a fresh runtime is built afterwards.
* The method is idempotent and tolerates a runtime that never connected, because a
* switch may happen before the first login.</p>
*/
public void shutdownRuntime() {
if (runtimeShutdownDone) {
return;
}
timer_buildWindowTitle.purge();
timer_buildWindowTitle.cancel();
runtimeShutdownDone = true;
System.out.println("[Main.java, Info:] Stage is closing, killing all resources");
if (layoutAutosave != null) {
// Flush before cancelling, otherwise a pending debounced write would either
// be lost or land after a profile switch.
layoutAutosave.flushPending();
layoutAutosave.cancelPending();
}
cancelViewTimer(timer_buildWindowTitle);
timer_buildWindowTitle = null;
// timer_chatMemberTableSortTimer.purge();
// timer_chatMemberTableSortTimer.cancel();
timer_updatePrivatemessageTable.purge();
timer_updatePrivatemessageTable.cancel();
cancelViewTimer(timer_updatePrivatemessageTable);
timer_updatePrivatemessageTable = null;
stopAnimation(userListRefreshCoalescer);
userListRefreshCoalescer = null;
stopAnimation(skedWarnBlinkTimeline);
skedWarnBlinkTimeline = null;
stopAnimation(bandUpgradeBlinkTimeline);
bandUpgradeBlinkTimeline = null;
if (stationMapBridge != null) {
stationMapBridge.uninstall();
stationMapBridge = null;
}
if (stationMapView != null) {
stationMapView.dispose();
stationMapView = null;
}
closeOwnedStages();
try {
chatcontroller.disconnect("CLOSEALL");
if (chatcontroller != null) {
chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE);
}
} catch (Exception e) {
System.out.println("[Main.java, Warning:] Exception during disconnect: " + e.getMessage());
}
}
// Platform.exit();
System.exit(0);
/**
* Cancels a timer created during user interface construction.
*
* @param timerToCancel timer to cancel, may be null when startup did not get that far
*/
private static void cancelViewTimer(Timer timerToCancel) {
if (timerToCancel == null) {
return;
}
timerToCancel.purge();
timerToCancel.cancel();
}
/**
* Stops a JavaFX animation if it exists.
*
* @param animationToStop animation to stop, may be null
*/
private static void stopAnimation(Animation animationToStop) {
if (animationToStop != null) {
animationToStop.stop();
}
}
/**
* Closes every window this runtime opened, so no stale window survives a profile
* switch. The map window is closed by its own dispose method.
*/
private void closeOwnedStages() {
for (Stage ownedStage : new Stage[] {
settingsStage, clusterAndQSOMonStage, stage_updateStage, ownPrimaryStage }) {
if (ownedStage != null) {
try {
ownedStage.close();
} catch (Exception e) {
System.out.println("[Main.java, Warning:] Could not close a window: " + e.getMessage());
}
}
}
settingsStage = null;
clusterAndQSOMonStage = null;
stage_updateStage = null;
ownPrimaryStage = null;
}
private void requestLayoutSave() {
@@ -6644,9 +6901,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
};
}
@Override
public void init() {
Parameters applicationParameters = getParameters();
CommandLineOptions.remember(CommandLineOptions.parse(
applicationParameters == null ? null : applicationParameters.getRaw()));
}
@Override
public void start(Stage primaryStage) throws InterruptedException, IOException, URISyntaxException {
if (!resolveOperatorProfileIfRequired()) {
return;
}
ownPrimaryStage = primaryStage;
/*
* A profile switch closes every window of the old runtime before the new one
* exists. With the JavaFX default that would end the process, so the application
* takes over the exit decision and closing the main window is handled explicitly.
*/
Platform.setImplicitExit(false);
primaryStage.setOnCloseRequest(closeRequest -> {
closeRequest.consume();
ApplicationRuntimeLauncher.exitApplication();
});
ApplicationRuntimeLauncher.setCurrent(this);
GuiUtils.applyApplicationIcon(primaryStage);
VBox pnl_inputAndSendButtons = new VBox(); //gets the sendtext field, send button and the timeline
@@ -6681,8 +6965,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSDAY_RESOURCE, STYLE_DEFAULTCSSDAY_FILE);
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSEVENING_RESOURCE, STYLE_DEFAULTCSSEVENING_FILE);
ChatMember ownChatMemberObject = new ChatMember();
OperatorProfileSelection activeOperatorProfile = ActiveOperatorProfile.get();
chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object
// instantiate the Chatcontroller with the user object and the files of the active profile
chatcontroller = new ChatController(
ownChatMemberObject,
this,
activeOperatorProfile.getPreferencesRelativeFileName(),
activeOperatorProfile.getWorkedDatabaseRelativeFileName(),
activeOperatorProfile.isSeedWorkedDatabaseFromResource());
layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences());
messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences());
chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual
@@ -7200,7 +7491,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
txt_ownqrgSecondCategory.setFocusTraversable(false);
txt_ownqrgSecondCategory.setTooltip(new Tooltip("Enter frequency for second chat-category here by hand! <fixme>"));
primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState());
primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState() + buildOperatorProfileTitleSuffix());
timer_buildWindowTitle = new Timer();
timer_buildWindowTitle.scheduleAtFixedRate(new TimerTask() {
@@ -7255,7 +7546,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatcontroller.getChatPreferences().setChatState(chatState);
}
primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState());
primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState() + buildOperatorProfileTitleSuffix());
// System.out.println(chatcontroller.getChatPreferences().getChatState());
});
@@ -11751,6 +12042,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
Tab tbInternalDB = new Tab("Workedstn database", vbxInternalDB);
Tab tbGui = new Tab("GUI", vbxGuiOptions);
/*
* Appended last on purpose so no established tab position shifts. Contest
* operators navigate these tabs by muscle memory.
*/
Tab tbProfiles = new Tab("Profiles", new OperatorProfileSettingsPane(
new OperatorProfileManagementService(),
this::requestOperatorProfileSwitch));
/**
* Automatic update of tab contents out of the database
@@ -11766,7 +12065,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
tabPaneOptions.getTabs().addAll(tbStationSettings, tbLogSynchSet, tbTRXSynchSet, tbAirScoutSettings, tbNotify,
tbShorts, tbBeacon, tbMsgHandling, tbInternalDB, tbGui);
tbShorts, tbBeacon, tbMsgHandling, tbInternalDB, tbGui, tbProfiles);
optionsPanel.setLeft(tabPaneOptions);
@@ -12178,10 +12477,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (res.get().equals(ButtonType.CANCEL)) {
// event.consume();
} else {
System.out.println("closewindowevent: Platform.exit");
System.out.println("closewindowevent: exiting the application");
Platform.exit();
// Routed through the launcher so the runtime that is actually live
// releases its resources. After a profile switch that is no longer the
// instance JavaFX would call stop() on.
ApplicationRuntimeLauncher.exitApplication();
}
}
// }
@@ -0,0 +1,107 @@
package kst4contest.view;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import java.util.List;
import java.util.Optional;
/**
* Decides which operator profile the application starts with.
*
* <p>The class contains no user interface code so the decision can be tested headless.
* Asking the operator is delegated to an {@link OperatorProfileChoiceRequester}, and a
* problem worth telling the operator about is reported through
* {@link #getStartupWarning()} instead of being shown here.</p>
*
* <p>The most important property of this logic is what it does <em>not</em> do: an
* installation with no or exactly one profile is resolved without asking anything and
* without touching a single file, so a single operator start stays exactly as fast and
* as quiet as it was before profiles existed.</p>
*/
public class OperatorProfileBootstrap {
private String startupWarning;
/**
* Resolves the operator profile to start with.
*
* @param store registry to read the profiles from
* @param commandLineOptions parsed command line options
* @param choiceRequester requester used when the operator has to choose
* @return the resolved selection, or null when the operator chose to quit
*/
public OperatorProfileSelection resolveAtStartup(final OperatorProfileStore store,
final CommandLineOptions commandLineOptions,
final OperatorProfileChoiceRequester choiceRequester) {
startupWarning = null;
List<OperatorProfile> availableProfiles = store.loadProfiles();
String requestedProfileName = commandLineOptions == null
? null
: commandLineOptions.getRequestedProfileName();
if (requestedProfileName != null) {
OperatorProfile requestedProfile = findProfile(availableProfiles, requestedProfileName);
if (requestedProfile != null) {
return OperatorProfilePaths.resolve(requestedProfile);
}
startupWarning = "The operator profile \"" + requestedProfileName
+ "\" is unknown. KST4Contest continues with the normal profile selection.";
}
if (availableProfiles.isEmpty()) {
// No registry at all: the historic flat installation is the only profile.
return OperatorProfilePaths.resolve(store.buildImplicitRootProfile());
}
if (availableProfiles.size() == 1) {
return OperatorProfilePaths.resolve(availableProfiles.get(0));
}
String preselectedProfileId = store.loadLastUsedProfileId().orElse(null);
Optional<OperatorProfile> chosenProfile =
choiceRequester.requestProfileChoice(availableProfiles, preselectedProfileId);
return chosenProfile.map(OperatorProfilePaths::resolve).orElse(null);
}
/**
* Returns a message that should be shown to the operator after startup.
*
* @return the warning text, or null when startup was unremarkable
*/
public String getStartupWarning() {
return startupWarning;
}
/**
* Finds a profile by identifier or display name, ignoring case.
*
* @param availableProfiles profiles to search
* @param requestedName identifier or display name entered by the operator
* @return the matching profile, or null
*/
private static OperatorProfile findProfile(final List<OperatorProfile> availableProfiles,
final String requestedName) {
for (OperatorProfile currentProfile : availableProfiles) {
if (requestedName.equalsIgnoreCase(currentProfile.getProfileId())) {
return currentProfile;
}
}
for (OperatorProfile currentProfile : availableProfiles) {
if (requestedName.equalsIgnoreCase(currentProfile.getDisplayName())) {
return currentProfile;
}
}
return null;
}
}
@@ -0,0 +1,26 @@
package kst4contest.view;
import kst4contest.model.OperatorProfile;
import java.util.List;
import java.util.Optional;
/**
* Asks the operator which profile to start with.
*
* <p>The startup logic depends on this interface rather than on a dialog, so the
* decision which profile to use can be tested without a JavaFX runtime.</p>
*/
@FunctionalInterface
public interface OperatorProfileChoiceRequester {
/**
* Requests a profile choice.
*
* @param selectableProfiles profiles to choose from, never empty
* @param preselectedProfileId identifier to preselect, may be null
* @return the chosen profile, or empty when the operator wants to quit
*/
Optional<OperatorProfile> requestProfileChoice(List<OperatorProfile> selectableProfiles,
String preselectedProfileId);
}
@@ -0,0 +1,143 @@
package kst4contest.view;
import kst4contest.model.OperatorProfile;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.stage.Stage;
import java.util.List;
import java.util.Optional;
/**
* Asks the operator which profile to start with.
*
* <p>The dialog is shown only when more than one profile exists. It is intentionally
* minimal, because it stands between the operator and a contest: the last used profile
* is preselected, the list has the focus, and Enter or a double click start immediately.</p>
*/
public final class OperatorProfilePickerDialog {
private OperatorProfilePickerDialog() {
// Utility class.
}
/**
* Shows the picker and waits for the operator's choice.
*
* @param selectableProfiles profiles to choose from
* @param preselectedProfileId identifier of the profile to preselect, may be null
* @return the chosen profile, or empty when the operator wants to quit
*/
public static Optional<OperatorProfile> showAndSelect(final List<OperatorProfile> selectableProfiles,
final String preselectedProfileId) {
Stage dialogStage = new Stage();
GuiUtils.applyApplicationIcon(dialogStage);
dialogStage.initModality(Modality.APPLICATION_MODAL);
dialogStage.setTitle("Select operator profile");
ListView<OperatorProfile> profileListView = new ListView<>();
profileListView.getItems().addAll(selectableProfiles);
profileListView.setCellFactory(listView -> new OperatorProfileListCell());
VBox.setVgrow(profileListView, Priority.ALWAYS);
selectPreselectedProfile(profileListView, selectableProfiles, preselectedProfileId);
OperatorProfile[] chosenProfile = new OperatorProfile[1];
Button startButton = new Button("Start");
startButton.setDefaultButton(true);
startButton.setOnAction(event -> {
chosenProfile[0] = profileListView.getSelectionModel().getSelectedItem();
dialogStage.close();
});
Button quitButton = new Button("Quit");
quitButton.setCancelButton(true);
quitButton.setOnAction(event -> {
chosenProfile[0] = null;
dialogStage.close();
});
profileListView.setOnMouseClicked(event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
startButton.fire();
}
});
profileListView.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
startButton.fire();
}
});
HBox buttonRow = new HBox(10, startButton, quitButton);
buttonRow.setPadding(new Insets(10, 0, 0, 0));
VBox dialogContent = new VBox(8,
new Label("More than one operator profile is configured."),
profileListView,
buttonRow);
dialogContent.setPadding(new Insets(15));
dialogStage.setScene(new Scene(dialogContent, 380, 280));
profileListView.requestFocus();
dialogStage.showAndWait();
return Optional.ofNullable(chosenProfile[0]);
}
private static void selectPreselectedProfile(final ListView<OperatorProfile> profileListView,
final List<OperatorProfile> selectableProfiles,
final String preselectedProfileId) {
int profileIndexToSelect = 0;
if (preselectedProfileId != null) {
for (int profileIndex = 0; profileIndex < selectableProfiles.size(); profileIndex++) {
if (preselectedProfileId.equalsIgnoreCase(
selectableProfiles.get(profileIndex).getProfileId())) {
profileIndexToSelect = profileIndex;
break;
}
}
}
profileListView.getSelectionModel().select(profileIndexToSelect);
profileListView.scrollTo(profileIndexToSelect);
}
/**
* Renders a profile with its name and the kind of worked data it uses.
*/
private static final class OperatorProfileListCell extends ListCell<OperatorProfile> {
@Override
protected void updateItem(final OperatorProfile profile, final boolean empty) {
super.updateItem(profile, empty);
if (empty || profile == null) {
setText(null);
return;
}
String workedDataDescription = profile.isRootProfile() || profile.isSharedWorkedDatabase()
? "shared station worked database"
: "own worked database";
setText(profile.getDisplayName() + "\n" + workedDataDescription);
}
}
}
@@ -0,0 +1,488 @@
package kst4contest.view;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfileManagementService;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import javafx.beans.property.SimpleStringProperty;
import javafx.geometry.Insets;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.RadioButton;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.util.Pair;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
/**
* Settings tab that manages the operator profiles.
*
* <p>Every profile keeps its own settings and window layout. Whether it also keeps its
* own worked stations is chosen per profile, because a multi operator contest station
* shares one log while two operators on a private computer usually do not.</p>
*/
public class OperatorProfileSettingsPane extends VBox {
private static final DateTimeFormatter LAST_USED_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault());
private final OperatorProfileManagementService managementService;
private final Consumer<OperatorProfile> profileActivationRequest;
private final TableView<OperatorProfile> profileTable = new TableView<>();
private final Label activeProfileLabel = new Label();
private final Label preferencesPathLabel = new Label();
private final Label workedDatabasePathLabel = new Label();
public OperatorProfileSettingsPane(final OperatorProfileManagementService managementService,
final Consumer<OperatorProfile> profileActivationRequest) {
this.managementService = managementService;
this.profileActivationRequest = profileActivationRequest;
setSpacing(10);
setPadding(new Insets(15));
getChildren().addAll(
buildActiveProfileHeader(),
buildProfileTable(),
buildButtonRows(),
buildExplanationLabel());
refreshActiveProfileHeader();
refreshProfileTable();
}
private GridPane buildActiveProfileHeader() {
GridPane headerGrid = new GridPane();
headerGrid.setHgap(10);
headerGrid.setVgap(4);
headerGrid.add(new Label("Active profile:"), 0, 0);
headerGrid.add(activeProfileLabel, 1, 0);
headerGrid.add(new Label("Settings file:"), 0, 1);
headerGrid.add(preferencesPathLabel, 1, 1);
headerGrid.add(new Label("Worked stations:"), 0, 2);
headerGrid.add(workedDatabasePathLabel, 1, 2);
return headerGrid;
}
private TableView<OperatorProfile> buildProfileTable() {
TableColumn<OperatorProfile, String> nameColumn = new TableColumn<>("Profile");
nameColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(cellData.getValue().getDisplayName()));
nameColumn.setPrefWidth(200);
TableColumn<OperatorProfile, String> workedDataColumn = new TableColumn<>("Worked stations");
workedDataColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(describeWorkedDataMode(cellData.getValue())));
workedDataColumn.setPrefWidth(200);
TableColumn<OperatorProfile, String> lastUsedColumn = new TableColumn<>("Last used");
lastUsedColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(describeLastUsed(cellData.getValue())));
lastUsedColumn.setPrefWidth(140);
profileTable.getColumns().add(nameColumn);
profileTable.getColumns().add(workedDataColumn);
profileTable.getColumns().add(lastUsedColumn);
profileTable.setPlaceholder(new Label("No operator profile configured."));
VBox.setVgrow(profileTable, Priority.ALWAYS);
return profileTable;
}
private VBox buildButtonRows() {
Button newProfileButton = new Button("New profile...");
newProfileButton.setOnAction(event -> createProfile());
Button duplicateProfileButton = new Button("Duplicate...");
duplicateProfileButton.setOnAction(event -> duplicateSelectedProfile());
Button renameProfileButton = new Button("Rename...");
renameProfileButton.setOnAction(event -> renameSelectedProfile());
Button deleteProfileButton = new Button("Delete...");
deleteProfileButton.setOnAction(event -> deleteSelectedProfile());
Button changeWorkedDataButton = new Button("Change worked stations...");
changeWorkedDataButton.setOnAction(event -> changeWorkedDataModeOfSelectedProfile());
Button switchProfileButton = new Button("Switch to selected profile...");
switchProfileButton.setOnAction(event -> activateSelectedProfile());
HBox managementRow = new HBox(8,
newProfileButton, duplicateProfileButton, renameProfileButton, deleteProfileButton);
HBox activationRow = new HBox(8, changeWorkedDataButton, switchProfileButton);
return new VBox(8, managementRow, activationRow);
}
private Label buildExplanationLabel() {
Label explanation = new Label(
"Each profile has its own settings and window layout. A profile can either share the "
+ "common station worked stations, which is what a multi operator station wants, "
+ "or keep its own. Duplicating a profile copies everything except callsign and "
+ "password, and never copies worked stations.");
explanation.setWrapText(true);
return explanation;
}
private void refreshActiveProfileHeader() {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
if (activeProfile == null) {
activeProfileLabel.setText("unknown");
return;
}
activeProfileLabel.setText(activeProfile.getProfile().getDisplayName());
preferencesPathLabel.setText(activeProfile.getPreferencesAbsolutePath());
workedDatabasePathLabel.setText(activeProfile.getWorkedDatabaseAbsolutePath());
}
private void refreshProfileTable() {
List<OperatorProfile> knownProfiles = managementService.listProfiles();
OperatorProfile previouslySelected = profileTable.getSelectionModel().getSelectedItem();
profileTable.getItems().setAll(knownProfiles);
if (previouslySelected != null && knownProfiles.contains(previouslySelected)) {
profileTable.getSelectionModel().select(previouslySelected);
} else if (!knownProfiles.isEmpty()) {
profileTable.getSelectionModel().select(0);
}
}
private void createProfile() {
Optional<Pair<String, Boolean>> enteredProfile =
showProfileCreationDialog("New operator profile", "");
if (enteredProfile.isEmpty()) {
return;
}
OperatorProfile createdProfile = managementService.createProfile(
enteredProfile.get().getKey(), enteredProfile.get().getValue());
if (createdProfile == null) {
showError("The profile could not be created. The profile registry could not be written.");
return;
}
refreshProfileTable();
profileTable.getSelectionModel().select(createdProfile);
showInformation("The profile \"" + createdProfile.getDisplayName() + "\" was created without "
+ "callsign and password. Enter them on the Station tab after switching to it.");
}
private void duplicateSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
TextInputDialog nameDialog =
new TextInputDialog("Copy of " + selectedProfile.getDisplayName());
nameDialog.setTitle("Duplicate operator profile");
nameDialog.setHeaderText("Name of the new profile");
nameDialog.setContentText(
"Everything is copied except callsign and password. Worked stations are never copied.");
Optional<String> enteredName = nameDialog.showAndWait();
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
return;
}
OperatorProfile duplicatedProfile =
managementService.duplicateProfile(selectedProfile, enteredName.get());
if (duplicatedProfile == null) {
showError("The profile could not be duplicated.");
return;
}
refreshProfileTable();
profileTable.getSelectionModel().select(duplicatedProfile);
}
private void renameSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
TextInputDialog nameDialog = new TextInputDialog(selectedProfile.getDisplayName());
nameDialog.setTitle("Rename operator profile");
nameDialog.setHeaderText("New name of the profile");
nameDialog.setContentText("Files and folders of the profile are not touched.");
Optional<String> enteredName = nameDialog.showAndWait();
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
return;
}
managementService.renameProfile(selectedProfile, enteredName.get());
refreshProfileTable();
refreshActiveProfileHeader();
}
private void deleteSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (selectedProfile.isRootProfile()) {
showError("The default profile uses the files of the installation itself "
+ "and cannot be deleted.");
return;
}
if (isActiveProfile(selectedProfile)) {
showError("The profile currently in use cannot be deleted. Switch to another "
+ "profile first.");
return;
}
Alert confirmation = new Alert(AlertType.CONFIRMATION);
confirmation.setTitle("Delete operator profile");
confirmation.setHeaderText("Delete the profile \"" + selectedProfile.getDisplayName() + "\"?");
confirmation.setContentText(
"The following folder is removed permanently:\n"
+ managementService.getProfileDirectory(selectedProfile)
+ "\n\n"
+ (selectedProfile.isSharedWorkedDatabase()
? "The common station worked stations are not touched."
: "The worked stations of this profile are deleted as well."));
ButtonType deleteButton = new ButtonType("Delete profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
confirmation.getButtonTypes().setAll(deleteButton, cancelButton);
if (confirmation.showAndWait().orElse(cancelButton) != deleteButton) {
return;
}
if (!managementService.deleteProfile(selectedProfile)) {
showError("The profile could not be deleted.");
}
refreshProfileTable();
}
private void changeWorkedDataModeOfSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (selectedProfile.isRootProfile()) {
showError("The default profile always uses the common station worked stations, "
+ "because that database is the one of the installation itself.");
return;
}
Optional<Boolean> chosenMode = showWorkedDataModeDialog(selectedProfile);
if (chosenMode.isEmpty() || chosenMode.get() == selectedProfile.isSharedWorkedDatabase()) {
return;
}
managementService.setSharedWorkedDatabase(selectedProfile, chosenMode.get());
refreshProfileTable();
if (isActiveProfile(selectedProfile)) {
showInformation("The change takes effect after switching to this profile again.");
}
}
private void activateSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (isActiveProfile(selectedProfile)) {
showInformation("This profile is already active.");
return;
}
profileActivationRequest.accept(selectedProfile);
}
private Optional<Pair<String, Boolean>> showProfileCreationDialog(final String title,
final String initialName) {
Dialog<Pair<String, Boolean>> creationDialog = new Dialog<>();
creationDialog.setTitle(title);
creationDialog.setHeaderText("Name and worked stations of the new profile");
ButtonType createButton = new ButtonType("Create profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
creationDialog.getDialogPane().getButtonTypes().setAll(createButton, cancelButton);
TextField nameField = new TextField(initialName);
nameField.setPromptText("for example DN9APW");
ToggleGroup workedDataGroup = new ToggleGroup();
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
ownDatabaseOption.setToggleGroup(workedDataGroup);
ownDatabaseOption.setSelected(true);
RadioButton sharedDatabaseOption =
new RadioButton("Share the common station worked stations (multi operator station)");
sharedDatabaseOption.setToggleGroup(workedDataGroup);
VBox dialogContent = new VBox(8,
new Label("Profile name"),
nameField,
new Label("Worked stations"),
ownDatabaseOption,
sharedDatabaseOption);
dialogContent.setPadding(new Insets(10));
creationDialog.getDialogPane().setContent(dialogContent);
creationDialog.setResultConverter(pressedButton -> {
if (pressedButton != createButton || nameField.getText().isBlank()) {
return null;
}
return new Pair<>(nameField.getText().trim(), sharedDatabaseOption.isSelected());
});
return creationDialog.showAndWait();
}
private Optional<Boolean> showWorkedDataModeDialog(final OperatorProfile profile) {
Dialog<Boolean> modeDialog = new Dialog<>();
modeDialog.setTitle("Worked stations");
modeDialog.setHeaderText("Worked stations of \"" + profile.getDisplayName() + "\"");
ButtonType applyButton = new ButtonType("Apply", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
modeDialog.getDialogPane().getButtonTypes().setAll(applyButton, cancelButton);
ToggleGroup workedDataGroup = new ToggleGroup();
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
ownDatabaseOption.setToggleGroup(workedDataGroup);
RadioButton sharedDatabaseOption =
new RadioButton("Share the common station worked stations (multi operator station)");
sharedDatabaseOption.setToggleGroup(workedDataGroup);
sharedDatabaseOption.setSelected(profile.isSharedWorkedDatabase());
ownDatabaseOption.setSelected(!profile.isSharedWorkedDatabase());
Label pathHint = new Label("Switching does not move any data. Worked stations already "
+ "collected under the other setting stay where they are.");
pathHint.setWrapText(true);
VBox dialogContent = new VBox(8, ownDatabaseOption, sharedDatabaseOption, pathHint);
dialogContent.setPadding(new Insets(10));
modeDialog.getDialogPane().setContent(dialogContent);
modeDialog.setResultConverter(pressedButton ->
pressedButton == applyButton ? sharedDatabaseOption.isSelected() : null);
return modeDialog.showAndWait();
}
private OperatorProfile requireSelectedProfile() {
OperatorProfile selectedProfile = profileTable.getSelectionModel().getSelectedItem();
if (selectedProfile == null) {
showInformation("Select a profile in the table first.");
}
return selectedProfile;
}
private static boolean isActiveProfile(final OperatorProfile profile) {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
return activeProfile != null
&& activeProfile.getProfile().getProfileId().equals(profile.getProfileId());
}
private static String describeWorkedDataMode(final OperatorProfile profile) {
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
return "common station database";
}
return "own database";
}
private static String describeLastUsed(final OperatorProfile profile) {
if (profile.getLastUsedEpochMs() <= 0L) {
return "";
}
return LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.getLastUsedEpochMs()));
}
private static void showInformation(final String message) {
Alert information = new Alert(AlertType.INFORMATION);
information.setTitle("Operator profiles");
information.setContentText(message);
information.showAndWait();
}
private static void showError(final String message) {
Alert error = new Alert(AlertType.ERROR);
error.setTitle("Operator profiles");
error.setContentText(message);
error.showAndWait();
}
}
@@ -2,6 +2,7 @@ package kst4contest.view.map;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.collections.ListChangeListener;
import javafx.scene.control.TableView;
import javafx.util.Duration;
@@ -54,6 +55,16 @@ public final class StationMapBridge {
private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0));
/*
* The listeners are kept so install() can be undone. Without that, the coalescing
* animation and the registered listeners would keep a discarded runtime reachable
* after an operator profile switch.
*/
private ListChangeListener<ChatMember> chatMemberListListener;
private ChangeListener<ChatMember> selectedChatMemberListener;
private ChangeListener<Number> antennaDirectionListener;
private ListChangeListener<Predicate<ChatMember>> filterPredicateListener;
public StationMapBridge(ChatController chatController,
TableView<ChatMember> chatMemberTable,
StationMapView stationMapView,
@@ -81,25 +92,59 @@ public final class StationMapBridge {
stationMapView.setOnResetView(this::handleMapReset);
chatController.getLst_chatMemberSortedFilteredList().addListener(
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
);
chatMemberListListener = change -> scheduleRefresh();
chatController.getLst_chatMemberSortedFilteredList().addListener(chatMemberListListener);
chatController.getScoreService().selectedChatMemberProperty().addListener(
(obs, oldValue, newValue) -> requestImmediateRefresh()
);
selectedChatMemberListener = (obs, oldValue, newValue) -> requestImmediateRefresh();
chatController.getScoreService().selectedChatMemberProperty()
.addListener(selectedChatMemberListener);
chatController.getChatPreferences().getActualQTF().addListener(
(obs, oldValue, newValue) -> scheduleRefresh()
);
antennaDirectionListener = (obs, oldValue, newValue) -> scheduleRefresh();
chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener);
chatController.getLst_chatMemberListFilterPredicates().addListener(
(ListChangeListener<Predicate<ChatMember>>) change -> requestImmediateRefresh()
);
filterPredicateListener = change -> requestImmediateRefresh();
chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener);
requestImmediateRefresh();
}
/**
* Removes everything {@link #install()} registered and stops the coalescing timer.
*
* <p>Needed when the runtime owning this bridge is discarded, for example during an
* operator profile switch. A running {@link PauseTransition} would otherwise keep
* firing into a dead user interface.</p>
*/
public void uninstall() {
refreshCoalescer.stop();
stationMapView.setOnCallsignRawSelected(null);
stationMapView.setOnTriggerClusterSpot(null);
stationMapView.setOnResetView(null);
if (chatMemberListListener != null) {
chatController.getLst_chatMemberSortedFilteredList().removeListener(chatMemberListListener);
chatMemberListListener = null;
}
if (selectedChatMemberListener != null) {
chatController.getScoreService().selectedChatMemberProperty()
.removeListener(selectedChatMemberListener);
selectedChatMemberListener = null;
}
if (antennaDirectionListener != null) {
chatController.getChatPreferences().getActualQTF().removeListener(antennaDirectionListener);
antennaDirectionListener = null;
}
if (filterPredicateListener != null) {
chatController.getLst_chatMemberListFilterPredicates().removeListener(filterPredicateListener);
filterPredicateListener = null;
}
}
private void handleMapReset() {
Runnable resetAction = () -> {
/*
@@ -244,6 +244,25 @@ public final class StationMapView {
stage.hide();
}
/**
* Releases every resource this map window owns.
*
* <p>The tile proxy is a local server socket with its own thread pool. It used to
* live until the process ended, which was harmless while the map existed exactly
* once per process. A runtime that is discarded, for example during an operator
* profile switch, has to hand it back.</p>
*/
public void dispose() {
if (tileProxyServer != null) {
tileProxyServer.stop();
tileProxyServer = null;
}
webEngine.load(null);
stage.close();
}
public boolean isShowing() {
return stage.isShowing();
}
@@ -1,9 +1,11 @@
package kst4contest.view.map;
import kst4contest.ApplicationConstants;
import kst4contest.controller.DBController;
import kst4contest.utils.ApplicationFileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
@@ -15,30 +17,44 @@ import java.util.Locale;
import java.util.Optional;
/**
* Persistent terrain profile cache stored in the application's existing SQLite database.
* Persistent terrain profile cache, stored globally in its own SQLite database.
*
* The cache is intentionally owner-bound:
* if the configured own callsign or own locator changes, all cached terrain
* profiles are cleared automatically.
* <p>Terrain profiles are pure geometry derived from two locators and a sample count.
* They do not belong to one operator, so the cache is deliberately not part of an
* operator profile: at a multi operator station both operators share one location, and
* duplicating the cache would double the traffic against an external terrain service.</p>
*
* <p>Entries are separated by owner identity through the primary key instead. Earlier
* versions kept a single owner identity and dropped the whole cache whenever the
* configured callsign or locator changed; with several operator profiles that would
* discard every computed profile on each switch.</p>
*/
public final class TerrainProfileCacheRepository {
private static final String META_KEY_OWNER_CALLSIGN_RAW = "terrain_cache_owner_callsign_raw";
private static final String META_KEY_OWNER_LOCATOR6 = "terrain_cache_owner_locator6";
/**
* File name of the global terrain profile cache below the application directory.
*/
public static final String TERRAIN_CACHE_DATABASE_FILE = "terrainprofilecache.db";
private final String databasePath;
public TerrainProfileCacheRepository() {
ApplicationFileUtils.copyResourceIfRequired(
ApplicationConstants.APPLICATION_NAME,
DBController.DATABASE_RESOURCE,
DBController.DATABASE_FILE
);
this.databasePath = ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
DBController.DATABASE_FILE
TERRAIN_CACHE_DATABASE_FILE
);
// SQLite only creates the database file itself, not the directory holding it.
Path applicationDirectory = Path.of(databasePath).getParent();
if (applicationDirectory != null) {
try {
Files.createDirectories(applicationDirectory);
} catch (IOException exception) {
System.err.println("[StationMap] Terrain cache directory could not be created: "
+ exception.getMessage());
}
}
}
public synchronized Optional<TerrainProfileData> load(String ownerCallsignRaw,
@@ -183,60 +199,37 @@ public final class TerrainProfileCacheRepository {
""");
statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS TerrainProfileCacheMeta (
meta_key TEXT NOT NULL PRIMARY KEY,
meta_value TEXT NOT NULL
CREATE TABLE IF NOT EXISTS TerrainProfileCacheOwner (
owner_callsign_raw TEXT NOT NULL,
owner_locator6 TEXT NOT NULL,
last_used_epoch_ms INTEGER NOT NULL,
PRIMARY KEY (owner_callsign_raw, owner_locator6)
)
""");
}
}
/**
* Records that the given owner identity is in use.
*
* <p>Entries of other owners stay untouched. The cached profiles of an identity are
* separated by the primary key already, so a different callsign or locator simply
* misses the cache instead of invalidating everybody else's entries.</p>
*/
private void ensureOwnerIdentity(Connection connection,
String currentOwnerCallsignRaw,
String currentOwnerLocator6) throws Exception {
String normalizedOwnerCallsignRaw = normalize(currentOwnerCallsignRaw);
String normalizedOwnerLocator6 = normalize(currentOwnerLocator6);
String storedOwnerCallsignRaw = readMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW);
String storedOwnerLocator6 = readMetaValue(connection, META_KEY_OWNER_LOCATOR6);
boolean callsignChanged = storedOwnerCallsignRaw != null && !storedOwnerCallsignRaw.equals(normalizedOwnerCallsignRaw);
boolean locatorChanged = storedOwnerLocator6 != null && !storedOwnerLocator6.equals(normalizedOwnerLocator6);
if (callsignChanged || locatorChanged) {
clearTerrainCache(connection);
}
writeMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW, normalizedOwnerCallsignRaw);
writeMetaValue(connection, META_KEY_OWNER_LOCATOR6, normalizedOwnerLocator6);
}
private void clearTerrainCache(Connection connection) throws Exception {
try (Statement statement = connection.createStatement()) {
statement.executeUpdate("DELETE FROM TerrainProfileCache");
}
}
private String readMetaValue(Connection connection, String key) throws Exception {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT meta_value FROM TerrainProfileCacheMeta WHERE meta_key = ?")) {
statement.setString(1, key);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? resultSet.getString(1) : null;
}
}
}
private void writeMetaValue(Connection connection, String key, String value) throws Exception {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value)
VALUES (?, ?)
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value
INSERT INTO TerrainProfileCacheOwner (
owner_callsign_raw, owner_locator6, last_used_epoch_ms
) VALUES (?, ?, ?)
ON CONFLICT(owner_callsign_raw, owner_locator6) DO UPDATE SET
last_used_epoch_ms = excluded.last_used_epoch_ms
""")) {
statement.setString(1, key);
statement.setString(2, value == null ? "" : value);
statement.setString(1, normalize(currentOwnerCallsignRaw));
statement.setString(2, normalize(currentOwnerLocator6));
statement.setLong(3, System.currentTimeMillis());
statement.executeUpdate();
}
}
@@ -0,0 +1,127 @@
package kst4contest.controller;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.SQLException;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies that one DBController instance works on exactly one database file, so two
* operator profiles can keep independent worked-station data inside the same process.
*/
class DBControllerProfileDatabaseTest {
private static final String USER_HOME_PROPERTY = "user.home";
@TempDir
Path temporaryHomeDirectory;
private String originalUserHome;
@BeforeEach
void redirectUserHomeToTemporaryDirectory() {
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
}
@AfterEach
void restoreUserHome() {
if (originalUserHome == null) {
System.clearProperty(USER_HOME_PROPERTY);
} else {
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
}
}
@Test
void profileDatabaseIsCreatedEmptyAndKeepsWorkedDataSeparate() throws SQLException {
DBController firstProfileDatabase =
new DBController("profiles/OP1/praktiKST.db", false);
DBController secondProfileDatabase =
new DBController("profiles/OP2/praktiKST.db", false);
try {
// A profile database must not inherit the several thousand callsigns of the
// bundled template database.
assertTrue(firstProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty());
assertTrue(secondProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty());
assertNotEquals(firstProfileDatabase.getDatabaseFilePath(),
secondProfileDatabase.getDatabaseFilePath());
assertTrue(Files.exists(Path.of(firstProfileDatabase.getDatabaseFilePath())));
assertTrue(Files.exists(Path.of(secondProfileDatabase.getDatabaseFilePath())));
ChatMember workedOnFirstProfile = new ChatMember();
workedOnFirstProfile.setCallSign("DL0XYZ");
workedOnFirstProfile.setQra("JO51IJ");
workedOnFirstProfile.setWorked(true);
workedOnFirstProfile.setWorked144(true);
firstProfileDatabase.storeChatMember(workedOnFirstProfile);
Map<String, ChatMember> firstProfileContent =
firstProfileDatabase.fetchChatMemberWkdDataFromDB();
Map<String, ChatMember> secondProfileContent =
secondProfileDatabase.fetchChatMemberWkdDataFromDB();
assertEquals(1, firstProfileContent.size());
assertTrue(firstProfileContent.get("DL0XYZ").isWorked144());
assertTrue(secondProfileContent.isEmpty(),
"A worked station of one profile must not appear in the other profile");
} finally {
firstProfileDatabase.closeDBConnection();
secondProfileDatabase.closeDBConnection();
}
}
@Test
void twoProfilesPointingAtTheSameFileShareTheirWorkedData() throws SQLException {
DBController sharedStationDatabase = new DBController("praktiKST.db", false);
DBController sameSharedDatabaseAgain = new DBController("praktiKST.db", false);
try {
ChatMember workedAtTheStation = new ChatMember();
workedAtTheStation.setCallSign("DL0ABC");
workedAtTheStation.setWorked(true);
workedAtTheStation.setWorked432(true);
sharedStationDatabase.storeChatMember(workedAtTheStation);
Map<String, ChatMember> seenByTheOtherOperator =
sameSharedDatabaseAgain.fetchChatMemberWkdDataFromDB();
assertTrue(seenByTheOtherOperator.containsKey("DL0ABC"),
"Operators sharing one station database must see the same worked stations");
assertTrue(seenByTheOtherOperator.get("DL0ABC").isWorked432());
} finally {
sharedStationDatabase.closeDBConnection();
sameSharedDatabaseAgain.closeDBConnection();
}
}
@Test
void closingTheConnectionDeregistersTheShutdownHook() {
DBController profileDatabase = new DBController("profiles/OP3/praktiKST.db", false);
profileDatabase.closeDBConnection();
// A second close must stay harmless, and the hook must already be gone.
profileDatabase.closeDBConnection();
assertFalse(Files.notExists(Path.of(profileDatabase.getDatabaseFilePath())),
"The database file stays on disk after the connection was closed");
}
}
@@ -3,63 +3,46 @@ package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.OutputStreamWriter;
import java.lang.reflect.Field;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
class On4KstConnectionProbeTest {
@Test
void separatesClientProbeServerProbeAndInternalResponses() {
assertEquals("CK|", On4KstProtocol.clientLivenessProbe());
assertTrue(On4KstProtocol.isClientLivenessProbeResponse("OK"));
assertTrue(On4KstProtocol.isClientLivenessProbeResponse("OK|"));
assertFalse(On4KstProtocol.isClientLivenessProbeResponse(
"OK|unexpected|"));
assertTrue(On4KstProtocol.isServerLivenessProbe("CK|"));
assertEquals("", On4KstProtocol.serverLivenessProbeResponse());
assertTrue(On4KstProtocol.isInternalDxqResponse("DXQ|2|data|"));
void buildsMainChatProbeAndAcceptsExpectedResponse() {
assertEquals("RDXQ|2|", On4KstProtocol.connectionProbe(2));
assertTrue(On4KstProtocol.isConnectionProbeResponse("DXQ|2|data|"));
assertFalse(On4KstProtocol.isConnectionProbeResponse(
"CH|2|123|DL1ABC|Name|0|text|0|"));
}
@Test
void selectsOneClientProbeAndTimeoutAtIdleBoundaries() {
void selectsHeartbeatProbeAndTimeoutAtIdleBoundaries() {
assertEquals(
On4KstConnectionManager.IdleAction.NONE,
idleAction(90_000L, true, false));
idleAction(90_000L, false, false));
assertEquals(
On4KstConnectionManager.IdleAction.HEARTBEAT,
idleAction(90_001L, false, false));
assertEquals(
On4KstConnectionManager.IdleAction.NONE,
idleAction(90_001L, false, false),
"No client probe may be sent before the session is online");
idleAction(179_999L, true, false));
assertEquals(
On4KstConnectionManager.IdleAction.CLIENT_LIVENESS_PROBE,
idleAction(90_001L, true, false));
assertEquals(
On4KstConnectionManager.IdleAction.NONE,
idleAction(180_000L, true, true),
"The 90-second CK remains the only probe in this idle phase");
assertEquals(
On4KstConnectionManager.IdleAction.CLIENT_LIVENESS_PROBE,
idleAction(180_000L, true, false),
"Even without probe state, the only 180-second action is CK");
On4KstConnectionManager.IdleAction.CONNECTION_PROBE,
idleAction(180_000L, true, false));
assertEquals(
On4KstConnectionManager.IdleAction.NONE,
idleAction(210_000L, true, true));
@@ -69,9 +52,9 @@ class On4KstConnectionProbeTest {
}
@Test
void sessionProbeCoversTwoCategoriesAndRepeatedIdlePhases() {
On4KstConnectionManager.ClientLivenessProbeState probe =
new On4KstConnectionManager.ClientLivenessProbeState();
void oneSessionProbeIsAcknowledgedByAnyInboundTraffic() {
On4KstConnectionManager.ConnectionProbeState probe =
new On4KstConnectionManager.ConnectionProbeState();
assertTrue(probe.tryStart(1_000L));
assertFalse(probe.tryStart(1_001L),
@@ -88,8 +71,8 @@ class On4KstConnectionProbeTest {
@Test
@Timeout(5)
void writerUsesExactBytesForClientAndServerLivenessFrames() throws Exception {
byte[] expected = "CK|\r\n\r\n".getBytes(StandardCharsets.UTF_8);
void writerUsesExactCrLfForHeartbeatAndConnectionProbe() throws Exception {
byte[] expected = "\r\nRDXQ|2|\r\n".getBytes(StandardCharsets.UTF_8);
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<byte[]> received = CompletableFuture.supplyAsync(() -> {
@@ -115,9 +98,8 @@ class On4KstConnectionProbeTest {
ignored -> { });
writer.start();
queue.add(serverFrame(On4KstProtocol.clientLivenessProbe()));
queue.add(serverFrame(
On4KstProtocol.serverLivenessProbeResponse()));
queue.add(serverFrame(""));
queue.add(serverFrame(On4KstProtocol.connectionProbe(2)));
assertArrayEquals(
expected,
@@ -130,153 +112,14 @@ class On4KstConnectionProbeTest {
}
}
@Test
@Timeout(5)
void readerRecordsOkAsActivityWithoutPublishingIt() throws Exception {
CountDownLatch releaseChatFrame = new CountDownLatch(1);
String chatFrame = "CH|2|123|DL1ABC|Name|0|text|0|";
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<Void> serverDone = CompletableFuture.runAsync(() -> {
try (Socket accepted = server.accept();
OutputStreamWriter out = new OutputStreamWriter(
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
out.write("OK|\r\n");
out.flush();
releaseChatFrame.await(2, TimeUnit.SECONDS);
out.write(chatFrame + "\r\n");
out.flush();
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
LinkedBlockingQueue<ChatMessage> messages =
new LinkedBlockingQueue<>();
LinkedBlockingQueue<String> activity =
new LinkedBlockingQueue<>();
AtomicBoolean active = new AtomicBoolean(true);
ReadThread reader = new ReadThread(
21L,
client,
messages,
ignored -> active.get(),
activity::offer,
ignored -> { });
reader.start();
assertEquals("OK|", activity.poll(2, TimeUnit.SECONDS));
assertNull(messages.poll(200, TimeUnit.MILLISECONDS));
releaseChatFrame.countDown();
assertEquals(chatFrame,
messages.poll(2, TimeUnit.SECONDS).getMessageText());
active.set(false);
reader.join(Duration.ofSeconds(2).toMillis());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
@Test
@Timeout(5)
void readerIgnoresDelayedResponseFromReplacedSession() throws Exception {
CountDownLatch releaseOldResponse = new CountDownLatch(1);
AtomicBoolean inboundCallbackUsed = new AtomicBoolean();
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<Void> serverDone = CompletableFuture.runAsync(() -> {
try (Socket accepted = server.accept();
OutputStreamWriter out = new OutputStreamWriter(
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
releaseOldResponse.await(2, TimeUnit.SECONDS);
out.write("OK\r\n");
out.flush();
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
LinkedBlockingQueue<ChatMessage> messages =
new LinkedBlockingQueue<>();
AtomicBoolean active = new AtomicBoolean(true);
ReadThread reader = new ReadThread(
22L,
client,
messages,
ignored -> active.get(),
ignored -> inboundCallbackUsed.set(true),
ignored -> { });
reader.start();
active.set(false);
releaseOldResponse.countDown();
reader.join(Duration.ofSeconds(2).toMillis());
assertFalse(inboundCallbackUsed.get());
assertNull(messages.poll());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
@Test
@Timeout(15)
void unansweredProbeUsesExistingReconnectFlow() throws Exception {
ChatPreferences preferences = localPreferences();
ChatController controller = org.mockito.Mockito.mock(
ChatController.class);
org.mockito.Mockito.when(controller.getChatPreferences())
.thenReturn(preferences);
org.mockito.Mockito.when(controller.getChatCategoryMain())
.thenReturn(preferences.getLoginChatCategoryMain());
org.mockito.Mockito.when(controller.getChatCategorySecondChat())
.thenReturn(preferences.getLoginChatCategorySecond());
try (ServerSocket server = new ServerSocket(0)) {
preferences.setStn_on4kstServersPort(server.getLocalPort());
CompletableFuture<Integer> acceptedConnections =
CompletableFuture.supplyAsync(() -> {
try (Socket first = server.accept();
Socket second = server.accept()) {
return 2;
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
On4KstConnectionManager manager =
new On4KstConnectionManager(controller);
try {
manager.start();
awaitState(manager, On4KstConnectionState.AUTHENTICATING);
manager.onLogstat(1L, new String[] {"LOGSTAT", "100"});
awaitState(manager, On4KstConnectionState.SYNCING_MAIN_CHAT);
manager.onInitialUserListCompleted(1L,
preferences.getLoginChatCategoryMain());
awaitState(manager, On4KstConnectionState.ONLINE);
setTimedOutProbe(manager, System.currentTimeMillis());
awaitState(manager, On4KstConnectionState.RECONNECT_WAIT);
assertEquals(2, acceptedConnections.get(7, TimeUnit.SECONDS));
} finally {
manager.stopByUser();
}
}
}
private On4KstConnectionManager.IdleAction idleAction(
long inboundIdleMillis,
boolean online,
boolean heartbeatSent,
boolean probeOutstanding
) {
return On4KstConnectionManager.determineIdleAction(
inboundIdleMillis,
online,
heartbeatSent,
probeOutstanding);
}
@@ -286,55 +129,4 @@ class On4KstConnectionProbeTest {
message.setMessageText(text);
return message;
}
private ChatPreferences localPreferences() {
ChatPreferences preferences = new ChatPreferences();
preferences.setStn_on4kstServersDns("127.0.0.1");
preferences.setStn_loginCallSign("DL1ABC");
preferences.setStn_loginPassword("test-password");
preferences.setStn_loginNameMainCat("");
preferences.setStn_loginLocatorMainCat("JO50AA");
preferences.setLoginChatCategoryMain(new ChatCategory(2));
preferences.setLoginChatCategorySecond(new ChatCategory(3));
preferences.setLoginToSecondChatEnabled(false);
return preferences;
}
private void awaitState(
On4KstConnectionManager manager,
On4KstConnectionState expected
) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(7L);
while (System.nanoTime() < deadline) {
if (manager.getState() == expected) {
return;
}
TimeUnit.MILLISECONDS.sleep(25L);
}
assertEquals(expected, manager.getState());
}
private void setTimedOutProbe(
On4KstConnectionManager manager,
long now
) throws ReflectiveOperationException {
Field activeSession = On4KstConnectionManager.class
.getDeclaredField("activeSession");
activeSession.setAccessible(true);
Object session = activeSession.get(manager);
Field lastInboundMillis = session.getClass()
.getDeclaredField("lastInboundMillis");
lastInboundMillis.setAccessible(true);
((AtomicLong) lastInboundMillis.get(session)).set(
now - On4KstConnectionManager.INBOUND_STALE_AFTER_MILLIS - 1L);
Field clientLivenessProbe = session.getClass()
.getDeclaredField("clientLivenessProbe");
clientLivenessProbe.setAccessible(true);
((On4KstConnectionManager.ClientLivenessProbeState)
clientLivenessProbe.get(session)).tryStart(
now - On4KstConnectionManager
.CLIENT_LIVENESS_PROBE_AFTER_MILLIS);
}
}
@@ -1,296 +0,0 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
/**
* Opt-in practice test against the configured ON4KST server.
*
* <p>The normal test suite skips this class. Run it explicitly with
* {@code -Don4kst.live=true -Dtest=On4KstIdlePracticeTest} from the IDE or
* Maven when the locally stored test credentials may be used. The practice
* connection uses only the usually quiet category 9.</p>
*/
class On4KstIdlePracticeTest {
private static final int LIVE_CATEGORY = ChatCategory.VUHFR3;
private static final Duration ONLINE_TIMEOUT = Duration.ofSeconds(90);
private static final Duration QUIET_PHASE_TIMEOUT = Duration.ofMinutes(45);
private static final Duration POST_RESPONSE_OBSERVATION = Duration.ofSeconds(135);
@Test
@Timeout(value = 50, unit = TimeUnit.MINUTES)
void observesCkOkWithoutQuietReconnect() throws Exception {
assumeTrue(Boolean.getBoolean("on4kst.live"),
"Live ON4KST test requires -Don4kst.live=true");
ChatPreferences preferences = new ChatPreferences();
assumeTrue(preferences.readPreferencesFromXmlFile(),
"No readable local ON4KST test configuration");
assumeTrue(hasText(preferences.getStn_loginCallSign())
&& hasText(preferences.getStn_loginPassword()),
"Local ON4KST test credentials are incomplete");
preferences.setLoginChatCategoryMain(
new ChatCategory(LIVE_CATEGORY));
preferences.setLoginToSecondChatEnabled(false);
LinkedBlockingQueue<StateEvent> stateEvents =
new LinkedBlockingQueue<>();
LinkedBlockingQueue<ProbeEvent> probeEvents =
new LinkedBlockingQueue<>();
AtomicBoolean reconnectObserved = new AtomicBoolean();
AtomicReference<On4KstConnectionManager> managerReference =
new AtomicReference<>();
ChatController controller = mock(ChatController.class);
when(controller.getChatPreferences()).thenReturn(preferences);
ChatCategory mainCategory = preferences.getLoginChatCategoryMain();
ChatCategory secondCategory = preferences.getLoginChatCategorySecond();
if (secondCategory == null) {
secondCategory = new ChatCategory(
mainCategory.getCategoryNumber() == 2 ? 3 : 2);
}
when(controller.getChatCategoryMain()).thenReturn(mainCategory);
when(controller.getChatCategorySecondChat()).thenReturn(secondCategory);
DBController database = mock(DBController.class);
when(database.fetchChatMemberWkdDataForOnlyOneCallsignFromDB(
any(ChatMember.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
doNothing().when(database).storeChatMember(any(ChatMember.class));
when(controller.getDbHandler()).thenReturn(database);
doAnswer(invocation -> {
On4KstConnectionState state = invocation.getArgument(0);
stateEvents.offer(new StateEvent(state, ZonedDateTime.now()));
if (state == On4KstConnectionState.RECONNECT_WAIT) {
reconnectObserved.set(true);
}
return null;
}).when(controller).updateOn4KstConnectionState(
any(On4KstConnectionState.class), anyString(), anyBoolean());
doAnswer(invocation -> {
managerReference.get().onLogstat(
invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(controller).onOn4KstLogstat(anyLong(), any(String[].class));
doAnswer(invocation -> {
managerReference.get().stageInitialChatMember(
invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(controller).stageInitialOn4KstChatMember(
anyLong(), any(ChatMember.class));
doAnswer(invocation -> {
managerReference.get().onInitialUserListCompleted(
invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(controller).onOn4KstInitialUserListCompleted(
anyLong(), any(ChatCategory.class));
On4KstConnectionManager manager =
new On4KstConnectionManager(controller);
managerReference.set(manager);
Logger logger = Logger.getLogger(
On4KstConnectionManager.class.getName());
Level previousLevel = logger.getLevel();
Handler probeHandler = probeHandler(probeEvents);
logger.setLevel(Level.INFO);
logger.addHandler(probeHandler);
try {
manager.start();
StateEvent online = awaitOnline(stateEvents);
System.out.println("[ON4KST live] Category " + LIVE_CATEGORY
+ " ONLINE at "
+ timestamp(online.at()));
reconnectObserved.set(false);
ProbeCycle successfulCycle = awaitCkOkCycle(
probeEvents, reconnectObserved);
System.out.println("[ON4KST live] CK sent at "
+ timestamp(successfulCycle.sentAt())
+ ", OK received at "
+ timestamp(successfulCycle.confirmedAt())
+ ", response time "
+ successfulCycle.responseMillis() + " ms");
reconnectObserved.set(false);
long observationDeadline = System.nanoTime()
+ POST_RESPONSE_OBSERVATION.toNanos();
while (System.nanoTime() < observationDeadline) {
if (reconnectObserved.get()) {
fail("ON4KST entered reconnect after the confirmed CK/OK cycle");
}
TimeUnit.SECONDS.sleep(1L);
}
System.out.println("[ON4KST live] Observation completed at "
+ timestamp(ZonedDateTime.now())
+ "; no reconnect followed the quiet CK/OK cycle");
} finally {
manager.stopByUser();
logger.removeHandler(probeHandler);
logger.setLevel(previousLevel);
}
}
private StateEvent awaitOnline(
LinkedBlockingQueue<StateEvent> stateEvents
) throws InterruptedException {
long deadline = System.nanoTime() + ONLINE_TIMEOUT.toNanos();
while (System.nanoTime() < deadline) {
StateEvent event = stateEvents.poll(1L, TimeUnit.SECONDS);
if (event == null) {
continue;
}
if (event.state() == On4KstConnectionState.ONLINE) {
return event;
}
if (event.state() == On4KstConnectionState.DISCONNECTED) {
fail("ON4KST live test disconnected before reaching ONLINE");
}
}
fail("ON4KST live test did not reach ONLINE within "
+ ONLINE_TIMEOUT.toSeconds() + " seconds");
throw new IllegalStateException("unreachable");
}
private ProbeCycle awaitCkOkCycle(
LinkedBlockingQueue<ProbeEvent> probeEvents,
AtomicBoolean reconnectObserved
) throws InterruptedException {
long deadline = System.nanoTime() + QUIET_PHASE_TIMEOUT.toNanos();
ProbeEvent sent = null;
while (System.nanoTime() < deadline) {
if (reconnectObserved.get()) {
fail("ON4KST reconnected before a CK/OK idle cycle was observed");
}
ProbeEvent event = probeEvents.poll(1L, TimeUnit.SECONDS);
if (event == null) {
continue;
}
if (event.type() == ProbeEventType.SENT) {
sent = event;
continue;
}
if (sent != null && event.sessionId() == sent.sessionId()) {
if ("OK".equals(event.opcode())) {
return new ProbeCycle(
sent.at(), event.at(), event.responseMillis());
}
// Normal server data ended this idle phase before OK arrived.
sent = null;
}
}
fail("No uninterrupted CK/OK idle cycle was observed within "
+ QUIET_PHASE_TIMEOUT.toMinutes() + " minutes");
throw new IllegalStateException("unreachable");
}
private Handler probeHandler(
LinkedBlockingQueue<ProbeEvent> probeEvents
) {
return new Handler() {
@Override
public void publish(LogRecord record) {
if (record == null || record.getParameters() == null) {
return;
}
Object[] parameters = record.getParameters();
if (record.getMessage().startsWith(
"Sending ON4KST client liveness probe")
&& parameters.length >= 1) {
probeEvents.offer(new ProbeEvent(
ProbeEventType.SENT,
((Number) parameters[0]).longValue(),
"CK",
-1L,
ZonedDateTime.now()));
} else if (record.getMessage().startsWith(
"ON4KST client liveness probe confirmed")
&& parameters.length >= 3) {
probeEvents.offer(new ProbeEvent(
ProbeEventType.CONFIRMED,
((Number) parameters[0]).longValue(),
String.valueOf(parameters[1]),
((Number) parameters[2]).longValue(),
ZonedDateTime.now()));
}
}
@Override
public void flush() {
// Nothing is buffered.
}
@Override
public void close() {
// The handler owns no external resource.
}
};
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private String timestamp(ZonedDateTime value) {
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value);
}
private record StateEvent(
On4KstConnectionState state,
ZonedDateTime at
) {
}
private enum ProbeEventType {
SENT,
CONFIRMED
}
private record ProbeEvent(
ProbeEventType type,
long sessionId,
String opcode,
long responseMillis,
ZonedDateTime at
) {
}
private record ProbeCycle(
ZonedDateTime sentAt,
ZonedDateTime confirmedAt,
long responseMillis
) {
}
}
@@ -1,425 +0,0 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Writer;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatPreferences;
/**
* Opt-in raw-wire probe matrix against ON4KST category 9.
*
* <p>Login traffic is deliberately excluded from the evidence file so that no
* credentials or login tokens are persisted. Capture begins after the initial
* category-9 user list has completed.</p>
*/
class On4KstProbeVariantPracticeTest {
private static final int CATEGORY = ChatCategory.VUHFR3;
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
private static final Duration HANDSHAKE_TIMEOUT = Duration.ofSeconds(45);
private static final Duration QUIET_PERIOD = Duration.ofSeconds(90);
private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(125);
private static final Duration VARIANT_TIMEOUT = Duration.ofMinutes(8);
private static final Duration TOTAL_TIMEOUT = Duration.ofMinutes(45);
private static final List<ProbeVariant> VARIANTS = List.of(
variant("CK_CRLF", "CK\r\n"),
variant("CK_PIPE_CRLF", "CK|\r\n"),
variant("CK_CR_NUL", "CK\r\0"),
variant("CK_PIPE_CR_NUL", "CK|\r\0"),
variant("PIPE_CK_PIPE_CRLF", "|CK|\r\n")
);
@Test
@Timeout(value = 47, unit = TimeUnit.MINUTES)
void recordsProbeVariantResponsesWithoutLoginData() throws Exception {
assumeTrue(Boolean.getBoolean("on4kst.live.variants"),
"Live probe matrix requires -Don4kst.live.variants=true");
ChatPreferences preferences = new ChatPreferences();
assumeTrue(preferences.readPreferencesFromXmlFile(),
"No readable local ON4KST test configuration");
assumeTrue(hasText(preferences.getStn_loginCallSign())
&& hasText(preferences.getStn_loginPassword()),
"Local ON4KST test credentials are incomplete");
Path evidenceDirectory = Path.of(
"target", "on4kst-live-evidence");
Files.createDirectories(evidenceDirectory);
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
.format(ZonedDateTime.now());
Path evidenceFile = evidenceDirectory.resolve(
timestamp + "-category-9-probe-matrix.log");
List<ProbeResult> results = new ArrayList<>();
long totalDeadline = System.nanoTime() + TOTAL_TIMEOUT.toNanos();
try (Writer evidence = Files.newBufferedWriter(
evidenceFile,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE)) {
writeHeader(evidence);
for (ProbeVariant variant : VARIANTS) {
if (System.nanoTime() >= totalDeadline) {
ProbeResult result = new ProbeResult(
variant.name(), "TOTAL_BUDGET_EXHAUSTED", "");
results.add(result);
writeResult(evidence, result);
continue;
}
ProbeResult result;
try {
result = runVariant(
preferences, variant, totalDeadline, evidence);
} catch (Exception exception) {
result = new ProbeResult(
variant.name(),
"ERROR",
exception.getClass().getSimpleName()
+ ": " + safeMessage(exception));
writeResult(evidence, result);
}
results.add(result);
evidence.flush();
}
evidence.write("\nSUMMARY\n");
for (ProbeResult result : results) {
writeResult(evidence, result);
}
}
System.out.println("[ON4KST variant test] Evidence: "
+ evidenceFile.toAbsolutePath());
assertTrue(
results.stream().anyMatch(result ->
"OK_DIRECT".equals(result.status())),
"No probe variant received a direct OK response. Evidence: "
+ evidenceFile.toAbsolutePath());
}
private ProbeResult runVariant(
ChatPreferences preferences,
ProbeVariant variant,
long totalDeadline,
Writer evidence
) throws Exception {
long variantDeadline = Math.min(
totalDeadline,
System.nanoTime() + VARIANT_TIMEOUT.toNanos());
evidence.write("\nVARIANT " + variant.name() + "\n");
evidence.write("probe hex=" + HexFormat.ofDelimiter(" ")
.withUpperCase().formatHex(variant.bytes())
+ " ascii=" + escapedAscii(variant.bytes()) + "\n");
try (Socket socket = new Socket()) {
socket.connect(
new InetSocketAddress(
preferences.getStn_on4kstServersDns(),
preferences.getStn_on4kstServersPort()),
Math.toIntExact(CONNECT_TIMEOUT.toMillis()));
socket.setSoTimeout(1_000);
InputStream input = socket.getInputStream();
OutputStream output = socket.getOutputStream();
WireReader reader = new WireReader(input);
completeHandshake(preferences, reader, output, variantDeadline);
writeEvent(evidence, "STATE", new byte[0],
"category 9 synchronized; login capture suppressed");
long lastInbound = System.nanoTime();
while (System.nanoTime() < variantDeadline) {
long quietDeadline = Math.min(
variantDeadline,
lastInbound + QUIET_PERIOD.toNanos());
byte[] inbound = reader.readFrame(quietDeadline);
if (inbound == null) {
if (System.nanoTime() >= variantDeadline) {
ProbeResult result = new ProbeResult(
variant.name(), "NO_QUIET_PHASE", "");
writeResult(evidence, result);
return result;
}
break;
}
writeEvent(evidence, "RX", inbound, "pre-probe");
lastInbound = System.nanoTime();
if ("CK".equals(On4KstProtocol.opcode(frameText(inbound)))) {
byte[] response = "\r\n".getBytes(StandardCharsets.US_ASCII);
output.write(response);
output.flush();
writeEvent(evidence, "TX", response,
"response to server-initiated CK");
}
}
output.write(variant.bytes());
output.flush();
writeEvent(evidence, "TX", variant.bytes(), "client probe");
long responseDeadline = Math.min(
variantDeadline,
System.nanoTime() + RESPONSE_TIMEOUT.toNanos());
boolean receivedOtherFrame = false;
while (System.nanoTime() < responseDeadline) {
byte[] inbound = reader.readFrame(responseDeadline);
if (inbound == null) {
break;
}
writeEvent(evidence, "RX", inbound, "post-probe");
String text = frameText(inbound);
if (On4KstProtocol.isClientLivenessProbeResponse(text)) {
String status = receivedOtherFrame
? "OK_AFTER_OTHER_DATA"
: "OK_DIRECT";
ProbeResult result = new ProbeResult(
variant.name(), status, escapedAscii(inbound));
writeResult(evidence, result);
return result;
}
receivedOtherFrame = true;
}
ProbeResult result = new ProbeResult(
variant.name(),
receivedOtherFrame ? "OTHER_DATA_ONLY" : "NO_RESPONSE",
"");
writeResult(evidence, result);
return result;
}
}
private void completeHandshake(
ChatPreferences preferences,
WireReader reader,
OutputStream output,
long variantDeadline
) throws Exception {
long handshakeDeadline = Math.min(
variantDeadline,
System.nanoTime() + HANDSHAKE_TIMEOUT.toNanos());
byte[] prompt = requireFrame(reader, handshakeDeadline,
"ON4KST login prompt");
if (!frameText(prompt).toLowerCase().contains("login")) {
throw new IOException("Unexpected ON4KST login prompt opcode: "
+ On4KstProtocol.opcode(frameText(prompt)));
}
String login = On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
CATEGORY,
"KST4Contest v"
+ ApplicationConstants.APPLICATION_CURRENT_VERSION,
0L);
writeCrLfFrame(output, login);
boolean loginAccepted = false;
while (!loginAccepted) {
byte[] inbound = requireFrame(reader, handshakeDeadline,
"ON4KST LOGSTAT");
String text = frameText(inbound);
if (!"LOGSTAT".equals(On4KstProtocol.opcode(text))) {
continue;
}
String[] fields = text.split("\\|", -1);
if (fields.length < 2 || !"100".equals(fields[1])) {
throw new IOException("ON4KST login rejected with code "
+ (fields.length < 2 ? "missing" : fields[1]));
}
loginAccepted = true;
}
writeCrLfFrame(output, On4KstProtocol.settingsDone(CATEGORY));
while (true) {
byte[] inbound = requireFrame(reader, handshakeDeadline,
"category-9 user-list completion");
String text = frameText(inbound);
if (text.startsWith("UE|" + CATEGORY + "|")) {
return;
}
}
}
private byte[] requireFrame(
WireReader reader,
long deadline,
String description
) throws IOException {
byte[] frame = reader.readFrame(deadline);
if (frame == null) {
throw new SocketTimeoutException(
"Timed out waiting for " + description);
}
return frame;
}
private void writeCrLfFrame(OutputStream output, String frame)
throws IOException {
output.write(frame.getBytes(StandardCharsets.US_ASCII));
output.write('\r');
output.write('\n');
output.flush();
}
private void writeHeader(Writer evidence) throws IOException {
evidence.write("ON4KST client-probe wire evidence\n");
evidence.write("started=" + DateTimeFormatter.ISO_OFFSET_DATE_TIME
.format(ZonedDateTime.now()) + "\n");
evidence.write("category=9\n");
evidence.write("login and initial synchronization frames are suppressed"
+ " to exclude credentials and login tokens\n");
evidence.write("wtKST reference: server CK frame="
+ "43 4B 7C 0D 0A (CK|<CR><LF>)\n");
evidence.write("wtKST reference: no client CK and no server OK occur"
+ " in wtkstcomm.c\n");
evidence.write("wtKST reference: repeated client idle payload="
+ "0D 00 0D 0A (<CR><NUL><CR><LF>)\n");
}
private void writeEvent(
Writer evidence,
String direction,
byte[] bytes,
String note
) throws IOException {
evidence.write(DateTimeFormatter.ISO_OFFSET_DATE_TIME
.format(ZonedDateTime.now()));
evidence.write(" " + direction);
if (bytes.length > 0) {
evidence.write(" hex=" + HexFormat.ofDelimiter(" ")
.withUpperCase().formatHex(bytes));
evidence.write(" ascii=" + escapedAscii(bytes));
}
if (hasText(note)) {
evidence.write(" note=" + note);
}
evidence.write("\n");
evidence.flush();
}
private void writeResult(Writer evidence, ProbeResult result)
throws IOException {
evidence.write("RESULT variant=" + result.variant()
+ " status=" + result.status());
if (hasText(result.detail())) {
evidence.write(" detail=" + result.detail());
}
evidence.write("\n");
}
private static ProbeVariant variant(String name, String wireText) {
return new ProbeVariant(
name, wireText.getBytes(StandardCharsets.US_ASCII));
}
private String frameText(byte[] frame) {
int length = frame.length;
while (length > 0 && (frame[length - 1] == '\r'
|| frame[length - 1] == '\n'
|| frame[length - 1] == 0)) {
length--;
}
return new String(frame, 0, length, StandardCharsets.US_ASCII);
}
private String escapedAscii(byte[] bytes) {
StringBuilder escaped = new StringBuilder();
for (byte value : bytes) {
int unsigned = Byte.toUnsignedInt(value);
switch (unsigned) {
case 0 -> escaped.append("<NUL>");
case '\r' -> escaped.append("<CR>");
case '\n' -> escaped.append("<LF>");
default -> {
if (unsigned >= 0x20 && unsigned <= 0x7e) {
escaped.append((char) unsigned);
} else {
escaped.append(String.format("<%02X>", unsigned));
}
}
}
}
return escaped.toString();
}
private String safeMessage(Exception exception) {
String message = exception.getMessage();
return message == null || message.isBlank()
? "no detail" : message.replaceAll("[\\r\\n]+", " ");
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private record ProbeVariant(String name, byte[] bytes) {
private ProbeVariant {
bytes = bytes.clone();
}
@Override
public byte[] bytes() {
return bytes.clone();
}
}
private record ProbeResult(String variant, String status, String detail) {
}
private static final class WireReader {
private final InputStream input;
private WireReader(InputStream input) {
this.input = input;
}
private byte[] readFrame(long deadlineNanos) throws IOException {
ByteArrayOutputStream frame = new ByteArrayOutputStream();
while (System.nanoTime() < deadlineNanos) {
try {
int value = input.read();
if (value < 0) {
throw new IOException("ON4KST closed the TCP session");
}
frame.write(value);
if (value == '\n' || value == 0) {
return frame.toByteArray();
}
} catch (SocketTimeoutException timeout) {
// Continue until the caller's monotonic deadline expires.
}
}
return frame.size() == 0 ? null : frame.toByteArray();
}
}
}
@@ -0,0 +1,177 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.model.OperatorProfile;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OperatorProfileManagementServiceTest {
private static final String USER_HOME_PROPERTY = "user.home";
@TempDir
Path temporaryHomeDirectory;
private String originalUserHome;
private OperatorProfileManagementService managementService;
@BeforeEach
void redirectUserHomeToTemporaryDirectory() {
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
managementService = new OperatorProfileManagementService();
}
@AfterEach
void restoreUserHome() {
if (originalUserHome == null) {
System.clearProperty(USER_HOME_PROPERTY);
} else {
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
}
}
@Test
void aPlainInstallationReportsExactlyOneImplicitRootProfile() {
List<OperatorProfile> knownProfiles = managementService.listProfiles();
assertEquals(1, knownProfiles.size());
assertTrue(knownProfiles.get(0).isRootProfile());
assertFalse(Files.exists(applicationFile("profiles.xml")),
"Merely listing profiles must not create a registry");
}
@Test
void creatingTheSecondProfileMaterialisesTheRegistryIncludingTheRootProfile() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
assertNotNull(createdProfile);
assertEquals("DN9APW", createdProfile.getProfileId());
assertTrue(Files.exists(applicationFile("profiles.xml")));
List<OperatorProfile> knownProfiles = managementService.listProfiles();
assertEquals(2, knownProfiles.size());
assertTrue(knownProfiles.get(0).isRootProfile());
assertEquals("DN9APW", knownProfiles.get(1).getProfileId());
assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml")));
// The historic files must stay exactly where an older release expects them.
assertFalse(Files.exists(applicationFile("profiles/default")));
}
@Test
void aNewProfileStartsWithoutLoginCredentials() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
ChatPreferences createdPreferences =
preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(createdProfile));
assertEquals("", createdPreferences.getStn_loginCallSign());
assertEquals("", createdPreferences.getStn_loginPassword());
}
@Test
void duplicatingKeepsTheStationSetupButClearsCallsignAndPassword() {
OperatorProfile sourceProfile = managementService.createProfile("Source", false);
ChatPreferences sourcePreferences =
preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(sourceProfile));
sourcePreferences.setStn_loginCallSign("DM5M");
sourcePreferences.setStn_loginPassword("secret");
sourcePreferences.setStn_loginLocatorMainCat("JO51IJ");
sourcePreferences.setStn_antennaBeamWidthDeg(17.5);
assertTrue(sourcePreferences.writePreferencesToXmlFile());
OperatorProfile duplicatedProfile =
managementService.duplicateProfile(sourceProfile, "Copy of source");
assertNotNull(duplicatedProfile);
ChatPreferences duplicatedPreferences =
preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(duplicatedProfile));
// The work worth keeping.
assertEquals("JO51IJ", duplicatedPreferences.getStn_loginLocatorMainCat());
assertEquals(17.5, duplicatedPreferences.getStn_antennaBeamWidthDeg());
// The identity that must not be inherited.
assertEquals("", duplicatedPreferences.getStn_loginCallSign());
assertEquals("", duplicatedPreferences.getStn_loginPassword());
}
@Test
void switchingBetweenSharedAndOwnWorkedDataChangesOnlyTheDatabasePath() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
assertEquals("profiles/DN9APW/praktiKST.db",
OperatorProfilePaths.workedDatabaseRelativeFileName(createdProfile));
assertTrue(managementService.setSharedWorkedDatabase(createdProfile, true));
OperatorProfile reloadedProfile = managementService.listProfiles().get(1);
assertTrue(reloadedProfile.isSharedWorkedDatabase());
assertEquals("praktiKST.db",
OperatorProfilePaths.workedDatabaseRelativeFileName(reloadedProfile));
assertEquals("profiles/DN9APW/preferences.xml",
OperatorProfilePaths.preferencesRelativeFileName(reloadedProfile));
}
@Test
void deletingRemovesTheProfileDirectoryButNeverTheRootProfile() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml")));
OperatorProfile rootProfile = managementService.listProfiles().get(0);
assertFalse(managementService.deleteProfile(rootProfile),
"The root profile is the installation itself and must not be removable");
assertTrue(managementService.deleteProfile(createdProfile));
assertFalse(Files.exists(applicationFile("profiles/DN9APW")));
assertEquals(1, managementService.listProfiles().size());
}
@Test
void renamingKeepsTheIdentifierAndTherebyAllPaths() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
assertTrue(managementService.renameProfile(createdProfile, "Philipp portable"));
OperatorProfile renamedProfile = managementService.listProfiles().get(1);
assertEquals("Philipp portable", renamedProfile.getDisplayName());
assertEquals("DN9APW", renamedProfile.getProfileId());
assertEquals("profiles/DN9APW/preferences.xml",
OperatorProfilePaths.preferencesRelativeFileName(renamedProfile));
}
private ChatPreferences preferencesAt(final String relativeFileName) {
ChatPreferences preferences = new ChatPreferences(relativeFileName);
preferences.readPreferencesFromXmlFile();
return preferences;
}
private Path applicationFile(final String relativeFileName) {
return temporaryHomeDirectory.resolve(".praktiKST").resolve(relativeFileName);
}
}
@@ -0,0 +1,81 @@
package kst4contest.controller;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OperatorProfilePathsTest {
@Test
void rootProfileKeepsTheHistoricFlatFileNames() {
OperatorProfileSelection resolved =
OperatorProfilePaths.resolve(OperatorProfilePaths.buildRootProfile("Default"));
// This is the downgrade guard: an older KST4Contest release reads exactly these
// two files. If this test ever fails, existing installations would silently lose
// their configuration and worked data when the operator reverts a version.
assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName());
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
assertTrue(resolved.isSeedWorkedDatabaseFromResource());
}
@Test
void additionalProfileWithSharedDatabaseUsesItsOwnPreferencesButTheStationDatabase() {
OperatorProfile sharedProfile = new OperatorProfile("OP2", "DN9APW", false, true);
OperatorProfileSelection resolved = OperatorProfilePaths.resolve(sharedProfile);
assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName());
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
assertTrue(resolved.isSeedWorkedDatabaseFromResource());
}
@Test
void additionalProfileWithOwnDatabaseIsFullySeparatedAndNotSeeded() {
OperatorProfile ownDatabaseProfile = new OperatorProfile("OP2", "DN9APW", false, false);
OperatorProfileSelection resolved = OperatorProfilePaths.resolve(ownDatabaseProfile);
assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName());
assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
// Seeding would hand a new operator the several thousand callsigns of the
// bundled template database.
assertFalse(resolved.isSeedWorkedDatabaseFromResource());
}
@Test
void profileIdIsFileSystemSafe() {
assertEquals("DN9APW", OperatorProfilePaths.toProfileId("dn9apw", Set.of()));
assertEquals("DM5M_CONTEST", OperatorProfilePaths.toProfileId("DM5M Contest", Set.of()));
assertEquals("A_B", OperatorProfilePaths.toProfileId("a/../b", Set.of()));
assertEquals("MULLER", OperatorProfilePaths.toProfileId("Müller", Set.of()));
assertEquals("OP", OperatorProfilePaths.toProfileId(" ", Set.of()));
assertEquals("OP", OperatorProfilePaths.toProfileId(null, Set.of()));
String longName = "A".repeat(60);
assertEquals(32, OperatorProfilePaths.toProfileId(longName, Set.of()).length());
}
@Test
void profileIdNeverCollidesAndNeverClaimsTheRootIdentifier() {
assertEquals("DN9APW_2", OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW")));
assertEquals("DN9APW_3",
OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW", "DN9APW_2")));
// "default" is reserved for the historic flat installation.
assertNotEquals(OperatorProfilePaths.ROOT_PROFILE_ID,
OperatorProfilePaths.toProfileId("default", Set.of()));
}
}
@@ -0,0 +1,117 @@
package kst4contest.controller;
import kst4contest.model.OperatorProfile;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OperatorProfileStoreTest {
@TempDir
Path temporaryDirectory;
@Test
void missingRegistryIsNotAnErrorAndIsNotCreated() {
OperatorProfileStore store = storeAt("profiles.xml");
assertFalse(store.isRegistryPresent());
assertTrue(store.loadProfiles().isEmpty());
assertEquals(Optional.empty(), store.loadLastUsedProfileId());
// A single operator installation must stay untouched by merely starting up.
assertFalse(Files.exists(temporaryDirectory.resolve("profiles.xml")));
}
@Test
void profilesSurviveAWriteReadRoundTrip() {
OperatorProfileStore store = storeAt("profiles.xml");
OperatorProfile rootProfile = OperatorProfilePaths.buildRootProfile("DM5M station");
OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false);
secondProfile.setLastUsedEpochMs(1757328000000L);
assertTrue(store.saveProfiles(List.of(rootProfile, secondProfile), "OP2"));
assertTrue(store.isRegistryPresent());
List<OperatorProfile> restored = store.loadProfiles();
assertEquals(2, restored.size());
assertEquals("default", restored.get(0).getProfileId());
assertEquals("DM5M station", restored.get(0).getDisplayName());
assertTrue(restored.get(0).isRootProfile());
assertTrue(restored.get(0).isSharedWorkedDatabase());
assertEquals("OP2", restored.get(1).getProfileId());
assertEquals("DN9APW", restored.get(1).getDisplayName());
assertFalse(restored.get(1).isRootProfile());
assertFalse(restored.get(1).isSharedWorkedDatabase());
assertEquals(1757328000000L, restored.get(1).getLastUsedEpochMs());
assertEquals(Optional.of("OP2"), store.loadLastUsedProfileId());
}
@Test
void atomicWriteLeavesNoTemporaryFileBehind() throws IOException {
OperatorProfileStore store = storeAt("profiles.xml");
store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default");
try (var directoryEntries = Files.list(temporaryDirectory)) {
assertTrue(directoryEntries.noneMatch(entry -> entry.getFileName().toString().endsWith(".tmp")));
}
}
@Test
void malformedRegistryFallsBackToNoAdditionalProfiles() throws IOException {
Path registryFile = temporaryDirectory.resolve("profiles.xml");
Files.writeString(registryFile, "<praktiKSTProfiles><profile><profileId>OP2");
OperatorProfileStore store = storeAt("profiles.xml");
assertTrue(store.isRegistryPresent());
assertTrue(store.loadProfiles().isEmpty());
assertEquals(Optional.empty(), store.loadLastUsedProfileId());
}
@Test
void entriesWithoutAnIdentifierAreSkippedInsteadOfBreakingTheRegistry() throws IOException {
Path registryFile = temporaryDirectory.resolve("profiles.xml");
Files.writeString(registryFile,
"<praktiKSTProfiles>"
+ "<profile><displayName>broken</displayName></profile>"
+ "<profile><profileId>OP2</profileId><displayName>DN9APW</displayName>"
+ "<sharedWorkedDatabase>false</sharedWorkedDatabase></profile>"
+ "</praktiKSTProfiles>");
List<OperatorProfile> restored = storeAt("profiles.xml").loadProfiles();
assertEquals(1, restored.size());
assertEquals("OP2", restored.get(0).getProfileId());
}
@Test
void recordLastUsedDoesNothingWhenNoRegistryExists() {
OperatorProfileStore store = storeAt("profiles.xml");
assertFalse(store.recordLastUsed("default"));
assertFalse(store.isRegistryPresent());
}
private OperatorProfileStore storeAt(final String fileName) {
return new OperatorProfileStore(temporaryDirectory.resolve(fileName).toString());
}
}
@@ -0,0 +1,46 @@
package kst4contest.view;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class CommandLineOptionsTest {
@Test
void profileArgumentIsAcceptedInBothSpellings() {
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile=OP2")).getRequestedProfileName());
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile", "OP2")).getRequestedProfileName());
assertEquals("DM5M Contest",
CommandLineOptions.parse(List.of("--profile", "DM5M Contest")).getRequestedProfileName());
}
@Test
void missingOrEmptyProfileArgumentsAreTreatedAsAbsent() {
assertNull(CommandLineOptions.parse(List.of()).getRequestedProfileName());
assertNull(CommandLineOptions.parse(null).getRequestedProfileName());
assertNull(CommandLineOptions.parse(List.of("--profile=")).getRequestedProfileName());
assertNull(CommandLineOptions.parse(List.of("--profile")).getRequestedProfileName());
}
@Test
void unrelatedArgumentsAreIgnoredInsteadOfFailing() {
assertEquals("OP2",
CommandLineOptions.parse(List.of("--verbose", "--profile=OP2", "somefile.adi"))
.getRequestedProfileName());
assertNull(CommandLineOptions.parse(List.of("--verbose", "-x")).getRequestedProfileName());
}
@Test
void rememberedOptionsDefaultToEmptyInsteadOfNull() {
CommandLineOptions.remember(null);
assertNull(CommandLineOptions.remembered().getRequestedProfileName());
CommandLineOptions.remember(new CommandLineOptions("OP2"));
assertEquals("OP2", CommandLineOptions.remembered().getRequestedProfileName());
CommandLineOptions.remember(null);
}
}
@@ -0,0 +1,161 @@
package kst4contest.view;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OperatorProfileBootstrapTest {
@TempDir
Path temporaryDirectory;
@Test
void installationWithoutRegistryStartsSilentlyOnTheHistoricLayout() {
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileStore store = storeAt();
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
assertNotNull(resolved);
assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName());
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
// Nothing may be asked, and nothing may be written.
assertEquals(0, pickerInvocations.get());
assertTrue(store.loadProfiles().isEmpty());
}
@Test
void singleProfileStartsWithoutAskingAnything() {
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileStore store = storeAt();
store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default");
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
assertNotNull(resolved);
assertEquals(0, pickerInvocations.get());
}
@Test
void twoProfilesAskTheOperatorAndPreselectTheLastUsedOne() {
OperatorProfileStore store = storeAt();
OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false);
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"), secondProfile), "OP2");
AtomicInteger pickerInvocations = new AtomicInteger();
String[] observedPreselection = new String[1];
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store,
new CommandLineOptions(null),
(profiles, preselectedProfileId) -> {
pickerInvocations.incrementAndGet();
observedPreselection[0] = preselectedProfileId;
return Optional.of(profiles.get(1));
});
assertEquals(1, pickerInvocations.get());
assertEquals("OP2", observedPreselection[0]);
assertNotNull(resolved);
assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
}
@Test
void aValidProfileArgumentSkipsThePicker() {
OperatorProfileStore store = storeAt();
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"),
new OperatorProfile("OP2", "DN9APW", false, false)),
"default");
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
OperatorProfileSelection byId = bootstrap.resolveAtStartup(
store, new CommandLineOptions("op2"), countingPicker(pickerInvocations, null));
assertEquals(0, pickerInvocations.get());
assertEquals("profiles/OP2/preferences.xml", byId.getPreferencesRelativeFileName());
assertNull(bootstrap.getStartupWarning());
OperatorProfileSelection byDisplayName = bootstrap.resolveAtStartup(
store, new CommandLineOptions("DN9APW"), countingPicker(pickerInvocations, null));
assertEquals(0, pickerInvocations.get());
assertEquals("profiles/OP2/preferences.xml", byDisplayName.getPreferencesRelativeFileName());
}
@Test
void anUnknownProfileArgumentWarnsAndFallsBackToTheNormalSelection() {
OperatorProfileStore store = storeAt();
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"),
new OperatorProfile("OP2", "DN9APW", false, false)),
"default");
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
OperatorProfileSelection resolved = bootstrap.resolveAtStartup(
store, new CommandLineOptions("NOPE"), countingPicker(pickerInvocations, 0));
assertEquals(1, pickerInvocations.get());
assertNotNull(resolved);
assertNotNull(bootstrap.getStartupWarning());
assertTrue(bootstrap.getStartupWarning().contains("NOPE"));
}
@Test
void quittingInThePickerYieldsNoSelection() {
OperatorProfileStore store = storeAt();
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"),
new OperatorProfile("OP2", "DN9APW", false, false)),
"default");
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store, new CommandLineOptions(null), (profiles, preselected) -> Optional.empty());
assertNull(resolved);
}
private OperatorProfileChoiceRequester countingPicker(final AtomicInteger invocationCounter,
final Integer profileIndexToChoose) {
return (profiles, preselectedProfileId) -> {
invocationCounter.incrementAndGet();
if (profileIndexToChoose == null) {
return Optional.of(profiles.get(0));
}
return Optional.of(profiles.get(profileIndexToChoose));
};
}
private OperatorProfileStore storeAt() {
return new OperatorProfileStore(temporaryDirectory.resolve("profiles.xml").toString());
}
}
@@ -0,0 +1,111 @@
package kst4contest.view.map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Guards the property that made the terrain cache useless with several operator
* profiles: it used to drop every cached profile whenever the configured callsign or
* locator changed.
*/
class TerrainProfileCacheRepositoryTest {
private static final String USER_HOME_PROPERTY = "user.home";
private static final String PROVIDER_ID = "test-provider";
private static final int SAMPLE_COUNT = 3;
@TempDir
Path temporaryHomeDirectory;
private String originalUserHome;
@BeforeEach
void redirectUserHomeToTemporaryDirectory() {
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
}
@AfterEach
void restoreUserHome() {
if (originalUserHome == null) {
System.clearProperty(USER_HOME_PROPERTY);
} else {
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
}
}
@Test
void profilesOfDifferentOwnersCoexistAndSurviveSwitchingBackAndForth() {
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
// Another operator profile with a different callsign and locator.
repository.save("DN9APW", "JN59AA", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-b"));
Optional<TerrainProfileData> firstOwnerEntry = repository.load(
"DM5M", "JO51IJ", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
Optional<TerrainProfileData> secondOwnerEntry = repository.load(
"DN9APW", "JN59AA", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
assertTrue(firstOwnerEntry.isPresent(),
"Working under a second operator identity must not discard the first one's cache");
assertTrue(secondOwnerEntry.isPresent());
assertEquals("station-a", firstOwnerEntry.get().sourceName());
assertEquals("station-b", secondOwnerEntry.get().sourceName());
}
@Test
void anUnknownOwnerSimplyMissesTheCacheInsteadOfClearingIt() {
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
assertTrue(repository.load("DL0XYZ", "JO60AA", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID).isEmpty());
assertTrue(repository.load("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID).isPresent(),
"A cache miss of one owner must not remove the entries of another");
}
@Test
void theCacheLivesInItsOwnGlobalFileAndNotInTheWorkedStationDatabase() {
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
Path applicationDirectory = temporaryHomeDirectory.resolve(".praktiKST");
assertTrue(Files.exists(applicationDirectory.resolve("terrainprofilecache.db")));
assertFalse(Files.exists(applicationDirectory.resolve("praktiKST.db")),
"The terrain cache must not pull in the worked station database");
}
private static TerrainProfileData profileData(final String sourceName) {
return new TerrainProfileData(
List.of(
new PathProfilePoint(0.0, 51.0, 10.0, 100.0),
new PathProfilePoint(10.0, 51.1, 10.1, 220.0),
new PathProfilePoint(20.0, 51.2, 10.2, 150.0)),
sourceName,
false);
}
}
-14
View File
@@ -73,12 +73,6 @@ function getManualPageOrder(lang, slug) {
return index >= 0 ? index : 999;
}
function selectLatestNews(newsItems) {
return [...(newsItems || [])]
.sort((first, second) => second.date - first.date)
.slice(0, 1);
}
function githubCompatibleSlug(value) {
return (value || "")
.trim()
@@ -158,12 +152,6 @@ module.exports = function (eleventyConfig) {
});
});
eleventyConfig.addCollection("latestNews", function (collectionApi) {
return selectLatestNews(
collectionApi.getFilteredByTag("news")
);
});
eleventyConfig.addFilter("whereTag", function(collection, tag) {
return collection.filter(item => item.data.tags && item.data.tags.includes(tag));
});
@@ -243,5 +231,3 @@ module.exports = function (eleventyConfig) {
htmlTemplateEngine: "njk"
};
};
module.exports.selectLatestNews = selectLatestNews;
+1 -10
View File
@@ -48,13 +48,4 @@ Nginx serves this directory.
## Build Strategy
The production server keeps a checkout at `/srv/git/kst4contest`. A root cron
job runs `/srv/scripts/deploy-kst4contest-website.sh` every five minutes. The
script resets the checkout to `origin/main`, installs the locked npm
dependencies, builds and validates the Eleventy site, and synchronises the
result to `/srv/www/kst4contest/current` with the ownership required by Nginx.
This deploy path updates only the static website. Server-side analytics,
Nginx, systemd, Logrotate, GeoIP, Basic Auth and Certbot configuration are
installed and maintained separately as described in
[`ops/analytics/README.md`](ops/analytics/README.md).
GitHub Actions will later build the 11ty website and deploy the generated output to the server.
File diff suppressed because it is too large Load Diff
-879
View File
@@ -1,879 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const STATE_SCHEMA_VERSION = 1;
const PUBLIC_SCHEMA_VERSION = 1;
const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
const RESERVED_STATS_HOST = "stats.hamradioonline.de";
class ConfigurationError extends Error {
constructor(message) {
super(message);
this.name = "ConfigurationError";
this.exitCode = 2;
}
}
class LockError extends Error {
constructor(message) {
super(message);
this.name = "LockError";
this.exitCode = 3;
}
}
function readJson(filePath, label) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch (error) {
throw new ConfigurationError(`${label} is not valid JSON: ${error.message}`);
}
return parsed;
}
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
return null;
}
return `${match[1]}-${match[2]}-${match[3]}`;
}
function requireAbsolutePath(value, label) {
if (typeof value !== "string" || /[\r\n\0]/.test(value) || !path.isAbsolute(value)) {
throw new ConfigurationError(`${label} must be an absolute path`);
}
return path.normalize(value);
}
function isWithin(parent, candidate) {
const relative = path.relative(parent, candidate);
return relative !== ""
&& relative !== ".."
&& !relative.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relative);
}
function validateRegistry(registry) {
if (!registry || typeof registry !== "object" || Array.isArray(registry)) {
throw new ConfigurationError("registry must be an object");
}
if (registry.schemaVersion !== 1) {
throw new ConfigurationError("registry schemaVersion must be 1");
}
if (!Array.isArray(registry.sites) || registry.sites.length === 0) {
throw new ConfigurationError("registry.sites must contain at least one site");
}
const stateDirectory = requireAbsolutePath(
registry.stateDirectory,
"registry.stateDirectory"
);
const counterStatePath = requireAbsolutePath(
registry.counterStatePath,
"registry.counterStatePath"
);
if (!isWithin(stateDirectory, counterStatePath)) {
throw new ConfigurationError("counterStatePath must be below stateDirectory");
}
const ids = new Set();
const hostnames = new Set();
const analyticsLogPaths = new Set();
const outputDirectories = new Set();
const publicJsonPaths = new Set();
const sites = registry.sites.map((site, index) => {
const label = `registry.sites[${index}]`;
if (!site || typeof site !== "object" || Array.isArray(site)) {
throw new ConfigurationError(`${label} must be an object`);
}
if (typeof site.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(site.id)) {
throw new ConfigurationError(`${label}.id is invalid`);
}
if (site.id === "combined") {
throw new ConfigurationError(`${label}.id is reserved`);
}
if (ids.has(site.id)) {
throw new ConfigurationError(`duplicate site id: ${site.id}`);
}
ids.add(site.id);
if (typeof site.hostname !== "string"
|| !/^[a-z0-9.-]+$/.test(site.hostname)
|| site.hostname.includes("..")) {
throw new ConfigurationError(`${label}.hostname is invalid`);
}
const hostname = site.hostname.toLowerCase();
if (hostname === RESERVED_STATS_HOST) {
throw new ConfigurationError(`${RESERVED_STATS_HOST} must not be registered`);
}
if (hostnames.has(hostname)) {
throw new ConfigurationError(`duplicate hostname: ${hostname}`);
}
hostnames.add(hostname);
const activatedOn = parseIsoDate(site.activatedOn);
if (!activatedOn) {
throw new ConfigurationError(`${label}.activatedOn must be a valid ISO date`);
}
if (typeof site.publicCounter !== "boolean") {
throw new ConfigurationError(`${label}.publicCounter must be boolean`);
}
const analyticsLog = requireAbsolutePath(
site.analyticsLog,
`${label}.analyticsLog`
);
if (/\.(?:\d+|gz)$/i.test(path.basename(analyticsLog))) {
throw new ConfigurationError(
`${label}.analyticsLog must identify the current uncompressed log`
);
}
if (analyticsLogPaths.has(analyticsLog)) {
throw new ConfigurationError(`analytics log is registered more than once: ${analyticsLog}`);
}
analyticsLogPaths.add(analyticsLog);
const reportOutputDirectory = requireAbsolutePath(
site.reportOutputDirectory,
`${label}.reportOutputDirectory`
);
if (!isWithin(stateDirectory, reportOutputDirectory)) {
throw new ConfigurationError(`${label}.reportOutputDirectory must be below stateDirectory`);
}
if (outputDirectories.has(reportOutputDirectory)) {
throw new ConfigurationError(`duplicate report output directory: ${reportOutputDirectory}`);
}
outputDirectories.add(reportOutputDirectory);
const publicJsonPath = site.publicCounter
? requireAbsolutePath(site.publicJsonPath, `${label}.publicJsonPath`)
: null;
if (publicJsonPath && !isWithin(stateDirectory, publicJsonPath)) {
throw new ConfigurationError(`${label}.publicJsonPath must be below stateDirectory`);
}
if (publicJsonPath === counterStatePath) {
throw new ConfigurationError(`${label}.publicJsonPath conflicts with counterStatePath`);
}
if (publicJsonPath && publicJsonPaths.has(publicJsonPath)) {
throw new ConfigurationError(`duplicate public JSON path: ${publicJsonPath}`);
}
if (publicJsonPath) {
publicJsonPaths.add(publicJsonPath);
}
return {
id: site.id,
hostname,
activatedOn,
publicCounter: site.publicCounter,
analyticsLog,
reportOutputDirectory,
publicJsonPath
};
});
if (!registry.combined || typeof registry.combined !== "object") {
throw new ConfigurationError("registry.combined must be an object");
}
const combinedReportOutputDirectory = requireAbsolutePath(
registry.combined.reportOutputDirectory,
"registry.combined.reportOutputDirectory"
);
if (!isWithin(stateDirectory, combinedReportOutputDirectory)) {
throw new ConfigurationError(
"registry.combined.reportOutputDirectory must be below stateDirectory"
);
}
if (outputDirectories.has(combinedReportOutputDirectory)) {
throw new ConfigurationError(
`duplicate report output directory: ${combinedReportOutputDirectory}`
);
}
return {
schemaVersion: 1,
stateDirectory,
counterStatePath,
lockFile: requireAbsolutePath(registry.lockFile, "registry.lockFile"),
geoIpCountryDatabase: requireAbsolutePath(
registry.geoIpCountryDatabase,
"registry.geoIpCountryDatabase"
),
combined: {
reportOutputDirectory: combinedReportOutputDirectory
},
sites
};
}
function resolveAnalyticsLogs(site) {
const logs = [];
const candidates = [
{ path: `${site.analyticsLog}.1`, required: false },
{ path: site.analyticsLog, required: true }
];
for (const candidate of candidates) {
let stats;
try {
stats = fs.statSync(candidate.path);
} catch (error) {
if (!candidate.required && error.code === "ENOENT") {
continue;
}
throw new ConfigurationError(`analytics log is not readable: ${candidate.path}`);
}
if (!stats.isFile()) {
throw new ConfigurationError(`analytics log is not a file: ${candidate.path}`);
}
try {
fs.accessSync(candidate.path, fs.constants.R_OK);
} catch (error) {
throw new ConfigurationError(
`analytics log is not readable by the current user: ${candidate.path}`
);
}
logs.push(candidate.path);
}
return logs;
}
function requireWritableDirectory(directory, label) {
let stats;
try {
stats = fs.statSync(directory);
} catch (error) {
throw new ConfigurationError(`${label} does not exist: ${directory}`);
}
if (!stats.isDirectory()) {
throw new ConfigurationError(`${label} is not a directory: ${directory}`);
}
try {
fs.accessSync(directory, fs.constants.W_OK | fs.constants.X_OK);
} catch (error) {
throw new ConfigurationError(
`${label} is not writable by the current user: ${directory}`
);
}
}
function validateInputs(registry, configTemplate) {
const requiredConfigParts = [
"{{DB_PATH}}",
"{{RESTORE_DIRECTIVE}}",
"{{GEOIP_COUNTRY_DATABASE}}",
"persist true",
"anonymize-ip true",
"ignore-crawlers true",
"unknowns-as-crawlers true",
"keep-last 395"
];
for (const required of requiredConfigParts) {
if (!configTemplate.includes(required)) {
throw new ConfigurationError(`GoAccess template is missing: ${required}`);
}
}
const analyticsLogsBySite = new Map(registry.sites.map(site => [
site.id,
resolveAnalyticsLogs(site)
]));
let geoStats;
try {
geoStats = fs.statSync(registry.geoIpCountryDatabase);
} catch (error) {
throw new ConfigurationError(
`GeoIP Country database is not readable: ${registry.geoIpCountryDatabase}`
);
}
if (!geoStats.isFile() || /city/i.test(path.basename(registry.geoIpCountryDatabase))) {
throw new ConfigurationError("geoIpCountryDatabase must be a Country database file");
}
try {
fs.accessSync(registry.geoIpCountryDatabase, fs.constants.R_OK);
} catch (error) {
throw new ConfigurationError(
`GeoIP Country database is not readable by the current user: `
+ registry.geoIpCountryDatabase
);
}
requireWritableDirectory(registry.stateDirectory, "stateDirectory");
requireWritableDirectory(
registry.combined.reportOutputDirectory,
"combined report output directory"
);
for (const site of registry.sites) {
requireWritableDirectory(
site.reportOutputDirectory,
`report output directory for ${site.id}`
);
if (site.publicCounter) {
requireWritableDirectory(
path.dirname(site.publicJsonPath),
`public output directory for ${site.id}`
);
}
}
return analyticsLogsBySite;
}
function renderGoAccessConfig(template, dbPath, restore, geoIpCountryDatabase) {
const rendered = template
.replaceAll("{{DB_PATH}}", dbPath)
.replaceAll(
"{{RESTORE_DIRECTIVE}}",
restore ? "restore true" : "# restore is disabled until a database exists"
)
.replaceAll("{{GEOIP_COUNTRY_DATABASE}}", geoIpCountryDatabase);
if (/{{[A-Z_]+}}/.test(rendered)) {
throw new ConfigurationError("GoAccess template contains an unknown placeholder");
}
return rendered;
}
function defaultRunGoAccess({ binary, args }) {
const result = spawnSync(binary, args, {
encoding: "utf8",
maxBuffer: 1024 * 1024,
timeout: 30 * 60 * 1000,
windowsHide: true
});
if (result.error) {
throw new Error(`could not start GoAccess: ${result.error.message}`);
}
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || "no diagnostic output").trim();
throw new Error(`GoAccess failed with exit code ${result.status}: ${detail}`);
}
}
function defaultCheckGoAccess(binary) {
const result = spawnSync(binary, ["--version"], {
encoding: "utf8",
timeout: 10000,
windowsHide: true
});
if (result.error || result.status !== 0) {
const detail = result.error
? result.error.message
: (result.stderr || result.stdout || "no diagnostic output").trim();
throw new ConfigurationError(`GoAccess is not available: ${detail}`);
}
return parseGoAccessVersion(`${result.stdout || ""}\n${result.stderr || ""}`);
}
function parseGoAccessVersion(output) {
const versionMatch = /GoAccess\s+-\s+([0-9]+(?:\.[0-9]+)+)/i.exec(output);
return {
version: versionMatch ? versionMatch[1] : "unknown",
geoIpMmdb: /--enable-geoip=mmdb\b/i.test(output),
openSsl: /--with-openssl\b/i.test(output),
zlib: /--with-zlib\b/i.test(output)
};
}
function formatGoAccessCheck(capabilities) {
const detected = [
`GeoIP2/MMDB ${capabilities.geoIpMmdb ? "enabled" : "not detected"}`,
`OpenSSL ${capabilities.openSsl ? "enabled" : "not detected"}`,
`Zlib ${capabilities.zlib ? "enabled" : "not detected"}`
].join(", ");
const summary = `GoAccess ${capabilities.version}: ${detected}.`;
if (capabilities.zlib) {
return summary;
}
return `${summary} This build has no Zlib support. That is valid for regular mode: `
+ "the current analytics log and its optional uncompressed .1 rotation are read "
+ "directly; older .gz logs are not imported.";
}
function parseGoAccessDate(value) {
if (typeof value !== "string") {
return null;
}
if (parseIsoDate(value)) {
return value;
}
if (/^\d{8}$/.test(value)) {
return parseIsoDate(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`);
}
const match = /^(\d{2})\/([A-Za-z]{3})\/(\d{4})$/.exec(value);
const months = {
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12"
};
return match && months[match[2]]
? parseIsoDate(`${match[3]}-${months[match[2]]}-${match[1]}`)
: null;
}
function validateReport(report, combined) {
if (!report || typeof report !== "object" || Array.isArray(report)) {
throw new Error("GoAccess JSON report must be an object");
}
const requiredPanels = ["visitors", "requests", "status_codes", "geolocation"];
if (combined) {
requiredPanels.push("vhosts");
}
if (!report.general || typeof report.general !== "object") {
throw new Error("GoAccess JSON report has no general summary");
}
for (const panel of requiredPanels) {
if (!report[panel] || !Array.isArray(report[panel].data)) {
throw new Error(`GoAccess JSON report has no ${panel} panel`);
}
}
extractDailyVisits(report);
return report;
}
function readAndValidateReport(jsonPath, htmlPath, combined) {
let report;
try {
report = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
} catch (error) {
throw new Error(`GoAccess JSON output is invalid: ${error.message}`);
}
validateReport(report, combined);
const html = fs.readFileSync(htmlPath, "utf8");
if (html.length < 100 || !/<html(?:\s|>)/i.test(html)) {
throw new Error("GoAccess HTML output is missing or implausibly small");
}
return report;
}
function extractDailyVisits(report) {
const daily = {};
for (const row of report.visitors.data) {
const date = parseGoAccessDate(row && row.data);
const visits = row && row.visitors && row.visitors.count;
if (!date || !Number.isSafeInteger(visits) || visits < 0) {
throw new Error("GoAccess visitors panel contains invalid daily data");
}
if (Object.hasOwn(daily, date)) {
throw new Error(`GoAccess visitors panel contains duplicate date ${date}`);
}
daily[date] = visits;
}
return daily;
}
function loadCounterState(statePath) {
if (!fs.existsSync(statePath)) {
return { schemaVersion: STATE_SCHEMA_VERSION, sites: {} };
}
const state = readJson(statePath, "counter state");
if (state.schemaVersion !== STATE_SCHEMA_VERSION
|| !state.sites || typeof state.sites !== "object" || Array.isArray(state.sites)) {
throw new ConfigurationError("counter state has an unsupported structure");
}
for (const [siteId, site] of Object.entries(state.sites)) {
if (!site || typeof site !== "object" || !parseIsoDate(site.since)
|| !site.dailyVisits || typeof site.dailyVisits !== "object"
|| Array.isArray(site.dailyVisits)) {
throw new ConfigurationError(`counter state for ${siteId} is invalid`);
}
for (const [date, visits] of Object.entries(site.dailyVisits)) {
if (!parseIsoDate(date) || !Number.isSafeInteger(visits) || visits < 0) {
throw new ConfigurationError(`counter state value for ${siteId}/${date} is invalid`);
}
}
}
return state;
}
function updateCounterState(state, site, report) {
const current = state.sites[site.id];
if (current && current.since !== site.activatedOn) {
throw new ConfigurationError(
`activation date for ${site.id} differs from the existing counter state`
);
}
if (current && current.hostname !== site.hostname) {
throw new ConfigurationError(
`hostname for ${site.id} differs from the existing counter state`
);
}
const dailyVisits = current ? { ...current.dailyVisits } : {};
for (const [date, visits] of Object.entries(extractDailyVisits(report))) {
if (date >= site.activatedOn) {
dailyVisits[date] = visits;
}
}
state.sites[site.id] = {
hostname: site.hostname,
since: site.activatedOn,
dailyVisits
};
}
function publicPayload(state, site, now) {
const siteState = state.sites[site.id];
let visits = 0;
for (const [date, value] of Object.entries(siteState.dailyVisits)) {
if (date >= site.activatedOn) {
visits += value;
if (!Number.isSafeInteger(visits) || visits > MAX_SAFE_INTEGER) {
throw new Error(`public visit total for ${site.id} exceeds the safe integer range`);
}
}
}
return {
schemaVersion: PUBLIC_SCHEMA_VERSION,
visits,
since: site.activatedOn,
updatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z")
};
}
function atomicWriteFile(destination, content, mode = 0o640) {
const directory = path.dirname(destination);
const temporary = path.join(
directory,
`.${path.basename(destination)}.${process.pid}.${Date.now()}.tmp`
);
const handle = fs.openSync(temporary, "wx", mode);
try {
fs.writeFileSync(handle, content);
fs.fchmodSync(handle, mode);
fs.fsyncSync(handle);
} finally {
fs.closeSync(handle);
}
try {
fs.renameSync(temporary, destination);
} catch (error) {
fs.rmSync(temporary, { force: true });
throw error;
}
}
function atomicCopyFile(source, destination) {
atomicWriteFile(destination, fs.readFileSync(source));
}
function replaceDirectory(source, destination) {
fs.mkdirSync(path.dirname(destination), { recursive: true });
const backup = `${destination}.previous-${process.pid}`;
const hadDestination = fs.existsSync(destination);
if (fs.existsSync(backup)) {
throw new Error(`stale database backup blocks replacement: ${backup}`);
}
try {
if (hadDestination) {
fs.renameSync(destination, backup);
}
fs.renameSync(source, destination);
if (hadDestination) {
fs.rmSync(backup, { recursive: true, force: true });
}
} catch (error) {
if (!fs.existsSync(destination) && fs.existsSync(backup)) {
fs.renameSync(backup, destination);
}
throw error;
}
}
function acquireLock(lockPath) {
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
let descriptor;
try {
descriptor = fs.openSync(lockPath, "wx", 0o640);
fs.writeFileSync(descriptor, `${process.pid}\n`);
} catch (error) {
throw new LockError(`another analytics run is active (${lockPath})`);
}
return () => {
fs.closeSync(descriptor);
fs.rmSync(lockPath, { force: true });
};
}
function createReportJob(id, logs, outputDirectory, combined) {
return { id, logs, outputDirectory, combined };
}
function prepareReport(job, context) {
const jobDirectory = path.join(context.runDirectory, job.id);
const stagedDb = path.join(jobDirectory, "db");
const currentDb = path.join(context.registry.stateDirectory, "db", job.id);
const outputJson = path.join(jobDirectory, "report.json");
const outputHtml = path.join(jobDirectory, "report.html");
const runConfig = path.join(jobDirectory, "goaccess.conf");
let hasDatabase = false;
if (fs.existsSync(currentDb)) {
if (!fs.statSync(currentDb).isDirectory()) {
throw new Error(`GoAccess database path is not a directory: ${currentDb}`);
}
hasDatabase = fs.readdirSync(currentDb).length > 0;
}
fs.mkdirSync(jobDirectory, { recursive: true });
if (hasDatabase) {
fs.cpSync(currentDb, stagedDb, { recursive: true, errorOnExist: true });
} else {
fs.mkdirSync(stagedDb);
}
fs.writeFileSync(runConfig, renderGoAccessConfig(
context.configTemplate,
stagedDb,
hasDatabase,
context.registry.geoIpCountryDatabase
));
const args = [
...job.logs,
"--no-global-config",
"--config-file", runConfig
];
if (job.combined) {
args.push("--enable-panel=VIRTUAL_HOSTS");
}
args.push(
"--output", outputJson,
"--output", outputHtml
);
context.runGoAccess({
binary: context.goaccessBinary,
args,
id: job.id,
combined: job.combined,
outputJson,
outputHtml,
dbPath: stagedDb
});
return {
...job,
report: readAndValidateReport(outputJson, outputHtml, job.combined),
outputJson,
outputHtml,
stagedDb,
currentDb
};
}
function generateReports(options, dependencies = {}) {
const registryPath = path.resolve(options.registryPath);
const configTemplatePath = path.resolve(options.configTemplatePath);
const registry = validateRegistry(readJson(registryPath, "site registry"));
const configTemplate = fs.readFileSync(configTemplatePath, "utf8");
const runGoAccess = dependencies.runGoAccess || defaultRunGoAccess;
const checkGoAccess = dependencies.checkGoAccess || defaultCheckGoAccess;
const now = dependencies.now ? dependencies.now() : new Date();
const analyticsLogsBySite = validateInputs(registry, configTemplate);
const goAccess = checkGoAccess(options.goaccessBinary || "goaccess");
if (!goAccess || !goAccess.geoIpMmdb) {
throw new ConfigurationError(
"GoAccess must be built with GeoIP2/MMDB support (--enable-geoip=mmdb)"
);
}
if (options.check) {
return { checked: true, sites: registry.sites.length, goAccess };
}
const releaseLock = options.dryRun || dependencies.skipLock
? () => {}
: acquireLock(registry.lockFile);
let runDirectory;
try {
runDirectory = fs.mkdtempSync(path.join(
options.dryRun ? os.tmpdir() : registry.stateDirectory,
".analytics-run-"
));
const jobs = registry.sites.map(site => createReportJob(
site.id,
analyticsLogsBySite.get(site.id),
site.reportOutputDirectory,
false
));
jobs.push(createReportJob(
"combined",
registry.sites.flatMap(site => analyticsLogsBySite.get(site.id)),
registry.combined.reportOutputDirectory,
true
));
const context = {
registry,
configTemplate,
goaccessBinary: options.goaccessBinary || "goaccess",
runGoAccess,
runDirectory
};
const prepared = jobs.map(job => prepareReport(job, context));
const state = loadCounterState(registry.counterStatePath);
for (const site of registry.sites.filter(entry => entry.publicCounter)) {
const generated = prepared.find(entry => entry.id === site.id);
updateCounterState(state, site, generated.report);
}
const publicFiles = registry.sites
.filter(site => site.publicCounter)
.map(site => ({
destination: site.publicJsonPath,
content: `${JSON.stringify(publicPayload(state, site, now), null, 2)}\n`
}));
if (!options.dryRun) {
for (const report of prepared) {
atomicCopyFile(
report.outputJson,
path.join(report.outputDirectory, "report.json")
);
atomicCopyFile(
report.outputHtml,
path.join(report.outputDirectory, "report.html")
);
}
for (const report of prepared) {
replaceDirectory(report.stagedDb, report.currentDb);
}
atomicWriteFile(
registry.counterStatePath,
`${JSON.stringify(state, null, 2)}\n`
);
for (const publicFile of publicFiles) {
atomicWriteFile(publicFile.destination, publicFile.content, 0o644);
}
}
return {
checked: false,
dryRun: Boolean(options.dryRun),
sites: registry.sites.length,
reports: prepared.length,
publicCounters: publicFiles.length
};
} finally {
if (runDirectory && fs.existsSync(runDirectory)) {
fs.rmSync(runDirectory, { recursive: true, force: true });
}
releaseLock();
}
}
function parseArguments(argv) {
const options = { check: false, dryRun: false, goaccessBinary: "goaccess" };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--check") {
options.check = true;
} else if (argument === "--dry-run") {
options.dryRun = true;
} else if (["--registry", "--config-template", "--goaccess"].includes(argument)) {
const value = argv[index + 1];
if (!value) {
throw new ConfigurationError(`${argument} requires a value`);
}
index += 1;
if (argument === "--registry") options.registryPath = value;
if (argument === "--config-template") options.configTemplatePath = value;
if (argument === "--goaccess") options.goaccessBinary = value;
} else {
throw new ConfigurationError(`unknown argument: ${argument}`);
}
}
if (!options.registryPath || !options.configTemplatePath) {
throw new ConfigurationError(
"usage: generate-reports.js --registry FILE --config-template FILE "
+ "[--goaccess FILE] [--check|--dry-run]"
);
}
if (options.check && options.dryRun) {
throw new ConfigurationError("--check and --dry-run are mutually exclusive");
}
return options;
}
if (require.main === module) {
try {
const result = generateReports(parseArguments(process.argv.slice(2)));
const action = result.checked ? "Configuration check" : "Analytics generation";
if (result.checked) {
process.stdout.write(`${formatGoAccessCheck(result.goAccess)}\n`);
}
process.stdout.write(`${action} completed: ${JSON.stringify(result)}\n`);
} catch (error) {
process.stderr.write(`analytics: ${error.message}\n`);
process.exitCode = error.exitCode || 1;
}
}
module.exports = {
ConfigurationError,
extractDailyVisits,
formatGoAccessCheck,
generateReports,
parseGoAccessVersion,
parseArguments,
publicPayload,
updateCounterState,
validateRegistry,
validateReport
};
@@ -1,43 +0,0 @@
# Rendered by generate-reports.js. Do not use this file without replacing
# all {{...}} placeholders.
datetime-format %Y-%m-%dT%H:%M:%S%z
log-format %v\t%h\t%x\t%m\t%U\t%H\t%s\t%b\t"%u"
anonymize-ip true
anonymize-level 2
ignore-crawlers true
unknowns-as-crawlers true
keep-last 395
persist true
{{RESTORE_DIRECTIVE}}
db-path {{DB_PATH}}
geoip-database {{GEOIP_COUNTRY_DATABASE}}
json-pretty-print true
no-progress true
no-parsing-spinner true
no-color true
agent-list false
http-method true
http-protocol true
max-items 500
# The Nginx analytics log contains $uri rather than $request_uri, so query
# strings never reach GoAccess. These panels are deliberately unavailable.
ignore-panel HOSTS
ignore-panel OS
ignore-panel BROWSERS
ignore-panel REFERRERS
ignore-panel REFERRING_SITES
ignore-panel KEYPHRASES
ignore-panel REMOTE_USER
ignore-panel REQUESTS_STATIC
ignore-panel VISIT_TIMES
ignore-panel NOT_FOUND
ignore-panel ASN
ignore-panel MIME_TYPE
ignore-panel TLS_TYPE
ignore-panel CACHE_STATUS
@@ -1,14 +0,0 @@
/var/log/nginx/*-analytics.log {
daily
rotate 14
missingok
notifempty
compress
# Keep .1 uncompressed because the regular generator reads it directly.
delaycompress
create 0640 www-data hamradio-analytics
sharedscripts
postrotate
invoke-rc.d nginx rotate >/dev/null 2>&1
endscript
}
@@ -1,36 +0,0 @@
# Only page GET requests which are candidates for the reach statistics enter
# the dedicated analytics log. GoAccess performs the second bot-classification
# layer, including unknown browsers and operating systems.
map $request_method $hamradioonline_analytics_method {
default 0;
GET 1;
}
map $uri $hamradioonline_analytics_path {
default 1;
/visitor-count.json 0;
/kst4ContestVersionInfo.xml 0;
/sitemap.xml 0;
/robots.txt 0;
/favicon.ico 0;
/assets/favicon.svg 0;
/health 0;
/healthz 0;
/ping 0;
/status 0;
~*^/(?:assets|manual/assets)/ 0;
~*\.(?:css|js|mjs|map|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|xml|txt|pdf|zip|gz|wasm|mp4|webm)$ 0;
}
map $http_user_agent $hamradioonline_analytics_known_bot {
default 0;
~*(?:bot|crawler|spider|slurp|headless|monitor|healthcheck|uptime|wget|curl) 1;
}
map "$hamradioonline_analytics_method:$hamradioonline_analytics_path:$hamradioonline_analytics_known_bot"
$hamradioonline_analytics_loggable {
default 0;
"1:1:0" 1;
}
@@ -1,15 +0,0 @@
# Include in the Nginx http context. $uri is the normalized path and excludes
# the query string. The format intentionally omits referrer and remote user.
log_format hamradioonline_analytics
'$server_name\t$remote_addr\t$time_iso8601\t$request_method\t$uri\t'
'$server_protocol\t$status\t$body_bytes_sent\t"$http_user_agent"';
# Include the maps below in the Nginx http context as well.
include /etc/nginx/snippets/hamradioonline-analytics-filters.conf;
# Add this extra log to each registered project server block. Keep the
# existing operational access_log directive; do not replace it implicitly.
#
# access_log /var/log/nginx/kst4contest-analytics.log
# hamradioonline_analytics if=$hamradioonline_analytics_loggable;
@@ -1,9 +0,0 @@
# Include inside the kst4contest.hamradioonline.de HTTPS server block. Grant
# the Nginx worker read access to the file and directory, but no write access.
location = /visitor-count.json {
alias /var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json;
default_type application/json;
access_log off;
add_header Cache-Control "public, max-age=3600" always;
add_header X-Content-Type-Options "nosniff" always;
}
@@ -1,17 +0,0 @@
# Temporary HTTP-only virtual host for initial certificate provisioning.
# Replace it with stats-vhost.conf.example after Certbot has succeeded.
server {
listen 80;
server_name stats.hamradioonline.de;
access_log off;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/letsencrypt;
default_type text/plain;
}
location / {
return 404;
}
}
@@ -1,57 +0,0 @@
# Keep this HTTP server active so Certbot can renew the webroot certificate.
server {
listen 80;
server_name stats.hamradioonline.de;
access_log off;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/letsencrypt;
default_type text/plain;
}
location / {
return 301 https://$host$request_uri;
}
}
# This server exposes static reports only. Provision the certificate and the
# htpasswd file outside the repository. Do not add this host to sites.json.
server {
listen 443 ssl http2;
server_name stats.hamradioonline.de;
ssl_certificate /etc/letsencrypt/live/stats.hamradioonline.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stats.hamradioonline.de/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /var/lib/hamradioonline-analytics/reports;
index report.html;
auth_basic "Private project statistics";
auth_basic_user_file /etc/nginx/htpasswd/hamradioonline-analytics;
access_log off;
add_header Cache-Control "private, no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location = / {
try_files /__no_report_at_root__ @combined_reports;
}
location @combined_reports {
return 302 /combined/;
}
location / {
try_files $uri $uri/ =404;
}
location ~ /\. {
deny all;
}
}
# IPv6 is deliberately omitted until the DNS AAAA record has been confirmed.
-21
View File
@@ -1,21 +0,0 @@
{
"schemaVersion": 1,
"stateDirectory": "/var/lib/hamradioonline-analytics",
"counterStatePath": "/var/lib/hamradioonline-analytics/public-counter-state.json",
"lockFile": "/run/hamradioonline-analytics/generator.lock",
"geoIpCountryDatabase": "/var/lib/GeoIP/GeoLite2-Country.mmdb",
"combined": {
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/combined"
},
"sites": [
{
"id": "kst4contest",
"hostname": "kst4contest.hamradioonline.de",
"analyticsLog": "/var/log/nginx/kst4contest-analytics.log",
"activatedOn": "2026-09-11",
"publicCounter": true,
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/kst4contest",
"publicJsonPath": "/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json"
}
]
}
@@ -1,34 +0,0 @@
[Unit]
Description=Generate private GoAccess reports and public project counters
After=nginx.service
[Service]
Type=oneshot
User=hamradio-analytics
Group=hamradio-analytics
UMask=0027
Environment=LC_TIME=C
ExecStart=/usr/bin/node /opt/hamradioonline-analytics/generate-reports.js --registry /etc/hamradioonline-analytics/sites.json --config-template /etc/hamradioonline-analytics/goaccess.conf.template
NoNewPrivileges=true
PrivateDevices=true
PrivateNetwork=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict
PrivateTmp=true
ProtectProc=invisible
ProcSubset=pid
RestrictAddressFamilies=AF_UNIX
ReadOnlyPaths=/etc/hamradioonline-analytics /opt/hamradioonline-analytics /var/log/nginx /var/lib/GeoIP/GeoLite2-Country.mmdb
ReadWritePaths=/var/lib/hamradioonline-analytics
StateDirectory=hamradioonline-analytics
StateDirectoryMode=0711
RuntimeDirectory=hamradioonline-analytics
RuntimeDirectoryMode=0750
RestrictSUIDSGID=true
LockPersonality=true
@@ -1,11 +0,0 @@
[Unit]
Description=Run hamradioonline analytics once per hour
[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=4m
AccuracySec=1m
[Install]
WantedBy=timers.target
-3
View File
@@ -7,9 +7,6 @@
"": {
"name": "kst4contest-website",
"version": "0.1.0",
"engines": {
"node": ">=18.19.1"
},
"dependencies": {
"markdown-it": "^14.3.0",
"markdown-it-anchor": "^9.2.0"
-3
View File
@@ -2,9 +2,6 @@
"name": "kst4contest-website",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=18.19.1"
},
"scripts": {
"start": "eleventy --serve",
"build": "eleventy && npm run validate:version-info",
+14 -23
View File
@@ -1,32 +1,23 @@
module.exports = [
{
id: "main-window-light",
title: "Main window · Light theme",
image: "/assets/completeViewLight.png",
alt: "KST4Contest main window in the light theme with ON4KST chat, station filters, priority candidates and sked controls",
caption: "The complete contest view brings chat messages, active bands, filters and priority candidates together."
id: "main-window",
title: "Main Window",
image: "/assets/screenshots/main-window.png",
alt: "KST4Contest ON4KST contest chat client main window",
caption: "Contest-oriented ON4KST chat workflow with candidate awareness."
},
{
id: "main-window-dark",
title: "Main window · Dark theme",
image: "/assets/completeViewDark.png",
alt: "KST4Contest main window in the dark theme with ON4KST chat, station filters, priority candidates and sked controls",
caption: "The same operating view in the dark theme, including worked status, candidate priorities and message preparation."
id: "priority-candidates",
title: "Priority Candidates",
image: "/assets/screenshots/priority-candidates.png",
alt: "Priority candidate list in KST4Contest",
caption: "Score-based candidate ranking for faster operator decisions."
},
{
id: "timeline",
title: "AP and sked timeline",
image: "/assets/timeline.png",
alt: "KST4Contest timeline showing scheduled contacts and Aircraft Scatter crossing windows",
caption: "Skeds and Aircraft Scatter opportunities share one time axis, making upcoming operating windows easier to coordinate.",
wide: true
},
{
id: "airscout",
title: "KST4Contest with AirScout",
image: "/assets/complete_incl_AS.png",
alt: "KST4Contest main window alongside AirScout path and aircraft information",
caption: "AirScout adds path and aircraft information while KST4Contest keeps the related chat and station context visible.",
wide: true
title: "Timeline View",
image: "/assets/screenshots/timeline.png",
alt: "KST4Contest AP timeline view",
caption: "Timeline support for AP windows and candidate timing."
}
];
-4
View File
@@ -61,7 +61,6 @@
<p><a href="/features/">Features</a></p>
<p><a href="/download/">Download</a></p>
<p><a href="/manual/">Manual</a></p>
<p><a href="/background/">Background</a></p>
<p><a href="/roadmap/">Roadmap</a></p>
</div>
<div>
@@ -82,8 +81,5 @@
{% if heroFx %}
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
{% endif %}
{% if visitorCount %}
<script src="/assets/js/visitor-count.js?v={{ build.version }}" defer></script>
{% endif %}
</body>
</html>
-2
View File
@@ -25,8 +25,6 @@ description: About KST4Contest and its contest-oriented ON4KST workflow.
<p>
The project is open source and focused on practical contest station workflows.
Features are designed around real operating pressure, not theoretical UI concepts.
The <a href="/background/">background and conference papers</a> explain how
work at DM5M and later analysis of archived ON4KST chat data influenced the client.
</p>
<h2>Community supported</h2>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+9 -36
View File
@@ -641,43 +641,24 @@ a:hover {
margin-top: 30px;
}
.screenshot-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.screenshot-card--wide {
grid-column: 1 / -1;
}
.screenshot-link {
display: block;
.screenshot-placeholder {
display: grid;
place-items: center;
min-height: 180px;
margin-bottom: 16px;
border-radius: 18px;
overflow: hidden;
background:
linear-gradient(135deg, rgba(56, 189, 248, 0.20), rgba(168, 85, 247, 0.20)),
rgba(2, 6, 23, 0.7);
border: 1px solid var(--border);
background: rgba(2, 6, 23, 0.7);
}
.screenshot-image {
display: block;
width: 100%;
height: auto;
}
.screenshot-card h3 {
margin-bottom: 8px;
color: white;
font-weight: 800;
}
.cta-panel {
padding: clamp(28px, 5vw, 60px);
}
.visitor-count {
margin: 24px 0 0;
color: var(--soft);
font-size: 0.9rem;
}
.manual-content {
max-width: 980px;
}
@@ -731,14 +712,6 @@ code {
align-items: flex-start;
flex-direction: column;
}
.screenshot-grid {
grid-template-columns: 1fr;
}
.screenshot-card--wide {
grid-column: auto;
}
}
@media (prefers-reduced-motion: no-preference) {
-147
View File
@@ -1,147 +0,0 @@
"use strict";
const VISITOR_COUNT_ENDPOINT = "/visitor-count.json";
const VISITOR_COUNT_TIMEOUT_MS = 2500;
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
return null;
}
return date;
}
function isIsoTimestamp(value) {
if (typeof value !== "string") {
return false;
}
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
if (!match || !parseIsoDate(match[1])) {
return false;
}
const hour = Number(match[2]);
const minute = Number(match[3]);
const second = Number(match[4]);
const offsetHour = match[6] === undefined ? 0 : Number(match[6]);
const offsetMinute = match[7] === undefined ? 0 : Number(match[7]);
return hour <= 23
&& minute <= 59
&& second <= 59
&& offsetHour <= 23
&& offsetMinute <= 59
&& Number.isFinite(Date.parse(value));
}
function validateVisitorCount(data) {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return null;
}
if (data.schemaVersion !== 1
|| !Number.isSafeInteger(data.visits)
|| data.visits < 0
|| !isIsoTimestamp(data.updatedAt)) {
return null;
}
const since = typeof data.since === "string"
? parseIsoDate(data.since)
: null;
return since ? { visits: data.visits, since } : null;
}
function formatVisitorCount(data) {
const valid = validateVisitorCount(data);
if (!valid) {
return null;
}
const date = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC"
}).format(valid.since);
const visits = new Intl.NumberFormat("en-GB").format(valid.visits);
return `Visits since ${date}: ${visits}`;
}
async function loadVisitorCount({
element,
fetchImpl = globalThis.fetch,
AbortControllerImpl = globalThis.AbortController,
timeoutMs = VISITOR_COUNT_TIMEOUT_MS
}) {
if (!element || typeof fetchImpl !== "function"
|| typeof AbortControllerImpl !== "function") {
return false;
}
element.hidden = true;
element.textContent = "";
const controller = new AbortControllerImpl();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(VISITOR_COUNT_ENDPOINT, {
credentials: "omit",
signal: controller.signal
});
if (!response.ok) {
return false;
}
const text = formatVisitorCount(await response.json());
if (!text) {
return false;
}
element.textContent = text;
element.hidden = false;
return true;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
if (typeof document !== "undefined") {
const element = document.querySelector("[data-visitor-count]");
if (element) {
loadVisitorCount({ element });
}
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
formatVisitorCount,
loadVisitorCount,
parseIsoDate,
validateVisitorCount
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

-98
View File
@@ -1,98 +0,0 @@
---
layout: base.njk
lang: en
title: Background and conference papers
description: The operating background of KST4Contest and the German and English papers presented at GHz-Tagung Dorsten in 2025 and 2026.
---
<section class="hero">
<p class="badge">Project background</p>
<h1>Background and conference papers</h1>
<p class="lead">
KST4Contest grew out of practical contest operation at DM5M. Individual
functions started with concrete problems encountered while using the ON4KST
chat; later development was also influenced by analysis of archived chat data.
</p>
</section>
<section class="section narrow">
<div class="section-heading">
<p class="eyebrow">Two points in the project history</p>
<h2>What the papers document</h2>
<p>
These papers preserve the implementation and knowledge available at the
respective GHz-Tagung. Their historical version references and plans have
deliberately not been rewritten to match the current application.
</p>
</div>
<div class="grid">
<article class="card" id="paper-2025">
<p class="eyebrow">GHz-Tagung Dorsten · 2025</p>
<h3>Softwaregestützte Optimierung der Betriebstechnik von Conteststationen am Beispiel von KST4Contest</h3>
<p><strong>English title:</strong> Software-assisted optimisation of contest station operating practice using KST4Contest</p>
<p>
The paper covers the development of KST4Contest, practical contest
operating technique, the limits of conventional chat clients, Worked
information, Genius frequency and message analysis, AirScout, logger,
rotator and DX Cluster integration, and the operating and UI concept.
</p>
<p>
Marc Fröhlich, “Softwaregestützte Optimierung der Betriebstechnik von
Conteststationen am Beispiel von KST4Contest”, paper for the presentation
at <a href="https://ghztagung.darc.de/">GHz-Tagung Dorsten 2025</a>.
</p>
<p>
The English edition is a translation of the German original, not a
separate publication.
</p>
<div class="actions">
<a class="button" href="/assets/papers/kst4contest-ghz-tagung-2025-de.pdf">Download German PDF</a>
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2025-en.pdf">Download English PDF</a>
</div>
<p><small>German PDF · 3.4 MB · English PDF · 3.4 MB</small></p>
</article>
<article class="card" id="paper-2026">
<p class="eyebrow">GHz-Tagung Dorsten · 2026</p>
<h3>Chat-Mining: Jagdinstinkte wecken mit KST4Contest</h3>
<p><strong>English title:</strong> Chat mining: awakening hunting instincts with KST4Contest</p>
<p>
The paper examines archived ON4KST chats, presence in both categories,
activity periods, sked requests and reply behaviour, the relationship
between useful information and reply rate, and the functions derived
from those observations.
</p>
<p>
Marc Fröhlich, “Chat-Mining: Jagdinstinkte wecken mit KST4Contest”, paper
for the presentation at <a href="https://ghztagung.darc.de/">GHz-Tagung
Dorsten 2026</a>, including section “Erfolgsrezept”.
</p>
<p>
The English edition is a translation of the German original, not a
separate study.
</p>
<div class="actions">
<a class="button" href="/assets/papers/kst4contest-ghz-tagung-2026-de.pdf">Download German PDF</a>
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2026-en.pdf">Download English PDF</a>
</div>
<p><small>German PDF · 1.8 MB · English PDF · 1.7 MB</small></p>
</article>
</div>
</section>
<section class="section narrow">
<div class="cta-panel">
<p class="eyebrow">Current software</p>
<h2>The papers explain the background, not today's complete feature set</h2>
<p>
For current behaviour and configuration, use the complete function
overview and the manual. The conference papers remain historical sources.
</p>
<div class="actions">
<a class="button" href="/features/">Open all functions</a>
<a class="button secondary" href="/manual/">Read the current manual</a>
<a class="button ghost" href="/">Return to the home page</a>
</div>
</div>
</section>
-39
View File
@@ -212,45 +212,6 @@ description: Download the latest stable KST4Contest packages for Windows, Linux
</div>
</section>
<section class="section">
<div class="section-heading">
<p class="eyebrow">Conference papers</p>
<h2>Background from GHz-Tagung Dorsten</h2>
<p>
These historical conference papers document KST4Contest at the time of the 2025 and 2026 talks.
They provide project background, not a complete description of the current feature set.
</p>
</div>
<div class="grid">
<article class="card content-card">
<h3>GHz-Tagung 2025</h3>
<p>
How KST4Contest grew from practical contest operation and addressed limitations of conventional
chat clients.
</p>
<div class="actions">
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2025-de.pdf">Download German PDF</a>
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2025-en.pdf">Download English PDF</a>
</div>
<p><a href="/background/#paper-2025">Background and source details →</a></p>
</article>
<article class="card content-card">
<h3>GHz-Tagung 2026</h3>
<p>
How archived ON4KST chat data was evaluated and how the findings influenced later KST4Contest
functions.
</p>
<div class="actions">
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2026-de.pdf">Download German PDF</a>
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2026-en.pdf">Download English PDF</a>
</div>
<p><a href="/background/#paper-2026">Background and source details →</a></p>
</article>
</div>
</section>
<section class="section">
<div class="cta-panel">
<p class="eyebrow">Choosing a package</p>
+54 -120
View File
@@ -3,7 +3,6 @@ layout: base.njk
title: KST4Contest ON4KST Contest Client for VHF, UHF and SHF
description: KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration in one desktop client.
heroFx: true
visitorCount: true
---
<section class="hero hero-split">
@@ -15,15 +14,15 @@ visitorCount: true
<h1>KST4Contest</h1>
<p class="lead">
KST4Contest is an ON4KST client for VHF, UHF and SHF contest operation
on 144 MHz and above. It keeps active stations, frequencies, skeds and
aircraft-scatter opportunities in view while connecting chat information
with logger and station interfaces.
ON4KST shows you the traffic. KST4Contest helps turn it into an operating
decision by combining chat, candidate prioritisation, sked planning,
AirScout data and logger integration in one desktop client.
</p>
<p>
The client helps the operator choose the next useful station and prepare
the next operating step. It does not automate the radio contact itself.
The available information is filtered, related to active stations and
presented in a contest-oriented workflow. The final decision still
belongs to the operator.
</p>
<div class="actions">
@@ -36,71 +35,57 @@ visitorCount: true
<div class="hero-panel">
<div class="terminal-bar">
<span></span><span></span><span></span>
<strong>Contest information ready for the next decision</strong>
<strong>Functions sharing the same station context</strong>
</div>
<div class="mock-grid">
<div class="mini-card">
<strong>Active stations and QRGs</strong>
<small>Chat messages, recognised frequencies and known bands stay connected to the station.</small>
</div>
<div class="mini-card">
<strong>Skeds and AP windows</strong>
<small>Scheduled contacts and AirScout timing remain visible while the contest continues.</small>
</div>
<div class="mini-card">
<strong>Logger and station status</strong>
<small>Worked information, the current TRX QRG and rotor direction inform the same workflow.</small>
</div>
{% for feature in collections.sortedFeatures %}
{% if loop.index <= 6 %}
<div class="mini-card">
<strong>{{ feature.data.icon }} {{ feature.data.title }}</strong>
<small>{{ feature.data.summary }}</small>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</section>
{% if collections.latestNews.length %}
{% set latestPost = collections.latestNews[0] %}
<section class="section narrow" aria-labelledby="latest-news-heading">
<article class="card">
<p class="eyebrow">Latest news · {{ latestPost.date | readableDate }}</p>
<h2 id="latest-news-heading"><a href="{{ latestPost.url }}">{{ latestPost.data.title }}</a></h2>
<p>{{ latestPost.data.summary }}</p>
<a href="{{ latestPost.url }}">Open this update →</a>
</article>
</section>
{% endif %}
<section class="section">
<div class="section-heading">
<p class="eyebrow">Practical tasks</p>
<h2>From chat traffic to the next operating step</h2>
<p class="eyebrow">The operating problem</p>
<h2>The useful station is rarely the only station in the chat</h2>
<p>
Messages, station activity and station interfaces provide different
pieces of the same operating situation. KST4Contest brings them together
where they are needed.
During an active contest, messages, sked requests, frequency information
and band changes arrive continuously. The task is to identify which part
of that traffic matters now, which candidate should be monitored and
which contact is better scheduled for later.
</p>
</div>
<div class="grid">
<article class="card">
<h3>Keep track of chat and active stations</h3>
<h3>Observe</h3>
<p>
Follow messages across two ON4KST categories, see active bands and keep
recognised frequencies attached to the callsign that sent them.
Messages, locators, detected frequencies and known band activity are
assigned to stations across as many as two ON4KST chat categories.
</p>
</article>
<article class="card">
<h3>Select and plan contacts</h3>
<h3>Evaluate</h3>
<p>
Use filters and priorities to find candidates, schedule skeds and keep
their timing visible in the timeline together with AirScout data.
Direction, distance, worked status, NOT-QRV information, chat activity,
sked context and aircraft scatter data feed filters and candidate scores.
</p>
</article>
<article class="card">
<h3>Connect logger and station components</h3>
<h3>Act</h3>
<p>
Synchronise Worked status with supported loggers and pass useful context
to the TRX, rotator and local DX Cluster interfaces.
Candidates can be contacted, scheduled or monitored. Skeds remain
visible, while supported loggers and station interfaces receive the
information required for the next operating step.
</p>
</article>
</div>
@@ -108,77 +93,25 @@ visitorCount: true
<section class="section">
<div class="section-heading">
<p class="eyebrow">What the chat data showed</p>
<h2>Useful detail makes a reply easier</h2>
<p class="eyebrow">Functions</p>
<h2>One workflow, shared context</h2>
<p>
Archived ON4KST chat logs from several IARU contests in the preceding two
years were analysed. The concrete example comes from the July 2025 contest
and combines the 144/432 MHz and Microwave categories. It compares sked
requests with the replies and sked windows they produced.
</p>
<p>
The text analysis considered action and sked words, frequency, timing,
beam direction, aircraft-scatter information, locator, mode and courtesy
or anchor words. Within this sample, requests containing AP and beam
information were associated with an almost 30% higher chat reply rate
than short, unspecific requests such as “Hi noname, try 2m?”.
</p>
<p>
This measures chat replies, not completed QSOs. It is a correlation within
the investigated sample, not general proof of causation and not a promise
that an individual sked will succeed. The practical point is modest:
information that can be used immediately saves the other operator from
having to ask for it first. KST4Contest supplies part of that context when
preparing messages and skeds.
</p>
<p>
Source: Marc Fröhlich, “Chat-Mining: Jagdinstinkte wecken mit KST4Contest”,
conference paper for GHz-Tagung Dorsten 2026, section “Erfolgsrezept”.
<a href="/background/#paper-2026">Read the background and download the paper</a>.
</p>
</div>
</section>
<section class="section">
<div class="section-heading">
<p class="eyebrow">Selected functions</p>
<h2>The parts most often used during a contest</h2>
<p>
These examples show how station, band, message and timing information is
used in practice. The complete function overview remains available for
configuration details and less frequently used tools.
The individual functions are not isolated tools. They use the same
station, message, band and timing information, so that a change in one
part of the workflow can also affect filters, priorities and reminders.
</p>
</div>
<div class="grid">
<article class="card feature-card">
<h3><a href="/features/global-message-views/">Chat overview and station filters</a></h3>
<p>Separate global and private views keep the traffic readable, while station filters narrow the candidate list without hiding the underlying chat context.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/priority-score/">Priority and band opportunities</a></h3>
<p>Candidate scores combine available station information with Worked and band state so that a useful next call is easier to find.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/timeline/">Skeds and AP timeline</a></h3>
<p>Manual skeds, reminders and aircraft-scatter windows remain visible instead of disappearing in the continuing chat stream.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/airscout/">AirScout and path information</a></h3>
<p>AirScout data adds timing and path context to a selected station without turning that assessment into a propagation promise.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/log-sync/">Logger and Worked synchronisation</a></h3>
<p>Supported logger data updates global and per-band Worked information across the active callsign variants.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/trx-qrg-synchronisation/">TRX, rotator and DX Cluster integration</a></h3>
<p>The current QRG, rotor direction and local spots carry selected station information into the next manual operating step.</p>
</article>
</div>
<div class="actions">
<a class="button secondary" href="/features/">Open the complete function overview</a>
{% for feature in collections.sortedFeatures %}
<article class="card feature-card">
<div class="feature-icon">{{ feature.data.icon }}</div>
<p class="eyebrow">{{ feature.data.category }}</p>
<h3><a href="{{ feature.url }}">{{ feature.data.title }}</a></h3>
<p>{{ feature.data.summary }}</p>
<a href="{{ feature.url }}">Read how it works →</a>
</article>
{% endfor %}
</div>
</section>
@@ -187,9 +120,10 @@ visitorCount: true
<p class="eyebrow">Limits</p>
<h2>A priority score is not a propagation forecast</h2>
<p>
Priorities are not a propagation forecast. Scores, filters and AP windows
depend on the data available to the client. They help with sorting and
timing, but they do not guarantee a QSO.
KST4Contest can only evaluate the information it knows. Scores, AP
windows, filters and path profiles support the operator's decision; they
do not guarantee a contact. If the input data is incomplete or outdated,
the result can be incomplete or outdated as well.
</p>
<div class="actions">
@@ -204,9 +138,11 @@ visitorCount: true
<p class="eyebrow">Project status</p>
<h2>Stable for operation, Nightly for testing</h2>
<p>
Stable is the normal choice for contest operation. Nightly contains the
latest development state and is intended for testing. Changing versions
immediately before a contest is still not recommended.
KST4Contest is open-source software. The current stable release is the
normal choice for contest operation. Nightly builds follow ongoing
development and are useful when a particular fix or feature needs
testing. A few minutes before a contest is not the ideal time to discover
what changed.
</p>
<div class="actions">
@@ -214,8 +150,6 @@ visitorCount: true
<a class="button secondary" href="/roadmap/">Development status</a>
<a class="button ghost" href="/support/">Support development</a>
</div>
<p class="visitor-count" data-visitor-count hidden aria-live="polite"></p>
</div>
</section>
+4 -38
View File
@@ -7,7 +7,7 @@ description: Privacy policy for the KST4Contest website.
<section class="hero">
<p class="badge">Privacy</p>
<h1>Privacy Policy</h1>
<p class="lead">Information about data processing on this website.</p>
<p class="lead">Information about data processing on this static website.</p>
</section>
<section class="section narrow">
@@ -30,46 +30,12 @@ description: Privacy policy for the KST4Contest website.
<h2>Legal basis</h2>
<p>
The legal basis is Art. 6(1)(f) GDPR: legitimate interest in secure and reliable operation
of the website and in understanding the approximate use of its project pages.
of the website.
</p>
<h2>Server-side reach statistics</h2>
<h2>Cookies and tracking</h2>
<p>
The website uses server-side reach statistics to understand approximate demand and which
project pages are used. Measurement is based on a dedicated, reduced server access log. It
does not use cookies, an analytics or tracking script, advertising pixels, local storage,
third-party requests or user profiles.
</p>
<p>
The analytics log contains the server name, IP address, time, HTTP method, normalised path
without a query string, protocol, response status, transferred size and user agent. It does
not contain a referrer or authenticated user name. Known crawlers are filtered, and unknown
browsers or operating systems are treated as crawlers.
</p>
<p>
IP addresses are anonymised before data is stored in the statistics aggregates. The dedicated
analytics raw logs are retained for 14 days. Anonymised detailed aggregates are retained on a
rolling basis for 395 days. They include countries, but no city or host statistics. Countries
are derived with a Country database only.
</p>
<p>
A visit is an approximate daily value based on the combination of IP address, date and user
agent used by GoAccess. It must not be read as a number of unique people. Page views and visits
remain separate measures. Bots, monitoring requests, software update checks and static assets
are excluded as far as they can be identified.
</p>
<h2>Public visitor count</h2>
<p>
The home page displays an element containing the total number of these approximate daily visits
since the stated activation date. The display loads a same-origin JSON file and remains hidden
if that file is unavailable or invalid. This request is not included in the statistic.
</p>
<p>
The public file contains only the counter value, activation date, schema version and update time.
It contains no personal data or daily breakdown. The non-personal daily counter values are kept
permanently so that the total does not decrease when detailed aggregates reach the end of their
395-day retention period.
This website currently does not use cookies, analytics tracking, advertising pixels or user profiling.
</p>
<h2>External links</h2>
+5 -16
View File
@@ -14,25 +14,14 @@ description: Screenshots of KST4Contest, the contest-optimized ON4KST chat clien
</section>
<section class="section">
<div class="section-heading">
<p class="eyebrow">Four working views</p>
<h2>From chat overview to Aircraft Scatter timing</h2>
<p>
These screenshots were captured in a test environment. Version-like text inside chat messages is
test data, not an announcement of a published KST4Contest version.
</p>
</div>
<div class="grid screenshot-grid">
<div class="grid">
{% for shot in screenshots %}
<article class="card screenshot-card{% if shot.wide %} screenshot-card--wide{% endif %}">
<a class="screenshot-link" href="{{ shot.image }}" target="_blank" rel="noopener"
aria-label="Open {{ shot.title }} in full size">
<img class="screenshot-image" src="{{ shot.image }}" alt="{{ shot.alt }}" loading="lazy" decoding="async">
</a>
<article class="card screenshot-card">
<div class="screenshot-placeholder">
<span>{{ shot.title }}</span>
</div>
<h3>{{ shot.title }}</h3>
<p>{{ shot.caption }}</p>
<p><a href="{{ shot.image }}" target="_blank" rel="noopener">Open full-size image →</a></p>
</article>
{% endfor %}
</div>
-547
View File
@@ -1,547 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const {
formatGoAccessCheck,
generateReports,
parseGoAccessVersion,
validateReport
} = require("../ops/analytics/generate-reports");
const GOACCESS_WITHOUT_ZLIB = {
version: "1.8.1",
geoIpMmdb: true,
openSsl: true,
zlib: false
};
function goAccessReport(dailyVisits, combined = false) {
const report = {
general: { total_requests: 1 },
visitors: {
data: Object.entries(dailyVisits).map(([date, count]) => ({
data: date.replaceAll("-", ""),
visitors: { count }
}))
},
requests: { data: [] },
status_codes: { data: [] },
geolocation: { data: [] }
};
if (combined) {
report.vhosts = { data: [] };
}
return report;
}
function fixture(siteDefinitions) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "kst4-analytics-test-"));
const stateDirectory = path.join(root, "state");
const geoIpCountryDatabase = path.join(root, "GeoLite2-Country.mmdb");
const configTemplatePath = path.join(root, "goaccess.conf.template");
const registryPath = path.join(root, "sites.json");
fs.writeFileSync(geoIpCountryDatabase, "test database placeholder");
fs.copyFileSync(
path.join(__dirname, "../ops/analytics/goaccess.conf.template"),
configTemplatePath
);
const sites = siteDefinitions.map((definition, index) => {
const id = definition.id || `site-${index}`;
const log = path.join(root, `${id}.log`);
fs.writeFileSync(log, "example log line\n");
if (definition.rotated !== false) {
fs.writeFileSync(`${log}.1`, "rotated example log line\n");
}
if (definition.compressed) {
fs.writeFileSync(`${log}.2.gz`, "compressed placeholder\n");
}
return {
id,
hostname: definition.hostname || `${id}.example.test`,
analyticsLog: log,
activatedOn: definition.activatedOn || "2026-01-01",
publicCounter: definition.publicCounter,
reportOutputDirectory: path.join(stateDirectory, "reports", id),
...(definition.publicCounter
? { publicJsonPath: path.join(stateDirectory, "public", `${id}.json`) }
: {})
};
});
const registry = {
schemaVersion: 1,
stateDirectory,
counterStatePath: path.join(stateDirectory, "counter-state.json"),
lockFile: path.join(root, "run", "generator.lock"),
geoIpCountryDatabase,
combined: {
reportOutputDirectory: path.join(stateDirectory, "reports", "combined")
},
sites
};
fs.mkdirSync(stateDirectory, { recursive: true });
fs.mkdirSync(registry.combined.reportOutputDirectory, { recursive: true });
for (const site of sites) {
fs.mkdirSync(site.reportOutputDirectory, { recursive: true });
if (site.publicCounter) {
fs.mkdirSync(path.dirname(site.publicJsonPath), { recursive: true });
}
}
fs.writeFileSync(registryPath, JSON.stringify(registry));
return {
root,
registry,
registryPath,
configTemplatePath,
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
};
}
function fakeGoAccess(reports, calls, failureId) {
return invocation => {
calls.push(invocation);
if (invocation.id === failureId) {
throw new Error("simulated GoAccess failure");
}
fs.writeFileSync(
invocation.outputJson,
JSON.stringify(reports[invocation.id])
);
fs.writeFileSync(
invocation.outputHtml,
`<!doctype html><html><body>${"report".repeat(30)}</body></html>`
);
fs.writeFileSync(path.join(invocation.dbPath, "persisted.db"), invocation.id);
};
}
function run(testFixture, reports, calls = [], failureId) {
return generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess"
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess(reports, calls, failureId),
now: () => new Date("2026-09-11T07:00:00Z"),
skipLock: true
});
}
test("replaces repeated daily values and retains older public days", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
fs.writeFileSync(testFixture.registry.counterStatePath, JSON.stringify({
schemaVersion: 1,
sites: {
alpha: {
hostname: "alpha.example.test",
since: "2026-01-01",
dailyVisits: { "2026-01-01": 7 }
}
}
}));
run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
});
run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 5 }),
combined: goAccessReport({ "2026-09-11": 5 }, true)
});
const state = JSON.parse(fs.readFileSync(
testFixture.registry.counterStatePath,
"utf8"
));
const published = JSON.parse(fs.readFileSync(
testFixture.registry.sites[0].publicJsonPath,
"utf8"
));
assert.deepEqual(state.sites.alpha.dailyVisits, {
"2026-01-01": 7,
"2026-09-11": 5
});
assert.deepEqual(published, {
schemaVersion: 1,
visits: 12,
since: "2026-01-01",
updatedAt: "2026-09-11T07:00:00Z"
});
} finally {
testFixture.cleanup();
}
});
test("keeps valid outputs unchanged when a GoAccess job fails", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
const reportDirectory = testFixture.registry.sites[0].reportOutputDirectory;
fs.mkdirSync(reportDirectory, { recursive: true });
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
fs.mkdirSync(path.dirname(testFixture.registry.sites[0].publicJsonPath), { recursive: true });
fs.writeFileSync(path.join(reportDirectory, "report.json"), "old-json");
fs.writeFileSync(path.join(reportDirectory, "report.html"), "old-html");
fs.writeFileSync(testFixture.registry.counterStatePath, "old-state");
fs.writeFileSync(testFixture.registry.sites[0].publicJsonPath, "old-public");
assert.throws(() => run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
}, [], "combined"), /simulated GoAccess failure/);
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.json"), "utf8"), "old-json");
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.html"), "utf8"), "old-html");
assert.equal(fs.readFileSync(testFixture.registry.counterStatePath, "utf8"), "old-state");
assert.equal(fs.readFileSync(testFixture.registry.sites[0].publicJsonPath, "utf8"), "old-public");
} finally {
testFixture.cleanup();
}
});
test("processes subdomains separately and together without publishing disabled counters", () => {
const testFixture = fixture([
{ id: "alpha", publicCounter: true, compressed: true },
{ id: "bravo", publicCounter: false, rotated: false, compressed: true }
]);
const calls = [];
try {
const result = run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 3 }),
bravo: goAccessReport({ "2026-09-11": 4 }),
combined: goAccessReport({ "2026-09-11": 6 }, true)
}, calls);
assert.equal(result.reports, 3);
assert.deepEqual(calls.map(call => call.id), ["alpha", "bravo", "combined"]);
const alphaLogs = [
`${testFixture.registry.sites[0].analyticsLog}.1`,
testFixture.registry.sites[0].analyticsLog
];
const bravoLogs = [testFixture.registry.sites[1].analyticsLog];
assert.deepEqual(calls[0].args.slice(0, 2), alphaLogs);
assert.deepEqual(calls[1].args.slice(0, 1), bravoLogs);
assert.deepEqual(
calls[2].args.slice(0, 3),
[...alphaLogs, ...bravoLogs]
);
assert.equal(calls[0].args.includes("--enable-panel=VIRTUAL_HOSTS"), false);
assert.equal(calls[1].args.includes("--enable-panel=VIRTUAL_HOSTS"), false);
assert.equal(calls[2].args.includes("--enable-panel=VIRTUAL_HOSTS"), true);
assert.equal(calls.some(call => call.args.some(argument => argument.endsWith(".gz"))), false);
assert.equal(fs.existsSync(path.join(
testFixture.registry.stateDirectory,
"public",
"bravo.json"
)), false);
assert.equal(fs.existsSync(path.join(
testFixture.registry.combined.reportOutputDirectory,
"report.html"
)), true);
} finally {
testFixture.cleanup();
}
});
test("validates GoAccess 1.8.1 panel names strictly", () => {
const siteReport = goAccessReport({ "2026-09-11": 3 });
const combinedReport = goAccessReport({ "2026-09-11": 3 }, true);
assert.doesNotThrow(() => validateReport(siteReport, false));
assert.doesNotThrow(() => validateReport(combinedReport, true));
const missingGeolocation = goAccessReport({ "2026-09-11": 3 });
delete missingGeolocation.geolocation;
missingGeolocation.geo_location = { data: [] };
assert.throws(
() => validateReport(missingGeolocation, false),
/GoAccess JSON report has no geolocation panel/
);
const missingVhosts = goAccessReport({ "2026-09-11": 3 }, true);
delete missingVhosts.vhosts;
missingVhosts.virtual_hosts = { data: [] };
assert.throws(
() => validateReport(missingVhosts, true),
/GoAccess JSON report has no vhosts panel/
);
});
test("dry-run validates generated data without changing production paths", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
const blockedLockParent = path.join(testFixture.root, "blocked-lock-parent");
fs.writeFileSync(blockedLockParent, "not a directory");
testFixture.registry.lockFile = path.join(blockedLockParent, "generator.lock");
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
const result = generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess",
dryRun: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess({
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
}, []),
now: () => new Date("2026-09-11T07:00:00Z")
});
assert.equal(result.dryRun, true);
assert.equal(fs.statSync(blockedLockParent).isFile(), true);
assert.equal(fs.readdirSync(
testFixture.registry.sites[0].reportOutputDirectory
).length, 0);
assert.equal(fs.existsSync(testFixture.registry.sites[0].publicJsonPath), false);
assert.equal(fs.existsSync(testFixture.registry.counterStatePath), false);
} finally {
testFixture.cleanup();
}
});
test("configuration check accepts and explains a GoAccess build without Zlib", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
try {
const versionOutput = [
"GoAccess - 1.8.1.",
"Build configure arguments:",
" --enable-geoip=mmdb",
" --with-openssl"
].join("\n");
const capabilities = parseGoAccessVersion(versionOutput);
const result = generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess",
check: true
}, {
checkGoAccess: () => capabilities,
runGoAccess: () => {
throw new Error("configuration check must not generate reports");
}
});
const message = formatGoAccessCheck(result.goAccess);
assert.deepEqual(capabilities, GOACCESS_WITHOUT_ZLIB);
assert.equal(result.checked, true);
assert.match(message, /Zlib not detected/);
assert.match(message, /no Zlib support/);
assert.match(message, /valid for regular mode/);
assert.match(message, /optional uncompressed \.1 rotation/);
assert.match(message, /older \.gz logs are not imported/);
} finally {
testFixture.cleanup();
}
});
test("detects Zlib support when it is present", () => {
const capabilities = parseGoAccessVersion([
"GoAccess - 1.8.1.",
"Build configure arguments:",
" --enable-geoip=mmdb --with-openssl --with-zlib"
].join("\n"));
assert.equal(capabilities.zlib, true);
assert.match(formatGoAccessCheck(capabilities), /Zlib enabled/);
assert.doesNotMatch(formatGoAccessCheck(capabilities), /older \.gz logs/);
});
test("configuration check rejects GoAccess without GeoIP2 MMDB support", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => ({
version: "1.8.1",
geoIpMmdb: false,
openSsl: true,
zlib: false
})
}), error => error.exitCode === 2
&& /must be built with GeoIP2\/MMDB support/.test(error.message));
} finally {
testFixture.cleanup();
}
});
test("configuration check verifies analytics-log readability", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
const originalAccessSync = fs.accessSync;
try {
fs.accessSync = (filePath, mode) => {
if (filePath === testFixture.registry.sites[0].analyticsLog
&& mode === fs.constants.R_OK) {
const error = new Error("permission denied");
error.code = "EACCES";
throw error;
}
return originalAccessSync(filePath, mode);
};
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /analytics log is not readable by the current user/);
} finally {
fs.accessSync = originalAccessSync;
testFixture.cleanup();
}
});
test("configuration check reports missing output directories", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
fs.rmSync(testFixture.registry.sites[0].reportOutputDirectory, {
recursive: true,
force: true
});
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /report output directory for alpha does not exist/);
} finally {
testFixture.cleanup();
}
});
test("rejects a compressed log as the regular analytics input", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
testFixture.registry.sites[0].analyticsLog += ".2.gz";
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /must identify the current uncompressed log/);
} finally {
testFixture.cleanup();
}
});
test("Nginx filters exclude non-page traffic before analytics logging", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/analytics-filters.conf.example"
), "utf8");
for (const excluded of [
"/visitor-count.json",
"/kst4ContestVersionInfo.xml",
"/sitemap.xml",
"/robots.txt",
"manual/assets",
"healthz"
]) {
assert.match(source, new RegExp(excluded.replaceAll("/", "\\/"), "i"));
}
assert.doesNotMatch(source, /^\s*=\//m);
assert.match(source, /^\s*\/visitor-count\.json\s+0;/m);
assert.match(source, /^\s*\/health\s+0;/m);
assert.match(source, /\|map\|/);
assert.match(source, /\$uri/);
assert.match(source, /known_bot/);
});
test("server templates use the production GeoIP path and permission model", () => {
const registry = JSON.parse(fs.readFileSync(path.join(
__dirname,
"../ops/analytics/sites.example.json"
), "utf8"));
const service = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/systemd/hamradioonline-analytics.service.example"
), "utf8");
const generator = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/generate-reports.js"
), "utf8");
assert.equal(registry.geoIpCountryDatabase, "/var/lib/GeoIP/GeoLite2-Country.mmdb");
assert.match(service, /\/var\/lib\/GeoIP\/GeoLite2-Country\.mmdb/);
assert.match(service, /^StateDirectoryMode=0711$/m);
assert.match(generator, /fs\.fchmodSync\(handle, mode\)/);
assert.match(generator, /publicFile\.destination, publicFile\.content, 0o644/);
});
test("public counter template disables logging and sets cache and type headers", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/public-counter.conf.example"
), "utf8");
assert.match(source, /^\s*access_log off;$/m);
assert.match(source, /Cache-Control "public, max-age=3600" always/);
assert.match(source, /X-Content-Type-Options "nosniff" always/);
});
test("statistics vhost preserves ACME renewal and protects HTTPS reports", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/stats-vhost.conf.example"
), "utf8");
const bootstrap = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/stats-vhost-http-bootstrap.conf.example"
), "utf8");
const httpsOffset = source.search(/server\s*\{\s*listen 443 ssl http2;/);
assert.notEqual(httpsOffset, -1);
const httpSource = source.slice(0, httpsOffset);
const httpsSource = source.slice(httpsOffset);
assert.match(httpSource, /^\s*listen 80;$/m);
assert.match(httpSource, /^\s*access_log off;$/m);
assert.match(httpSource, /location \^~ \/\.well-known\/acme-challenge\//);
assert.match(httpSource, /root \/var\/lib\/letsencrypt;/);
assert.match(httpSource, /return 301 https:\/\/\$host\$request_uri;/);
assert.match(httpsSource, /^\s*listen 443 ssl http2;$/m);
assert.match(httpsSource, /^\s*auth_basic "Private project statistics";$/m);
assert.match(httpsSource, /^\s*access_log off;$/m);
assert.match(httpsSource, /location = \/[\s\S]*return 302 \/combined\/;/);
assert.match(httpsSource, /include \/etc\/letsencrypt\/options-ssl-nginx\.conf;/);
assert.match(httpsSource, /ssl_dhparam \/etc\/letsencrypt\/ssl-dhparams\.pem;/);
assert.doesNotMatch(source, /listen \[::\]/);
assert.match(bootstrap, /^\s*listen 80;$/m);
assert.doesNotMatch(bootstrap, /ssl_certificate/);
});
test("logrotate uses the Ubuntu Nginx rotation action", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/logrotate/hamradioonline-analytics.example"
), "utf8");
assert.match(source, /invoke-rc\.d nginx rotate >\/dev\/null 2>&1/);
assert.match(source, /^\s*delaycompress$/m);
assert.match(source, /^\s*rotate 14$/m);
assert.match(source, /^\s*create 0640 www-data hamradio-analytics$/m);
});
test("website package records the Node.js 18.19.1 baseline", () => {
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "../package.json")));
const packageLock = JSON.parse(fs.readFileSync(path.join(__dirname, "../package-lock.json")));
assert.equal(packageJson.engines.node, ">=18.19.1");
assert.equal(packageLock.packages[""].engines.node, ">=18.19.1");
});
-120
View File
@@ -1,120 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const eleventyConfig = require("../.eleventy.js");
test("selects the newest news article without relying on a version", () => {
const older = { date: new Date("2026-08-22"), data: { title: "Older" } };
const newest = { date: new Date("2026-09-03"), data: { title: "Newest" } };
assert.deepEqual(
eleventyConfig.selectLatestNews([older, newest]),
[newest]
);
});
test("an empty news collection omits the latest-news item safely", () => {
assert.deepEqual(eleventyConfig.selectLatestNews([]), []);
assert.deepEqual(eleventyConfig.selectLatestNews(), []);
});
test("homepage keeps the statistical result tied to its limits", () => {
const source = fs.readFileSync(
path.join(__dirname, "../src/index.njk"),
"utf8"
);
assert.match(source, /almost 30% higher chat reply rate/);
assert.match(source, /chat replies, not completed QSOs/);
assert.match(source, /correlation within[\s\S]*not general proof of causation/);
assert.match(source, /July 2025 contest/);
assert.match(source, /144\/432 MHz and Microwave categories/);
});
test("background page links all public papers and no ODT source", () => {
const source = fs.readFileSync(
path.join(__dirname, "../src/background/index.njk"),
"utf8"
);
for (const name of [
"kst4contest-ghz-tagung-2025-de.pdf",
"kst4contest-ghz-tagung-2025-en.pdf",
"kst4contest-ghz-tagung-2026-de.pdf",
"kst4contest-ghz-tagung-2026-en.pdf"
]) {
assert.match(source, new RegExp(name.replaceAll(".", "\\.")));
}
assert.doesNotMatch(source, /\.odt\b/i);
});
test("homepage contains one selected function grid and a complete-overview link", () => {
const source = fs.readFileSync(
path.join(__dirname, "../src/index.njk"),
"utf8"
);
assert.equal((source.match(/<h2>The parts most often used during a contest<\/h2>/g) || []).length, 1);
assert.match(source, /href="\/features\/"[^>]*>Open the complete function overview/);
assert.doesNotMatch(source, /Observe|Evaluate|>Act<|One workflow, shared context/);
});
test("screenshot page uses the four approved originals as full-size links", () => {
const page = fs.readFileSync(
path.join(__dirname, "../src/screenshots/index.njk"),
"utf8"
);
const css = fs.readFileSync(
path.join(__dirname, "../src/assets/css/main.css"),
"utf8"
);
const screenshots = require("../src/_data/screenshots.js");
assert.equal(screenshots.length, 4);
assert.deepEqual(
screenshots.map(({ image }) => image),
[
"/assets/completeViewLight.png",
"/assets/completeViewDark.png",
"/assets/timeline.png",
"/assets/complete_incl_AS.png"
]
);
assert.ok(screenshots.every(({ alt, caption }) => alt && caption));
assert.equal(screenshots.find(({ id }) => id === "timeline").wide, true);
assert.doesNotMatch(JSON.stringify(screenshots), /Priority Candidates/);
assert.match(page, /href="{{ shot\.image }}"[\s\S]*<img[^>]*src="{{ shot\.image }}"/);
assert.match(page, /Version-like text inside chat messages is[\s\S]*not an announcement/);
assert.match(css, /\.screenshot-image\s*\{[\s\S]*?width:\s*100%;[\s\S]*?height:\s*auto;/);
assert.match(css, /\.screenshot-card--wide\s*\{\s*grid-column:\s*1\s*\/\s*-1;/);
});
test("footer and download page expose background and conference papers", () => {
const layout = fs.readFileSync(
path.join(__dirname, "../src/_layouts/base.njk"),
"utf8"
);
const download = fs.readFileSync(
path.join(__dirname, "../src/download/index.njk"),
"utf8"
);
assert.match(layout, /<p><a href="\/background\/">Background<\/a><\/p>/);
for (const name of [
"kst4contest-ghz-tagung-2025-de.pdf",
"kst4contest-ghz-tagung-2025-en.pdf",
"kst4contest-ghz-tagung-2026-de.pdf",
"kst4contest-ghz-tagung-2026-en.pdf"
]) {
assert.match(download, new RegExp(`/assets/papers/${name.replaceAll(".", "\\.")}`));
}
assert.match(download, /href="\/background\/#paper-2025"/);
assert.match(download, /href="\/background\/#paper-2026"/);
assert.match(download, /historical conference papers[\s\S]*not a complete description of the current feature set/);
assert.ok(
download.indexOf("Background from GHz-Tagung Dorsten") >
download.indexOf('<div class="channel-panel" data-channel="nightly">')
);
});
-119
View File
@@ -1,119 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const {
formatVisitorCount,
loadVisitorCount,
validateVisitorCount
} = require("../src/assets/js/visitor-count");
const VALID_DATA = {
schemaVersion: 1,
visits: 1234,
since: "2026-09-11",
updatedAt: "2026-09-11T07:00:00Z"
};
function element() {
return { hidden: true, textContent: "" };
}
test("formats valid data for the English website", async () => {
const target = element();
let request;
const shown = await loadVisitorCount({
element: target,
fetchImpl: async (url, options) => {
request = { url, options };
return { ok: true, json: async () => VALID_DATA };
}
});
assert.equal(shown, true);
assert.equal(target.hidden, false);
assert.equal(target.textContent, "Visits since 11 September 2026: 1,234");
assert.equal(request.url, "/visitor-count.json");
assert.equal(request.options.credentials, "omit");
});
test("rejects unsupported schemas and invalid visit counts", () => {
for (const changes of [
{ schemaVersion: 2 },
{ visits: -1 },
{ visits: 1.5 },
{ visits: Number.MAX_SAFE_INTEGER + 1 }
]) {
assert.equal(validateVisitorCount({ ...VALID_DATA, ...changes }), null);
}
});
test("rejects invalid dates and timestamps without timezone shifts", () => {
for (const changes of [
{ since: "2026-02-30" },
{ since: "11-09-2026" },
{ updatedAt: "2026-09-11" },
{ updatedAt: "2026-02-30T07:00:00Z" },
{ updatedAt: "2026-09-11T25:00:00Z" },
{ updatedAt: "not-a-timestamp" }
]) {
assert.equal(formatVisitorCount({ ...VALID_DATA, ...changes }), null);
}
});
test("keeps the element hidden for HTTP and fetch failures", async () => {
for (const fetchImpl of [
async () => ({ ok: false }),
async () => { throw new Error("network failure"); }
]) {
const target = element();
assert.equal(await loadVisitorCount({ element: target, fetchImpl }), false);
assert.equal(target.hidden, true);
assert.equal(target.textContent, "");
}
});
test("aborts a slow request and keeps the element hidden", async () => {
const target = element();
let aborted = false;
const fetchImpl = (url, options) => new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
});
assert.equal(await loadVisitorCount({
element: target,
fetchImpl,
timeoutMs: 5
}), false);
assert.equal(aborted, true);
assert.equal(target.hidden, true);
});
test("uses textContent and loads the script only for the home page", () => {
const script = fs.readFileSync(path.join(
__dirname,
"../src/assets/js/visitor-count.js"
), "utf8");
const home = fs.readFileSync(path.join(__dirname, "../src/index.njk"), "utf8");
const layout = fs.readFileSync(path.join(
__dirname,
"../src/_layouts/base.njk"
), "utf8");
const sourceRoot = path.join(__dirname, "../src");
const otherPages = fs.readdirSync(sourceRoot, { recursive: true })
.filter(entry => entry.endsWith(".njk") && entry !== "index.njk");
assert.doesNotMatch(script, /innerHTML/);
assert.match(script, /textContent/);
assert.match(home, /^visitorCount: true$/m);
assert.match(layout, /\{% if visitorCount %\}/);
assert.equal(
otherPages.some(entry => fs.readFileSync(path.join(sourceRoot, entry), "utf8")
.includes("visitorCount: true")),
false
);
});