[refactor](trx-client): remove Qt/QML frontend support

Remove the Linux-only Qt/QML frontend (trx-frontend-qt) crate and all
references to it from the workspace, trx-client binary, configuration,
and documentation. This prepares for replacement with a native macOS
AppKit frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-07 09:10:46 +01:00
parent 004eea0000
commit 2c128127e6
13 changed files with 19 additions and 729 deletions
-5
View File
@@ -22,8 +22,3 @@ trx-frontend = { path = "trx-frontend" }
trx-frontend-http = { path = "trx-frontend/trx-frontend-http" }
trx-frontend-http-json = { path = "trx-frontend/trx-frontend-http-json" }
trx-frontend-rigctl = { path = "trx-frontend/trx-frontend-rigctl" }
trx-frontend-qt = { path = "trx-frontend/trx-frontend-qt", optional = true }
[features]
default = []
qt-frontend = ["trx-frontend-qt/qt"]
+1 -16
View File
@@ -67,7 +67,7 @@ pub struct RemoteAuthConfig {
pub token: Option<String>,
}
/// Frontend configurations (client — includes Qt).
/// Frontend configurations.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct FrontendsConfig {
@@ -77,8 +77,6 @@ pub struct FrontendsConfig {
pub rigctl: RigctlFrontendConfig,
/// JSON TCP frontend settings
pub http_json: HttpJsonFrontendConfig,
/// Qt/QML frontend settings
pub qt: QtFrontendConfig,
}
/// HTTP frontend configuration.
@@ -158,14 +156,6 @@ pub struct HttpJsonAuthConfig {
pub tokens: Vec<String>,
}
/// Qt/QML frontend configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct QtFrontendConfig {
/// Whether Qt frontend is enabled
pub enabled: bool,
}
impl ClientConfig {
/// Load configuration from a specific file path.
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
@@ -235,7 +225,6 @@ impl ClientConfig {
port: 4532,
},
http_json: HttpJsonFrontendConfig::default(),
qt: QtFrontendConfig { enabled: false },
},
};
@@ -290,7 +279,6 @@ mod tests {
assert_eq!(config.frontends.rigctl.port, 4532);
assert!(config.frontends.http_json.enabled);
assert_eq!(config.frontends.http_json.port, 0);
assert!(!config.frontends.qt.enabled);
assert!(config.remote.url.is_none());
assert_eq!(config.remote.poll_interval_ms, 750);
}
@@ -311,8 +299,6 @@ enabled = true
listen = "127.0.0.1"
port = 8080
[frontends.qt]
enabled = true
"#;
let config: ClientConfig = toml::from_str(toml_str).unwrap();
@@ -321,7 +307,6 @@ enabled = true
assert_eq!(config.remote.auth.token, Some("my-token".to_string()));
assert_eq!(config.remote.poll_interval_ms, 500);
assert!(config.frontends.http.enabled);
assert!(config.frontends.qt.enabled);
}
#[test]
+1 -11
View File
@@ -25,15 +25,11 @@ use trx_frontend_http::register_frontend as register_http_frontend;
use trx_frontend_http_json::{register_frontend as register_http_json_frontend, set_auth_tokens};
use trx_frontend_rigctl::register_frontend as register_rigctl_frontend;
#[cfg(feature = "qt-frontend")]
use trx_frontend_qt::register_frontend as register_qt_frontend;
use config::ClientConfig;
use remote_client::{parse_remote_url, RemoteClientConfig};
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - remote rig client");
const RIG_TASK_CHANNEL_BUFFER: usize = 32;
const QT_FRONTEND_LISTEN_ADDR: ([u8; 4], u16) = ([127, 0, 0, 1], 0);
#[derive(Debug, Parser)]
#[command(
@@ -57,7 +53,7 @@ struct Cli {
/// Poll interval in milliseconds
#[arg(long = "poll-interval")]
poll_interval_ms: Option<u64>,
/// Frontend(s) to expose locally (e.g. http,rigctl,qt)
/// Frontend(s) to expose locally (e.g. http,rigctl)
#[arg(short = 'f', long = "frontend", value_delimiter = ',', num_args = 1..)]
frontends: Option<Vec<String>>,
/// HTTP frontend listen address
@@ -98,8 +94,6 @@ async fn main() -> DynResult<()> {
register_http_frontend();
register_http_json_frontend();
register_rigctl_frontend();
#[cfg(feature = "qt-frontend")]
register_qt_frontend();
let _plugin_libs = plugins::load_plugins();
let cli = Cli::parse();
@@ -155,9 +149,6 @@ async fn main() -> DynResult<()> {
if cfg.frontends.http_json.enabled {
fes.push("httpjson".to_string());
}
if cfg.frontends.qt.enabled {
fes.push("qt".to_string());
}
if fes.is_empty() {
fes.push("http".to_string());
}
@@ -239,7 +230,6 @@ async fn main() -> DynResult<()> {
"http" => SocketAddr::from((http_listen, http_port)),
"rigctl" => SocketAddr::from((rigctl_listen, rigctl_port)),
"httpjson" => SocketAddr::from((http_json_listen, http_json_port)),
"qt" => SocketAddr::from(QT_FRONTEND_LISTEN_ADDR),
other => {
return Err(format!("Frontend missing listen configuration: {}", other).into());
}
@@ -1,24 +0,0 @@
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
#
# SPDX-License-Identifier: BSD-2-Clause
[package]
name = "trx-frontend-qt"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
qt = ["dep:qmetaobject"]
[dependencies]
trx-core = { path = "../../../trx-core" }
trx-frontend = { path = ".." }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
qmetaobject = { version = "0.2", optional = true }
@@ -1,36 +0,0 @@
# Qt QML Frontend Requirements
## Scope
- Provide a Qt Quick (QML) GUI frontend for trx-rs.
- Linux-only support for the initial implementation.
- Use system-wide Qt6 (no vendored Qt).
- Frontend must be optional and feature-gated; default build should not require Qt.
- Feature name in `trx-client`: `qt-frontend`.
## Functional Requirements
- Show rig status: frequency, mode, PTT state, VFO info, lock state, power state.
- Show basic meters when available: RX signal, TX power/limit/SWR/ALC (as provided by state).
- Allow commands: set frequency, set mode, toggle PTT, power on/off, toggle VFO, lock/unlock, set TX limit (if supported).
- Reflect live updates pushed from the rig task (watch updates).
## Non-Functional Requirements
- Linux-only for now.
- Build relies on Qt6 libraries/headers installed on the system.
- GUI must be responsive and not block the rig task or frontend thread.
- Minimal but clear UI; no advanced theming or custom widgets required yet.
## Configuration & Integration
- Expose as a new frontend crate: `trx-frontend-qt`.
- Register via frontend registry under name: `qt`.
- Optional via feature flag (e.g., `qt`) and not part of default workspace features.
- Provide config toggles under `[frontends.qt]` for enable/listen if needed.
- Remote client mode uses JSON TCP with bearer token via `frontends.qt.remote.*`.
## Packaging/Build
- Document required packages (Qt6 base + QML modules + qmetaobject-rs build prereqs).
- Provide build/run instructions in README/OVERVIEW updates.
## Out of Scope (for v1)
- Windows/macOS support.
- Offline themes or custom QML assets.
- Advanced settings editor or multi-rig management.
@@ -1,102 +0,0 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
ApplicationWindow {
id: root
visible: true
width: 900
height: 540
title: "trx-rs"
Column {
anchors.centerIn: parent
spacing: 10
Label {
text: "trx-rs Qt frontend (stub)"
font.pixelSize: 20
}
Label { text: "Frequency: " + rig.freq_text + " (" + rig.freq_hz + " Hz)" }
Label { text: "Mode: " + rig.mode + " Band: " + rig.band }
Label { text: "PTT: " + (rig.tx_enabled ? "TX" : "RX") + " Power: " + (rig.powered ? "On" : "Off") }
Label { text: "Lock: " + (rig.locked ? "Locked" : "Unlocked") }
Label { text: "RX Sig: " + rig.rx_sig + " dB" }
Label { text: "TX Pwr: " + rig.tx_power + " Limit: " + rig.tx_limit + " SWR: " + rig.tx_swr + " ALC: " + rig.tx_alc }
Row {
spacing: 6
TextField {
id: freqInput
width: 140
placeholderText: "Freq (Hz)"
}
Button {
text: "Set Freq"
onClicked: rig.set_freq_hz(parseInt(freqInput.text))
}
TextField {
id: modeInput
width: 80
placeholderText: "Mode"
}
Button {
text: "Set Mode"
onClicked: rig.set_mode(modeInput.text)
}
}
Row {
spacing: 6
Button {
text: rig.tx_enabled ? "PTT Off" : "PTT On"
onClicked: rig.toggle_ptt()
}
Button {
text: rig.powered ? "Power Off" : "Power On"
onClicked: rig.toggle_power()
}
Button {
text: "VFO"
onClicked: rig.toggle_vfo()
}
Button {
text: rig.locked ? "Unlock" : "Lock"
onClicked: rig.locked ? rig.unlock_panel() : rig.lock_panel()
}
}
Row {
spacing: 6
TextField {
id: txLimitInput
width: 120
placeholderText: "TX Limit"
}
Button {
text: "Set Limit"
onClicked: rig.set_tx_limit(parseInt(txLimitInput.text))
}
}
Rectangle {
width: 540
height: 120
color: "#20252b"
radius: 6
Text {
anchors.fill: parent
anchors.margins: 8
color: "#d0d6de"
text: rig.vfo
font.family: "monospace"
}
}
}
}
@@ -1,17 +0,0 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
#[cfg(all(target_os = "linux", feature = "qt"))]
pub mod server;
#[cfg(all(target_os = "linux", feature = "qt"))]
pub fn register_frontend() {
use trx_frontend::FrontendSpawner;
trx_frontend::register_frontend("qt", server::QtFrontend::spawn_frontend);
}
#[cfg(not(all(target_os = "linux", feature = "qt")))]
pub fn register_frontend() {
// No-op on non-Linux platforms.
}
@@ -1,360 +0,0 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use std::cell::RefCell;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::thread;
use qmetaobject::{
qt_base_class, qt_method, qt_property, qt_signal, queued_callback, QObject, QObjectPinned,
QString, QmlEngine,
};
use tokio::sync::{mpsc, oneshot, watch};
use tokio::task::JoinHandle;
use tracing::{info, warn};
use trx_core::rig::command::RigCommand;
use trx_core::rig::state::RigMode;
use trx_core::{RigRequest, RigState};
use trx_frontend::FrontendSpawner;
/// Qt/QML frontend (Linux-only).
pub struct QtFrontend;
impl FrontendSpawner for QtFrontend {
fn spawn_frontend(
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
_callsign: Option<String>,
listen_addr: SocketAddr,
) -> JoinHandle<()> {
tokio::spawn(async move {
let (update_tx, update_rx) = oneshot::channel::<Box<dyn Fn(RigState) + Send + Sync>>();
spawn_qt_thread(update_tx, listen_addr, rig_tx);
spawn_state_watcher(state_rx, update_rx).await;
})
}
}
fn spawn_qt_thread(
update_tx: oneshot::Sender<Box<dyn Fn(RigState) + Send + Sync>>,
listen_addr: SocketAddr,
rig_tx: mpsc::Sender<RigRequest>,
) {
thread::spawn(move || {
let model_cell = Box::leak(Box::new(RefCell::new(RigStateModel::default())));
let model_ptr = model_cell.as_ptr();
model_cell.borrow_mut().rig_tx = Some(rig_tx);
let update = queued_callback(move |state: RigState| unsafe {
// Safe as queued_callback executes on the Qt thread where the model lives.
let model_cell = &mut *model_ptr;
update_model(model_cell, &state);
});
if update_tx.send(Box::new(update)).is_err() {
warn!("Qt frontend update channel dropped before init");
}
let mut engine = QmlEngine::new();
engine.set_object_property("rig".into(), unsafe { QObjectPinned::new(model_cell) });
let qml_path = qml_main_path();
info!("Qt frontend loading QML from {}", qml_path.display());
engine.load_file(QString::from(qml_path.to_string_lossy().to_string()));
info!("Qt frontend running (addr hint: {})", listen_addr);
engine.exec();
});
}
async fn spawn_state_watcher(
mut state_rx: watch::Receiver<RigState>,
update_rx: oneshot::Receiver<Box<dyn Fn(RigState) + Send + Sync>>,
) {
let Ok(update) = update_rx.await else {
warn!("Qt frontend update channel closed");
return;
};
update(state_rx.borrow().clone());
while state_rx.changed().await.is_ok() {
update(state_rx.borrow().clone());
}
}
fn qml_main_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("qml")
.join("Main.qml")
}
#[derive(QObject, Default)]
struct RigStateModel {
base: qt_base_class!(trait QObject),
rig_tx: Option<mpsc::Sender<RigRequest>>,
freq_hz: qt_property!(u64; NOTIFY freq_hz_changed),
freq_hz_changed: qt_signal!(),
freq_text: qt_property!(QString; NOTIFY freq_text_changed),
freq_text_changed: qt_signal!(),
mode: qt_property!(QString; NOTIFY mode_changed),
mode_changed: qt_signal!(),
band: qt_property!(QString; NOTIFY band_changed),
band_changed: qt_signal!(),
tx_enabled: qt_property!(bool; NOTIFY tx_enabled_changed),
tx_enabled_changed: qt_signal!(),
locked: qt_property!(bool; NOTIFY locked_changed),
locked_changed: qt_signal!(),
powered: qt_property!(bool; NOTIFY powered_changed),
powered_changed: qt_signal!(),
rx_sig: qt_property!(i32; NOTIFY rx_sig_changed),
rx_sig_changed: qt_signal!(),
tx_power: qt_property!(i32; NOTIFY tx_power_changed),
tx_power_changed: qt_signal!(),
tx_limit: qt_property!(i32; NOTIFY tx_limit_changed),
tx_limit_changed: qt_signal!(),
tx_swr: qt_property!(f64; NOTIFY tx_swr_changed),
tx_swr_changed: qt_signal!(),
tx_alc: qt_property!(i32; NOTIFY tx_alc_changed),
tx_alc_changed: qt_signal!(),
vfo: qt_property!(QString; NOTIFY vfo_changed),
vfo_changed: qt_signal!(),
set_freq_hz: qt_method!(
fn set_freq_hz(&self, hz: i64) {
if hz <= 0 {
return;
}
self.send_command(RigCommand::SetFreq(trx_core::radio::freq::Freq {
hz: hz as u64,
}));
}
),
set_mode: qt_method!(
fn set_mode(&self, mode: QString) {
let mode = parse_mode(&mode.to_string());
self.send_command(RigCommand::SetMode(mode));
}
),
toggle_ptt: qt_method!(
fn toggle_ptt(&self) {
self.send_command(RigCommand::SetPtt(!self.tx_enabled));
}
),
toggle_power: qt_method!(
fn toggle_power(&self) {
if self.powered {
self.send_command(RigCommand::PowerOff);
} else {
self.send_command(RigCommand::PowerOn);
}
}
),
toggle_vfo: qt_method!(
fn toggle_vfo(&self) {
self.send_command(RigCommand::ToggleVfo);
}
),
lock_panel: qt_method!(
fn lock_panel(&self) {
self.send_command(RigCommand::Lock);
}
),
unlock_panel: qt_method!(
fn unlock_panel(&self) {
self.send_command(RigCommand::Unlock);
}
),
set_tx_limit: qt_method!(
fn set_tx_limit(&self, limit: i32) {
if limit < 0 {
return;
}
self.send_command(RigCommand::SetTxLimit(limit as u8));
}
),
}
impl RigStateModel {
fn send_command(&self, cmd: RigCommand) {
let Some(tx) = self.rig_tx.as_ref() else {
warn!("Qt frontend: rig command dropped (channel not set)");
return;
};
let (resp_tx, _resp_rx) = oneshot::channel();
if tx
.blocking_send(RigRequest {
cmd,
respond_to: resp_tx,
})
.is_err()
{
warn!("Qt frontend: rig command send failed");
}
}
}
fn update_model(model: &mut RigStateModel, state: &RigState) {
let freq_hz = state.status.freq.hz;
if model.freq_hz != freq_hz {
model.freq_hz = freq_hz;
model.freq_hz_changed();
}
let freq_text = QString::from(format_freq(freq_hz));
if model.freq_text != freq_text {
model.freq_text = freq_text;
model.freq_text_changed();
}
let mode = QString::from(mode_label(&state.status.mode));
if model.mode != mode {
model.mode = mode;
model.mode_changed();
}
let band = QString::from(state.band_name().unwrap_or_else(|| "--".to_string()));
if model.band != band {
model.band = band;
model.band_changed();
}
if model.tx_enabled != state.status.tx_en {
model.tx_enabled = state.status.tx_en;
model.tx_enabled_changed();
}
let locked = state.status.lock.unwrap_or(false);
if model.locked != locked {
model.locked = locked;
model.locked_changed();
}
let powered = state.control.enabled.unwrap_or(false);
if model.powered != powered {
model.powered = powered;
model.powered_changed();
}
let rx_sig = state.status.rx.as_ref().and_then(|rx| rx.sig).unwrap_or(0);
if model.rx_sig != rx_sig {
model.rx_sig = rx_sig;
model.rx_sig_changed();
}
let tx_power = state
.status
.tx
.as_ref()
.and_then(|tx| tx.power)
.map(i32::from)
.unwrap_or(0);
if model.tx_power != tx_power {
model.tx_power = tx_power;
model.tx_power_changed();
}
let tx_limit = state
.status
.tx
.as_ref()
.and_then(|tx| tx.limit)
.map(i32::from)
.unwrap_or(0);
if model.tx_limit != tx_limit {
model.tx_limit = tx_limit;
model.tx_limit_changed();
}
let tx_swr = state
.status
.tx
.as_ref()
.and_then(|tx| tx.swr)
.unwrap_or(0.0) as f64;
if (model.tx_swr - tx_swr).abs() > f64::EPSILON {
model.tx_swr = tx_swr;
model.tx_swr_changed();
}
let tx_alc = state
.status
.tx
.as_ref()
.and_then(|tx| tx.alc)
.map(i32::from)
.unwrap_or(0);
if model.tx_alc != tx_alc {
model.tx_alc = tx_alc;
model.tx_alc_changed();
}
let vfo = QString::from(vfo_label(state));
if model.vfo != vfo {
model.vfo = vfo;
model.vfo_changed();
}
}
fn format_freq(hz: u64) -> String {
if hz >= 1_000_000_000 {
format!("{:.3} GHz", hz as f64 / 1_000_000_000.0)
} else if hz >= 10_000_000 {
format!("{:.3} MHz", hz as f64 / 1_000_000.0)
} else if hz >= 1_000 {
format!("{:.1} kHz", hz as f64 / 1_000.0)
} else {
format!("{hz} Hz")
}
}
fn mode_label(mode: &RigMode) -> String {
match mode {
RigMode::LSB => "LSB".to_string(),
RigMode::USB => "USB".to_string(),
RigMode::CW => "CW".to_string(),
RigMode::CWR => "CWR".to_string(),
RigMode::AM => "AM".to_string(),
RigMode::WFM => "WFM".to_string(),
RigMode::FM => "FM".to_string(),
RigMode::DIG => "DIG".to_string(),
RigMode::PKT => "PKT".to_string(),
RigMode::Other(val) => val.clone(),
}
}
fn parse_mode(value: &str) -> RigMode {
match value.trim().to_uppercase().as_str() {
"LSB" => RigMode::LSB,
"USB" => RigMode::USB,
"CW" => RigMode::CW,
"CWR" => RigMode::CWR,
"AM" => RigMode::AM,
"FM" => RigMode::FM,
"WFM" => RigMode::WFM,
"DIG" | "DIGI" => RigMode::DIG,
"PKT" | "PACKET" => RigMode::PKT,
other => RigMode::Other(other.to_string()),
}
}
fn vfo_label(state: &RigState) -> String {
let Some(vfo) = state.status.vfo.as_ref() else {
return "--".to_string();
};
let mut lines = Vec::new();
for (idx, entry) in vfo.entries.iter().enumerate() {
let marker = if vfo.active == Some(idx) { "*" } else { " " };
let freq = format_freq(entry.freq.hz);
let mode = entry
.mode
.as_ref()
.map(mode_label)
.unwrap_or_else(|| "--".to_string());
lines.push(format!("{marker} {}: {} {}", entry.name, freq, mode));
}
lines.join("\\n")
}