[fix](trx-frontend-appkit): run AppKit event loop on calling thread

Extract the AppKit event loop from FrontendSpawner::spawn_frontend into
a new public run_appkit_main_thread() function that blocks on the
calling thread. This allows the process main thread (thread 0) to drive
the UI, which is required for MainThreadMarker::new() to succeed.

The FrontendSpawner impl now only spawns the async state watcher task.

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 13:36:32 +01:00
parent a7582f17f2
commit e7ddaa7300
2 changed files with 57 additions and 42 deletions
@@ -11,6 +11,9 @@ pub mod server;
#[cfg(all(target_os = "macos", feature = "appkit"))] #[cfg(all(target_os = "macos", feature = "appkit"))]
pub mod ui; pub mod ui;
#[cfg(all(target_os = "macos", feature = "appkit"))]
pub use server::run_appkit_main_thread;
#[cfg(all(target_os = "macos", feature = "appkit"))] #[cfg(all(target_os = "macos", feature = "appkit"))]
pub fn register_frontend() { pub fn register_frontend() {
use trx_frontend::FrontendSpawner; use trx_frontend::FrontendSpawner;
@@ -9,7 +9,6 @@
//! thread via a std::sync::mpsc channel. //! thread via a std::sync::mpsc channel.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::thread;
use objc2::MainThreadMarker; use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication; use objc2_app_kit::NSApplication;
@@ -30,31 +29,46 @@ pub struct AppKitFrontend;
impl FrontendSpawner for AppKitFrontend { impl FrontendSpawner for AppKitFrontend {
fn spawn_frontend( fn spawn_frontend(
state_rx: watch::Receiver<RigState>, state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>, _rig_tx: mpsc::Sender<RigRequest>,
_callsign: Option<String>, _callsign: Option<String>,
listen_addr: SocketAddr, listen_addr: SocketAddr,
) -> JoinHandle<()> { ) -> JoinHandle<()> {
// Channel for state updates: async watcher -> AppKit thread. let (state_update_tx, _state_update_rx) = std::sync::mpsc::channel::<RigState>();
let (state_update_tx, state_update_rx) = std::sync::mpsc::channel::<RigState>();
// Channel for button actions: UI buttons -> AppKit thread main loop.
let (action_tx, action_rx) = std::sync::mpsc::channel::<ButtonAction>();
// Spawn async state watcher that forwards state changes. // Spawn async state watcher that forwards state changes.
let handle = tokio::spawn(async move { // 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); info!("AppKit frontend starting (addr hint: {})", listen_addr);
run_state_watcher(state_rx, state_update_tx).await; 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;
}); });
// Spawn the AppKit main thread. let mtm = MainThreadMarker::new()
thread::spawn(move || { .expect("run_appkit_main_thread must be called from the process main thread");
let mtm = match MainThreadMarker::new() {
Some(m) => m,
None => {
warn!("AppKit frontend: could not obtain MainThreadMarker");
return;
}
};
let app = NSApplication::sharedApplication(mtm); let app = NSApplication::sharedApplication(mtm);
@@ -65,6 +79,8 @@ impl FrontendSpawner for AppKitFrontend {
let mut model = RigStateModel::default(); let mut model = RigStateModel::default();
info!("AppKit frontend: entering main run loop");
// Run a polling loop instead of NSApplication::run() so we can // Run a polling loop instead of NSApplication::run() so we can
// process state updates and button actions between event cycles. // process state updates and button actions between event cycles.
loop { loop {
@@ -86,10 +102,6 @@ impl FrontendSpawner for AppKitFrontend {
// Sleep briefly to avoid busy-waiting. // Sleep briefly to avoid busy-waiting.
std::thread::sleep(std::time::Duration::from_millis(16)); std::thread::sleep(std::time::Duration::from_millis(16));
} }
});
handle
}
} }
fn drain_appkit_events(app: &NSApplication) { fn drain_appkit_events(app: &NSApplication) {