[chore](trx-client): remove appkit frontend support

Remove macOS AppKit frontend (trx-frontend-appkit) and related code:
- Delete appkit crate directory
- Remove appkit dependency and feature from Cargo.toml
- Remove appkit imports, main thread handling, and config from main.rs
- Remove AppKit config struct from config.rs
- Remove appkit section from example config

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-12 20:06:01 +01:00
parent a53bd7a08f
commit 55fde37924
12 changed files with 4 additions and 1082 deletions
Generated
-185
View File
@@ -373,15 +373,6 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.2" version = "8.0.2"
@@ -723,16 +714,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "dispatch2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
"objc2",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -1426,158 +1407,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "objc2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.10.0",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.10.0",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-foundation",
]
[[package]] [[package]]
name = "oboe" name = "oboe"
version = "0.6.1" version = "0.6.1"
@@ -2390,7 +2219,6 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"trx-core", "trx-core",
"trx-frontend", "trx-frontend",
"trx-frontend-appkit",
"trx-frontend-http", "trx-frontend-http",
"trx-frontend-http-json", "trx-frontend-http-json",
"trx-frontend-rigctl", "trx-frontend-rigctl",
@@ -2414,19 +2242,6 @@ dependencies = [
"trx-core", "trx-core",
] ]
[[package]]
name = "trx-frontend-appkit"
version = "0.1.0"
dependencies = [
"objc2",
"objc2-app-kit",
"objc2-foundation",
"tokio",
"tracing",
"trx-core",
"trx-frontend",
]
[[package]] [[package]]
name = "trx-frontend-http" name = "trx-frontend-http"
version = "0.1.0" version = "0.1.0"
-1
View File
@@ -13,7 +13,6 @@ members = [
"src/trx-client/trx-frontend", "src/trx-client/trx-frontend",
"src/trx-client/trx-frontend/trx-frontend-http", "src/trx-client/trx-frontend/trx-frontend-http",
"src/trx-client/trx-frontend/trx-frontend-http-json", "src/trx-client/trx-frontend/trx-frontend-http-json",
"src/trx-client/trx-frontend/trx-frontend-appkit",
"src/trx-client/trx-frontend/trx-frontend-rigctl", "src/trx-client/trx-frontend/trx-frontend-rigctl",
"src/trx-core", "src/trx-core",
] ]
-2
View File
@@ -23,8 +23,6 @@ trx-frontend = { path = "trx-frontend" }
trx-frontend-http = { path = "trx-frontend/trx-frontend-http" } trx-frontend-http = { path = "trx-frontend/trx-frontend-http" }
trx-frontend-http-json = { path = "trx-frontend/trx-frontend-http-json" } trx-frontend-http-json = { path = "trx-frontend/trx-frontend-http-json" }
trx-frontend-rigctl = { path = "trx-frontend/trx-frontend-rigctl" } trx-frontend-rigctl = { path = "trx-frontend/trx-frontend-rigctl" }
trx-frontend-appkit = { path = "trx-frontend/trx-frontend-appkit", optional = true }
[features] [features]
default = [] default = []
appkit-frontend = ["trx-frontend-appkit/appkit"]
-11
View File
@@ -86,8 +86,6 @@ pub struct FrontendsConfig {
pub rigctl: RigctlFrontendConfig, pub rigctl: RigctlFrontendConfig,
/// JSON TCP frontend settings /// JSON TCP frontend settings
pub http_json: HttpJsonFrontendConfig, pub http_json: HttpJsonFrontendConfig,
/// AppKit (macOS) frontend settings
pub appkit: AppKitFrontendConfig,
/// Audio streaming settings /// Audio streaming settings
pub audio: AudioClientConfig, pub audio: AudioClientConfig,
} }
@@ -188,14 +186,6 @@ pub struct HttpJsonAuthConfig {
pub tokens: Vec<String>, pub tokens: Vec<String>,
} }
/// AppKit (macOS) frontend configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AppKitFrontendConfig {
/// Whether AppKit frontend is enabled
pub enabled: bool,
}
impl ClientConfig { impl ClientConfig {
/// Load configuration from a specific file path. /// Load configuration from a specific file path.
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> { pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
@@ -265,7 +255,6 @@ impl ClientConfig {
port: 4532, port: 4532,
}, },
http_json: HttpJsonFrontendConfig::default(), http_json: HttpJsonFrontendConfig::default(),
appkit: AppKitFrontendConfig { enabled: false },
audio: AudioClientConfig::default(), audio: AudioClientConfig::default(),
}, },
}; };
+4 -51
View File
@@ -30,11 +30,6 @@ use trx_frontend_http::{register_frontend as register_http_frontend, set_audio_c
use trx_frontend_http_json::{register_frontend as register_http_json_frontend, set_auth_tokens}; 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; use trx_frontend_rigctl::register_frontend as register_rigctl_frontend;
#[cfg(feature = "appkit-frontend")]
use trx_frontend_appkit::register_frontend as register_appkit_frontend;
#[cfg(feature = "appkit-frontend")]
use trx_frontend_appkit::run_appkit_main_thread;
use config::ClientConfig; use config::ClientConfig;
use remote_client::{parse_remote_url, RemoteClientConfig}; use remote_client::{parse_remote_url, RemoteClientConfig};
@@ -99,27 +94,8 @@ fn normalize_name(name: &str) -> String {
fn main() -> DynResult<()> { fn main() -> DynResult<()> {
let rt = tokio::runtime::Runtime::new()?; let rt = tokio::runtime::Runtime::new()?;
#[allow(unused_variables)] let _app_state = rt.block_on(async_init())?;
let app_state = rt.block_on(async_init())?;
#[cfg(feature = "appkit-frontend")]
if app_state.has_appkit {
// Keep a runtime context active on the main thread so that
// tokio::spawn inside run_appkit_main_thread works.
let _guard = rt.enter();
// AppKit needs the process main thread. Spawn Ctrl+C handler on the
// runtime, then hand main thread to AppKit (blocks forever).
rt.spawn(async {
signal::ctrl_c().await.ok();
info!("Ctrl+C received, shutting down");
std::process::exit(0);
});
run_appkit_main_thread(app_state.state_rx, app_state.rig_tx);
unreachable!();
}
// No AppKit — block on Ctrl+C as before.
rt.block_on(async { rt.block_on(async {
signal::ctrl_c().await?; signal::ctrl_c().await?;
info!("Ctrl+C received, shutting down"); info!("Ctrl+C received, shutting down");
@@ -128,14 +104,7 @@ fn main() -> DynResult<()> {
} }
/// Holds the state needed after async initialization completes. /// Holds the state needed after async initialization completes.
struct AppState { struct AppState;
#[allow(dead_code)]
has_appkit: bool,
#[cfg(feature = "appkit-frontend")]
state_rx: watch::Receiver<RigState>,
#[cfg(feature = "appkit-frontend")]
rig_tx: mpsc::Sender<RigRequest>,
}
async fn async_init() -> DynResult<AppState> { async fn async_init() -> DynResult<AppState> {
tracing_subscriber::fmt().with_target(false).init(); tracing_subscriber::fmt().with_target(false).init();
@@ -143,8 +112,6 @@ async fn async_init() -> DynResult<AppState> {
register_http_frontend(); register_http_frontend();
register_http_json_frontend(); register_http_json_frontend();
register_rigctl_frontend(); register_rigctl_frontend();
#[cfg(feature = "appkit-frontend")]
register_appkit_frontend();
let _plugin_libs = plugins::load_plugins(); let _plugin_libs = plugins::load_plugins();
let cli = Cli::parse(); let cli = Cli::parse();
@@ -200,9 +167,6 @@ async fn async_init() -> DynResult<AppState> {
if cfg.frontends.http_json.enabled { if cfg.frontends.http_json.enabled {
fes.push("httpjson".to_string()); fes.push("httpjson".to_string());
} }
if cfg.frontends.appkit.enabled {
fes.push("appkit".to_string());
}
if fes.is_empty() { if fes.is_empty() {
fes.push("http".to_string()); fes.push("http".to_string());
} }
@@ -232,8 +196,6 @@ async fn async_init() -> DynResult<AppState> {
.clone() .clone()
.or_else(|| cfg.general.callsign.clone()); .or_else(|| cfg.general.callsign.clone());
let has_appkit = frontends.iter().any(|f| f == "appkit");
info!( info!(
"Starting trx-client (remote: {}, frontends: {})", "Starting trx-client (remote: {}, frontends: {})",
remote_addr, remote_addr,
@@ -327,11 +289,8 @@ async fn async_init() -> DynResult<AppState> {
info!("Audio disabled in config, decode will not be available"); info!("Audio disabled in config, decode will not be available");
} }
// Spawn frontends (skip appkit — it will be driven from main thread) // Spawn frontends
for frontend in &frontends { for frontend in &frontends {
if frontend == "appkit" {
continue;
}
let frontend_state_rx = state_rx.clone(); let frontend_state_rx = state_rx.clone();
let addr = match frontend.as_str() { let addr = match frontend.as_str() {
"http" => SocketAddr::from((http_listen, http_port)), "http" => SocketAddr::from((http_listen, http_port)),
@@ -350,11 +309,5 @@ async fn async_init() -> DynResult<AppState> {
)?; )?;
} }
Ok(AppState { Ok(AppState)
has_appkit,
#[cfg(feature = "appkit-frontend")]
state_rx,
#[cfg(feature = "appkit-frontend")]
rig_tx: tx,
})
} }
@@ -1,30 +0,0 @@
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
#
# SPDX-License-Identifier: BSD-2-Clause
[package]
name = "trx-frontend-appkit"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
appkit = ["dep:objc2", "dep:objc2-foundation", "dep:objc2-app-kit"]
[dependencies]
trx-core = { path = "../../../trx-core" }
trx-frontend = { path = ".." }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6", optional = true }
objc2-foundation = { version = "0.3", optional = true, features = ["NSString", "NSThread", "NSRunLoop"] }
objc2-app-kit = { version = "0.3", optional = true, features = [
"NSApplication", "NSWindow", "NSView", "NSTextField",
"NSButton", "NSStackView", "NSColor", "NSResponder",
"NSControl", "NSRunningApplication", "NSGraphics"
] }
@@ -1,66 +0,0 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use trx_core::rig::state::{RigMode, RigState};
pub 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")
}
}
pub 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(),
}
}
pub 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()),
}
}
pub 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")
}
@@ -1,26 +0,0 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
#[cfg(all(target_os = "macos", feature = "appkit"))]
pub mod helpers;
#[cfg(all(target_os = "macos", feature = "appkit"))]
pub mod model;
#[cfg(all(target_os = "macos", feature = "appkit"))]
pub mod server;
#[cfg(all(target_os = "macos", feature = "appkit"))]
pub mod ui;
#[cfg(all(target_os = "macos", feature = "appkit"))]
pub use server::run_appkit_main_thread;
#[cfg(all(target_os = "macos", feature = "appkit"))]
pub fn register_frontend() {
use trx_frontend::FrontendSpawner;
trx_frontend::register_frontend("appkit", server::AppKitFrontend::spawn_frontend);
}
#[cfg(not(all(target_os = "macos", feature = "appkit")))]
pub fn register_frontend() {
// No-op on non-macOS platforms or when appkit feature is disabled.
}
@@ -1,160 +0,0 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Platform-agnostic rig state model.
//!
//! This struct holds the display-ready state derived from `RigState`.
//! The UI layer reads from this model; command sending is handled
//! separately via an `mpsc::Sender<RigRequest>`.
use trx_core::rig::state::RigState;
use crate::helpers::{format_freq, mode_label, vfo_label};
/// Display-ready rig state. Updated from `RigState` on each change.
#[derive(Debug, Clone)]
pub struct RigStateModel {
pub freq_hz: u64,
pub freq_text: String,
pub mode: String,
pub band: String,
pub tx_enabled: bool,
pub locked: bool,
pub powered: bool,
pub rx_sig: i32,
pub tx_power: i32,
pub tx_limit: i32,
pub tx_swr: f64,
pub tx_alc: i32,
pub vfo: String,
}
impl Default for RigStateModel {
fn default() -> Self {
Self {
freq_hz: 0,
freq_text: "-- Hz".to_string(),
mode: "--".to_string(),
band: "--".to_string(),
tx_enabled: false,
locked: false,
powered: false,
rx_sig: 0,
tx_power: 0,
tx_limit: 0,
tx_swr: 0.0,
tx_alc: 0,
vfo: "--".to_string(),
}
}
}
impl RigStateModel {
/// Update all fields from a `RigState` snapshot. Returns `true` if anything changed.
pub fn update(&mut self, state: &RigState) -> bool {
let mut changed = false;
let freq_hz = state.status.freq.hz;
if self.freq_hz != freq_hz {
self.freq_hz = freq_hz;
self.freq_text = format_freq(freq_hz);
changed = true;
}
let mode = mode_label(&state.status.mode);
if self.mode != mode {
self.mode = mode;
changed = true;
}
let band = state.band_name().unwrap_or_else(|| "--".to_string());
if self.band != band {
self.band = band;
changed = true;
}
if self.tx_enabled != state.status.tx_en {
self.tx_enabled = state.status.tx_en;
changed = true;
}
let locked = state.status.lock.unwrap_or(false);
if self.locked != locked {
self.locked = locked;
changed = true;
}
let powered = state.control.enabled.unwrap_or(false);
if self.powered != powered {
self.powered = powered;
changed = true;
}
let rx_sig = state
.status
.rx
.as_ref()
.and_then(|rx| rx.sig)
.unwrap_or(0);
if self.rx_sig != rx_sig {
self.rx_sig = rx_sig;
changed = true;
}
let tx_power = state
.status
.tx
.as_ref()
.and_then(|tx| tx.power)
.map(i32::from)
.unwrap_or(0);
if self.tx_power != tx_power {
self.tx_power = tx_power;
changed = true;
}
let tx_limit = state
.status
.tx
.as_ref()
.and_then(|tx| tx.limit)
.map(i32::from)
.unwrap_or(0);
if self.tx_limit != tx_limit {
self.tx_limit = tx_limit;
changed = true;
}
let tx_swr = state
.status
.tx
.as_ref()
.and_then(|tx| tx.swr)
.unwrap_or(0.0) as f64;
if (self.tx_swr - tx_swr).abs() > f64::EPSILON {
self.tx_swr = tx_swr;
changed = true;
}
let tx_alc = state
.status
.tx
.as_ref()
.and_then(|tx| tx.alc)
.map(i32::from)
.unwrap_or(0);
if self.tx_alc != tx_alc {
self.tx_alc = tx_alc;
changed = true;
}
let vfo = vfo_label(state);
if self.vfo != vfo {
self.vfo = vfo;
changed = true;
}
changed
}
}
@@ -1,195 +0,0 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
//! AppKit frontend spawner.
//!
//! Spawns a dedicated thread for the NSApplication run loop and an async
//! task that watches for rig state changes and pushes them to the UI
//! thread via a std::sync::mpsc channel.
use std::net::SocketAddr;
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tracing::{info, warn};
use trx_core::rig::command::RigCommand;
use trx_core::{RigRequest, RigState};
use trx_frontend::FrontendSpawner;
use crate::model::RigStateModel;
use crate::ui::{self, ButtonAction, UiElements};
/// AppKit frontend implementation.
pub struct AppKitFrontend;
impl FrontendSpawner for AppKitFrontend {
fn spawn_frontend(
state_rx: watch::Receiver<RigState>,
_rig_tx: mpsc::Sender<RigRequest>,
_callsign: Option<String>,
listen_addr: SocketAddr,
) -> JoinHandle<()> {
let (state_update_tx, _state_update_rx) = std::sync::mpsc::channel::<RigState>();
// Spawn async state watcher that forwards state changes.
// The actual AppKit event loop is driven by `run_appkit_main_thread`
// called from main() on the process main thread.
tokio::spawn(async move {
info!("AppKit frontend starting (addr hint: {})", listen_addr);
run_state_watcher(state_rx, state_update_tx).await;
})
}
}
/// Run the AppKit event loop on the calling thread (must be the process main
/// thread, i.e. thread 0). This function **blocks forever**.
///
/// It creates the NSApplication, builds the UI window, and enters a polling
/// loop that drains AppKit events, applies rig state updates, and dispatches
/// button actions.
pub fn run_appkit_main_thread(
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
) {
// Channel for state updates: async watcher -> main thread.
let (state_update_tx, state_update_rx) = std::sync::mpsc::channel::<RigState>();
// Channel for button actions: UI buttons -> main thread loop.
let (action_tx, action_rx) = std::sync::mpsc::channel::<ButtonAction>();
// Spawn async state watcher onto the tokio runtime (running on a
// background thread).
tokio::spawn(async move {
run_state_watcher(state_rx, state_update_tx).await;
});
let mtm = MainThreadMarker::new()
.expect("run_appkit_main_thread must be called from the process main thread");
let app = NSApplication::sharedApplication(mtm);
let (window, ui_elements) = ui::build_window(mtm, action_tx);
// Keep window alive for the process lifetime.
std::mem::forget(window);
let mut model = RigStateModel::default();
info!("AppKit frontend: entering main run loop");
// Run a polling loop instead of NSApplication::run() so we can
// process state updates and button actions between event cycles.
loop {
// Process pending AppKit events.
drain_appkit_events(&app);
// Process state updates from the async watcher.
while let Ok(state) = state_update_rx.try_recv() {
if model.update(&state) {
ui_elements.refresh(&model);
}
}
// Process button actions.
while let Ok(action) = action_rx.try_recv() {
handle_action(action, &ui_elements, &rig_tx, &model);
}
// Sleep briefly to avoid busy-waiting.
std::thread::sleep(std::time::Duration::from_millis(16));
}
}
fn drain_appkit_events(app: &NSApplication) {
use objc2_app_kit::NSEventMask;
use objc2_foundation::NSDate;
loop {
let event = unsafe {
app.nextEventMatchingMask_untilDate_inMode_dequeue(
NSEventMask::Any,
Some(&NSDate::distantPast()),
objc2_foundation::NSDefaultRunLoopMode,
true,
)
};
match event {
Some(event) => {
app.sendEvent(&event);
}
None => break,
}
}
}
fn handle_action(
action: ButtonAction,
ui: &UiElements,
rig_tx: &mpsc::Sender<RigRequest>,
model: &RigStateModel,
) {
match action {
ButtonAction::TogglePtt => {
send_command(rig_tx, RigCommand::SetPtt(!model.tx_enabled));
}
ButtonAction::TogglePower => {
if model.powered {
send_command(rig_tx, RigCommand::PowerOff);
} else {
send_command(rig_tx, RigCommand::PowerOn);
}
}
ButtonAction::ToggleVfo => {
send_command(rig_tx, RigCommand::ToggleVfo);
}
ButtonAction::ToggleLock => {
if model.locked {
send_command(rig_tx, RigCommand::Unlock);
} else {
send_command(rig_tx, RigCommand::Lock);
}
}
ButtonAction::SetFreq => {
ui.handle_set_freq(rig_tx);
}
ButtonAction::SetMode => {
ui.handle_set_mode(rig_tx);
}
ButtonAction::SetTxLimit => {
ui.handle_set_tx_limit(rig_tx);
}
}
}
fn send_command(tx: &mpsc::Sender<RigRequest>, cmd: RigCommand) {
let (resp_tx, _resp_rx) = tokio::sync::oneshot::channel();
if tx
.blocking_send(RigRequest {
cmd,
respond_to: resp_tx,
})
.is_err()
{
warn!("AppKit frontend: rig command send failed");
}
}
async fn run_state_watcher(
mut state_rx: watch::Receiver<RigState>,
state_update_tx: std::sync::mpsc::Sender<RigState>,
) {
// Send initial state.
let _ = state_update_tx.send(state_rx.borrow().clone());
while state_rx.changed().await.is_ok() {
let state = state_rx.borrow().clone();
if state_update_tx.send(state).is_err() {
warn!("AppKit frontend: state update channel closed");
break;
}
}
}
@@ -1,351 +0,0 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
//! AppKit window and controls.
//!
//! Creates an NSWindow with labels for rig status and buttons for rig
//! control. All AppKit calls must run on the main thread.
use objc2::rc::Retained;
use objc2::{msg_send, MainThreadMarker};
use objc2_app_kit::{
NSBackingStoreType, NSButton, NSColor, NSStackView, NSTextField, NSView, NSWindow,
NSWindowStyleMask,
};
use objc2_foundation::{NSArray, NSEdgeInsets, NSPoint, NSRect, NSSize, NSString};
use tokio::sync::{mpsc, oneshot};
use tracing::warn;
use trx_core::radio::freq::Freq;
use trx_core::rig::command::RigCommand;
use trx_core::rig::request::RigRequest;
use crate::helpers::parse_mode;
use crate::model::RigStateModel;
/// All UI elements that need updating when rig state changes.
/// These must only be accessed from the AppKit main thread.
pub struct UiElements {
pub freq_label: Retained<NSTextField>,
pub mode_label: Retained<NSTextField>,
pub band_label: Retained<NSTextField>,
pub ptt_label: Retained<NSTextField>,
pub lock_label: Retained<NSTextField>,
pub power_label: Retained<NSTextField>,
pub rx_sig_label: Retained<NSTextField>,
pub tx_power_label: Retained<NSTextField>,
pub tx_limit_label: Retained<NSTextField>,
pub tx_swr_label: Retained<NSTextField>,
pub tx_alc_label: Retained<NSTextField>,
pub vfo_label: Retained<NSTextField>,
// Input fields for reading user input from button actions
pub freq_input: Retained<NSTextField>,
pub mode_input: Retained<NSTextField>,
pub tx_limit_input: Retained<NSTextField>,
}
impl UiElements {
/// Refresh all labels from the model.
pub fn refresh(&self, model: &RigStateModel) {
set_label_text(&self.freq_label, &model.freq_text);
set_label_text(&self.mode_label, &format!("Mode: {}", model.mode));
set_label_text(&self.band_label, &format!("Band: {}", model.band));
set_label_text(
&self.ptt_label,
if model.tx_enabled { "PTT: TX" } else { "PTT: RX" },
);
set_label_text(
&self.lock_label,
if model.locked {
"Lock: ON"
} else {
"Lock: OFF"
},
);
set_label_text(
&self.power_label,
if model.powered {
"Power: ON"
} else {
"Power: OFF"
},
);
set_label_text(&self.rx_sig_label, &format!("RX Sig: {}", model.rx_sig));
set_label_text(
&self.tx_power_label,
&format!("TX Power: {}", model.tx_power),
);
set_label_text(
&self.tx_limit_label,
&format!("TX Limit: {}", model.tx_limit),
);
set_label_text(
&self.tx_swr_label,
&format!("SWR: {:.1}", model.tx_swr),
);
set_label_text(&self.tx_alc_label, &format!("ALC: {}", model.tx_alc));
set_label_text(&self.vfo_label, &format!("VFO: {}", model.vfo));
}
/// Read the frequency input field value and send a SetFreq command.
pub fn handle_set_freq(&self, rig_tx: &mpsc::Sender<RigRequest>) {
let val = self.freq_input.stringValue();
let text = val.to_string();
if let Ok(hz) = text.trim().parse::<u64>() {
if hz > 0 {
send_command(rig_tx, RigCommand::SetFreq(Freq { hz }));
}
}
}
/// Read the mode input field value and send a SetMode command.
pub fn handle_set_mode(&self, rig_tx: &mpsc::Sender<RigRequest>) {
let val = self.mode_input.stringValue();
let mode = parse_mode(&val.to_string());
send_command(rig_tx, RigCommand::SetMode(mode));
}
/// Read the TX limit input field value and send a SetTxLimit command.
pub fn handle_set_tx_limit(&self, rig_tx: &mpsc::Sender<RigRequest>) {
let val = self.tx_limit_input.stringValue();
if let Ok(limit) = val.to_string().trim().parse::<u8>() {
send_command(rig_tx, RigCommand::SetTxLimit(limit));
}
}
}
fn set_label_text(label: &NSTextField, text: &str) {
let ns = NSString::from_str(text);
label.setStringValue(&ns);
}
fn make_label(mtm: MainThreadMarker, text: &str) -> Retained<NSTextField> {
let ns = NSString::from_str(text);
let label = NSTextField::labelWithString(&ns, mtm);
label.setEditable(false);
label.setBordered(false);
label.setDrawsBackground(false);
label.setTextColor(Some(&NSColor::labelColor()));
label
}
fn make_editable_field(mtm: MainThreadMarker, placeholder: &str) -> Retained<NSTextField> {
let ns = NSString::from_str(placeholder);
let field = NSTextField::textFieldWithString(&ns, mtm);
field.setEditable(true);
field.setBordered(true);
field
}
/// Convert an NSTextField into an NSView (NSTextField -> NSControl -> NSView).
fn text_field_to_view(field: Retained<NSTextField>) -> Retained<NSView> {
Retained::into_super(Retained::into_super(field))
}
/// Convert an NSButton into an NSView (NSButton -> NSControl -> NSView).
fn button_to_view(btn: Retained<NSButton>) -> Retained<NSView> {
Retained::into_super(Retained::into_super(btn))
}
/// Actions that buttons can trigger. Sent via a channel to be handled
/// on the AppKit thread where UI elements live.
#[derive(Debug, Clone, Copy)]
pub enum ButtonAction {
TogglePtt,
TogglePower,
ToggleVfo,
ToggleLock,
SetFreq,
SetMode,
SetTxLimit,
}
/// Build the main window with status labels and control buttons.
///
/// `action_tx` is a channel sender for button actions — each button stores
/// its action tag and the server's run-loop timer reads these to dispatch.
///
/// Returns the window (which must be kept alive) and the UI elements
/// struct for later updates.
pub fn build_window(
mtm: MainThreadMarker,
action_tx: std::sync::mpsc::Sender<ButtonAction>,
) -> (Retained<NSWindow>, UiElements) {
let style = NSWindowStyleMask::Titled
| NSWindowStyleMask::Closable
| NSWindowStyleMask::Miniaturizable
| NSWindowStyleMask::Resizable;
let frame = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(400.0, 520.0));
let window = unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(
mtm.alloc::<NSWindow>(),
frame,
style,
NSBackingStoreType::Buffered,
false,
)
};
let title = NSString::from_str("trx-rs");
window.setTitle(&title);
// Status labels
let freq_label = make_label(mtm, "-- Hz");
let mode_label = make_label(mtm, "Mode: --");
let band_label = make_label(mtm, "Band: --");
let ptt_label = make_label(mtm, "PTT: RX");
let lock_label = make_label(mtm, "Lock: OFF");
let power_label = make_label(mtm, "Power: OFF");
let rx_sig_label = make_label(mtm, "RX Sig: 0");
let tx_power_label = make_label(mtm, "TX Power: 0");
let tx_limit_label = make_label(mtm, "TX Limit: 0");
let tx_swr_label = make_label(mtm, "SWR: 0.0");
let tx_alc_label = make_label(mtm, "ALC: 0");
let vfo_label = make_label(mtm, "VFO: --");
// Control buttons — each stores an action tag, actions are dispatched
// via the global action table.
let ptt_btn = make_button(mtm, "Toggle PTT", ButtonAction::TogglePtt, &action_tx);
let power_btn = make_button(mtm, "Toggle Power", ButtonAction::TogglePower, &action_tx);
let vfo_btn = make_button(mtm, "Toggle VFO", ButtonAction::ToggleVfo, &action_tx);
let lock_btn = make_button(mtm, "Toggle Lock", ButtonAction::ToggleLock, &action_tx);
// Input fields
let freq_input = make_editable_field(mtm, "Freq (Hz)");
let set_freq_btn = make_button(mtm, "Set Freq", ButtonAction::SetFreq, &action_tx);
let mode_input = make_editable_field(mtm, "Mode (USB, LSB, ...)");
let set_mode_btn = make_button(mtm, "Set Mode", ButtonAction::SetMode, &action_tx);
let tx_limit_input = make_editable_field(mtm, "TX Limit (0-255)");
let set_tx_limit_btn = make_button(mtm, "Set TX Limit", ButtonAction::SetTxLimit, &action_tx);
// Build vertical stack view
let views: Vec<Retained<NSView>> = vec![
text_field_to_view(freq_label.clone()),
text_field_to_view(mode_label.clone()),
text_field_to_view(band_label.clone()),
text_field_to_view(ptt_label.clone()),
text_field_to_view(lock_label.clone()),
text_field_to_view(power_label.clone()),
text_field_to_view(rx_sig_label.clone()),
text_field_to_view(tx_power_label.clone()),
text_field_to_view(tx_limit_label.clone()),
text_field_to_view(tx_swr_label.clone()),
text_field_to_view(tx_alc_label.clone()),
text_field_to_view(vfo_label.clone()),
button_to_view(ptt_btn),
button_to_view(power_btn),
button_to_view(vfo_btn),
button_to_view(lock_btn),
text_field_to_view(freq_input.clone()),
button_to_view(set_freq_btn),
text_field_to_view(mode_input.clone()),
button_to_view(set_mode_btn),
text_field_to_view(tx_limit_input.clone()),
button_to_view(set_tx_limit_btn),
];
let ns_views = NSArray::from_retained_slice(&views);
let stack = NSStackView::stackViewWithViews(&ns_views, mtm);
stack.setOrientation(objc2_app_kit::NSUserInterfaceLayoutOrientation::Vertical);
stack.setSpacing(8.0);
stack.setEdgeInsets(NSEdgeInsets {
top: 16.0,
left: 16.0,
bottom: 16.0,
right: 16.0,
});
window.setContentView(Some(&stack));
window.makeKeyAndOrderFront(None);
let ui = UiElements {
freq_label,
mode_label,
band_label,
ptt_label,
lock_label,
power_label,
rx_sig_label,
tx_power_label,
tx_limit_label,
tx_swr_label,
tx_alc_label,
vfo_label,
freq_input,
mode_input,
tx_limit_input,
};
(window, ui)
}
fn send_command(tx: &mpsc::Sender<RigRequest>, cmd: RigCommand) {
let (resp_tx, _resp_rx) = oneshot::channel();
if tx
.blocking_send(RigRequest {
cmd,
respond_to: resp_tx,
})
.is_err()
{
warn!("AppKit frontend: rig command send failed");
}
}
fn make_button(
mtm: MainThreadMarker,
title: &str,
action: ButtonAction,
action_tx: &std::sync::mpsc::Sender<ButtonAction>,
) -> Retained<NSButton> {
let title_ns = NSString::from_str(title);
let btn = unsafe {
NSButton::buttonWithTitle_target_action(&title_ns, None, None, mtm)
};
// Store the action in the global table, indexed by the button's tag.
let idx = register_button_action(action, action_tx.clone());
unsafe {
let _: () = msg_send![&btn, setTag: idx as isize];
};
btn
}
// Global action table for buttons.
struct ActionEntry {
action: ButtonAction,
sender: std::sync::mpsc::Sender<ButtonAction>,
}
static BUTTON_ACTIONS: std::sync::OnceLock<std::sync::Mutex<Vec<ActionEntry>>> =
std::sync::OnceLock::new();
fn action_table() -> &'static std::sync::Mutex<Vec<ActionEntry>> {
BUTTON_ACTIONS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}
fn register_button_action(
action: ButtonAction,
sender: std::sync::mpsc::Sender<ButtonAction>,
) -> usize {
let mut table = action_table().lock().unwrap();
let idx = table.len();
table.push(ActionEntry { action, sender });
idx
}
/// Call the action for a button given its tag index.
/// This sends the button's action through the channel for processing
/// on the main thread where UI elements are accessible.
pub fn invoke_button_action(tag: isize) {
let table = action_table().lock().unwrap();
if let Some(entry) = table.get(tag as usize) {
let _ = entry.sender.send(entry.action);
}
}
-4
View File
@@ -47,7 +47,3 @@ listen = "127.0.0.1"
port = 0 port = 0
# List of accepted bearer tokens (empty = no auth) # List of accepted bearer tokens (empty = no auth)
# auth.tokens = ["example-token"] # auth.tokens = ["example-token"]
[frontends.appkit]
# Enable AppKit GUI frontend (macOS only, requires appkit-frontend feature)
enabled = false