From e7ddaa7300eac9bd5df981baf69ba349588318d3 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sat, 7 Feb 2026 13:36:32 +0100 Subject: [PATCH] [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 Signed-off-by: Stanislaw Grams --- .../trx-frontend-appkit/src/lib.rs | 3 + .../trx-frontend-appkit/src/server.rs | 96 +++++++++++-------- 2 files changed, 57 insertions(+), 42 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-appkit/src/lib.rs b/src/trx-client/trx-frontend/trx-frontend-appkit/src/lib.rs index 5992c3f..3da456f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-appkit/src/lib.rs +++ b/src/trx-client/trx-frontend/trx-frontend-appkit/src/lib.rs @@ -11,6 +11,9 @@ 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; diff --git a/src/trx-client/trx-frontend/trx-frontend-appkit/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-appkit/src/server.rs index e3bec84..6bcf998 100644 --- a/src/trx-client/trx-frontend/trx-frontend-appkit/src/server.rs +++ b/src/trx-client/trx-frontend/trx-frontend-appkit/src/server.rs @@ -9,7 +9,6 @@ //! thread via a std::sync::mpsc channel. use std::net::SocketAddr; -use std::thread; use objc2::MainThreadMarker; use objc2_app_kit::NSApplication; @@ -30,65 +29,78 @@ pub struct AppKitFrontend; impl FrontendSpawner for AppKitFrontend { fn spawn_frontend( state_rx: watch::Receiver, - rig_tx: mpsc::Sender, + _rig_tx: mpsc::Sender, _callsign: Option, listen_addr: SocketAddr, ) -> JoinHandle<()> { - // Channel for state updates: async watcher -> AppKit thread. - let (state_update_tx, state_update_rx) = std::sync::mpsc::channel::(); - - // Channel for button actions: UI buttons -> AppKit thread main loop. - let (action_tx, action_rx) = std::sync::mpsc::channel::(); + let (state_update_tx, _state_update_rx) = std::sync::mpsc::channel::(); // 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); run_state_watcher(state_rx, state_update_tx).await; - }); + }) + } +} - // Spawn the AppKit main thread. - thread::spawn(move || { - let mtm = match MainThreadMarker::new() { - Some(m) => m, - None => { - warn!("AppKit frontend: could not obtain MainThreadMarker"); - return; - } - }; +/// 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, + rig_tx: mpsc::Sender, +) { + // Channel for state updates: async watcher -> main thread. + let (state_update_tx, state_update_rx) = std::sync::mpsc::channel::(); - let app = NSApplication::sharedApplication(mtm); + // Channel for button actions: UI buttons -> main thread loop. + let (action_tx, action_rx) = std::sync::mpsc::channel::(); - let (window, ui_elements) = ui::build_window(mtm, action_tx); + // 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; + }); - // Keep window alive for the process lifetime. - std::mem::forget(window); + let mtm = MainThreadMarker::new() + .expect("run_appkit_main_thread must be called from the process main thread"); - let mut model = RigStateModel::default(); + let app = NSApplication::sharedApplication(mtm); - // 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); + let (window, ui_elements) = ui::build_window(mtm, action_tx); - // Process state updates from the async watcher. - while let Ok(state) = state_update_rx.try_recv() { - if model.update(&state) { - ui_elements.refresh(&model); - } - } + // Keep window alive for the process lifetime. + std::mem::forget(window); - // Process button actions. - while let Ok(action) = action_rx.try_recv() { - handle_action(action, &ui_elements, &rig_tx, &model); - } + let mut model = RigStateModel::default(); - // Sleep briefly to avoid busy-waiting. - std::thread::sleep(std::time::Duration::from_millis(16)); + 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); } - }); + } - handle + // 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)); } }