From a55ea883a123959a7c2e9f69bec85891a4e2be2b Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Fri, 2 Oct 2026 17:54:38 +0100 Subject: [PATCH] Tray: initialise GTK and run its main loop on Linux tray-icon needs gtk::init() and a running GTK loop on Linux, which eframe/winit never provide, so the tray panicked at startup with "GTK has not been initialized". Build the tray on a dedicated GTK thread instead. Co-Authored-By: Claude --- Cargo.lock | 1 + crates/tsunagi-tray/Cargo.toml | 5 ++ crates/tsunagi-tray/src/tray.rs | 90 +++++++++++++++++++++++++-------- 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 932bbcb..5ff6d21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6329,6 +6329,7 @@ version = "0.1.0" dependencies = [ "eframe", "egui-phosphor", + "gtk", "tokio", "tracing", "tracing-subscriber", diff --git a/crates/tsunagi-tray/Cargo.toml b/crates/tsunagi-tray/Cargo.toml index 5fbcb4c..16a39d9 100644 --- a/crates/tsunagi-tray/Cargo.toml +++ b/crates/tsunagi-tray/Cargo.toml @@ -26,6 +26,11 @@ tracing.workspace = true tracing-subscriber.workspace = true egui-phosphor = "0.9" +# Linux only: tray-icon (already GTK-based there) needs the app to initialise +# GTK and run its main loop, which eframe/winit do not do. +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "0.18" + # macOS only: set the app's activation policy to Accessory so it lives in the # menu bar with no Dock icon. Pinned to eframe's winit so the types unify. [target.'cfg(target_os = "macos")'.dependencies] diff --git a/crates/tsunagi-tray/src/tray.rs b/crates/tsunagi-tray/src/tray.rs index 0bea31a..76118eb 100644 --- a/crates/tsunagi-tray/src/tray.rs +++ b/crates/tsunagi-tray/src/tray.rs @@ -8,7 +8,7 @@ use std::sync::mpsc; use eframe::egui; -use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; +use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem}; use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; /// What the user picked from the tray menu. @@ -22,34 +22,81 @@ pub(crate) enum Action { /// The live tray icon plus the channel of menu actions. pub(crate) struct Tray { /// Kept alive for as long as the app runs; dropping it removes the icon. + /// On Linux the icon lives on the GTK thread instead (it is not `Send`). + #[cfg(not(target_os = "linux"))] _icon: TrayIcon, actions: mpsc::Receiver, } +/// A built tray icon and the ids of its menu items. +struct Built { + icon: TrayIcon, + open: MenuId, + quit: MenuId, +} + +fn build() -> Result { + let open = MenuItem::new("Open tsunagi", true, None); + let quit = MenuItem::new("Quit", true, None); + let menu = Menu::new(); + menu.append(&open).map_err(|err| err.to_string())?; + menu.append(&PredefinedMenuItem::separator()) + .map_err(|err| err.to_string())?; + menu.append(&quit).map_err(|err| err.to_string())?; + + let icon = TrayIconBuilder::new() + .with_tooltip("tsunagi") + .with_menu(Box::new(menu)) + .with_icon(make_icon()?) + .build() + .map_err(|err| err.to_string())?; + Ok(Built { + icon, + open: open.id().clone(), + quit: quit.id().clone(), + }) +} + impl Tray { /// Builds the tray and starts reading its menu events. /// - /// Created on the main thread (required on macOS); `ctx` is used to wake the - /// UI when a menu item is chosen. + /// Created on the main thread on macOS and Windows (required on macOS). On + /// Linux the tray needs GTK, which eframe/winit never initialise, so it is + /// built on a dedicated thread that initialises GTK and runs its main loop. + /// `ctx` is used to wake the UI when a menu item is chosen. pub(crate) fn new(ctx: egui::Context) -> Result { - let open = MenuItem::new("Open tsunagi", true, None); - let quit = MenuItem::new("Quit", true, None); - let menu = Menu::new(); - menu.append(&open).map_err(|err| err.to_string())?; - menu.append(&PredefinedMenuItem::separator()) - .map_err(|err| err.to_string())?; - menu.append(&quit).map_err(|err| err.to_string())?; + #[cfg(target_os = "linux")] + let (open_id, quit_id) = { + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("tray-gtk".into()) + .spawn(move || { + if let Err(err) = gtk::init() { + let _ = ready_tx.send(Err(format!("cannot initialise GTK: {err}"))); + return; + } + match build() { + Ok(built) => { + let _ = ready_tx.send(Ok((built.open, built.quit))); + gtk::main(); + drop(built.icon); + } + Err(err) => { + let _ = ready_tx.send(Err(err)); + } + } + }) + .map_err(|err| err.to_string())?; + ready_rx + .recv() + .map_err(|_| "the tray thread exited before it was ready".to_string())?? + }; + #[cfg(not(target_os = "linux"))] + let (icon, open_id, quit_id) = { + let built = build()?; + (built.icon, built.open, built.quit) + }; - let icon = make_icon()?; - let tray = TrayIconBuilder::new() - .with_tooltip("tsunagi") - .with_menu(Box::new(menu)) - .with_icon(icon) - .build() - .map_err(|err| err.to_string())?; - - let open_id = open.id().clone(); - let quit_id = quit.id().clone(); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { // The global menu-event channel is fed by the OS event loop. @@ -69,7 +116,8 @@ impl Tray { }); Ok(Self { - _icon: tray, + #[cfg(not(target_os = "linux"))] + _icon: icon, actions: rx, }) }