tray_wrapper/
lib.rs

1//! The tray wrapper library is intended to make it simple to provide a GUI tray icon for a given running server
2//! process. This uses the excellent tray-icon library for the UI and re-exports some of its types.
3//!
4//! This is to make it convient to start and monitor a process on Mac (other operating systems are supported
5//! on a best effort basis). MacOS makes it challenging to manage a server process WITHOUT a tray icon.
6//!
7//! The tray icon provides a submenu to view the supplied server status and the ability to exit.
8pub mod event_loop;
9mod menu_state;
10pub mod server_generator;
11mod server_status;
12pub mod user_event;
13
14use crate::{
15    menu_state::MenuState,
16    server_generator::{ContinueRunning, ServerGenerator},
17    server_status::ServerStatus,
18    user_event::UserEvent,
19};
20use image::ImageError;
21use std::time::Duration;
22use take_once::TakeOnce;
23use thiserror::Error;
24use tokio::runtime::Runtime;
25use tray_icon::{BadIcon, Icon};
26use winit::{application::ApplicationHandler, event_loop::EventLoopProxy};
27
28/// This is the main entry point / handle for the wrapper
29pub struct TrayWrapper {
30    icon: Icon,
31    menu_state: Option<MenuState>,
32    runtime: Option<Runtime>,
33    event_loop_proxy: EventLoopProxy<UserEvent>,
34    server_generator: TakeOnce<ServerGenerator>,
35}
36
37impl TrayWrapper {
38    ///Construct the wrapper, its recommended you compile time load the icon which means you
39    /// can ignore image parsing errors.
40    pub fn new(
41        icon_data: &[u8],
42        event_loop_proxy: EventLoopProxy<UserEvent>,
43        server_gen: ServerGenerator,
44    ) -> Result<Self, TrayWrapperError> {
45        let image = image::load_from_memory(icon_data)?.into_rgba8();
46
47        let (width, height) = image.dimensions();
48        let rgba = image.into_raw();
49        let icon = Icon::from_rgba(rgba, width, height)?;
50        let server_generator = TakeOnce::new_with(server_gen);
51
52        Ok(TrayWrapper {
53            icon,
54            menu_state: None,
55            runtime: Some(Runtime::new()?),
56
57            event_loop_proxy,
58            server_generator,
59        })
60    }
61}
62
63// This implementation is from the winit example here: https://github.com/tauri-apps/tray-icon/blob/dev/examples/winit.rs
64impl ApplicationHandler<UserEvent> for TrayWrapper {
65    fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {}
66
67    fn window_event(
68        &mut self,
69        _event_loop: &winit::event_loop::ActiveEventLoop,
70        _window_id: winit::window::WindowId,
71        _event: winit::event::WindowEvent,
72    ) {
73    }
74
75    fn new_events(
76        &mut self,
77        _event_loop: &winit::event_loop::ActiveEventLoop,
78        cause: winit::event::StartCause,
79    ) {
80        // We create the icon once the event loop is actually running
81        // to prevent issues like https://github.com/tauri-apps/tray-icon/issues/90
82        if winit::event::StartCause::Init == cause {
83            let Ok(mut ms) = MenuState::new(self.icon.clone()) else {
84                return _event_loop.exit();
85            };
86            ms.update_tray_icon(ServerStatus::StartUp); //The error type doesn't matter in this case
87            self.menu_state = Some(ms);
88
89            //Now its time to really start the server
90            let Some(rt) = &self.runtime else {
91                return _event_loop.exit();
92            };
93
94            let sg = self
95                .server_generator
96                .take()
97                .expect("Unable to take generator function");
98            let elp = self.event_loop_proxy.clone();
99            rt.spawn(async move {
100                let sg_fn = sg;
101                loop {
102                    let next_run = sg_fn();
103                    elp.send_event(UserEvent::ServerStatusEvent(ServerStatus::Running))
104                        .expect("Event Loop Closed!");
105                    match next_run.await {
106                        ContinueRunning::Continue => {
107                            elp.send_event(UserEvent::ServerStatusEvent(ServerStatus::Stopped(
108                                "Server Exited, will start again".to_string(),
109                            )))
110                            .expect("Event Loop Closed!");
111                            continue;
112                        }
113                        ContinueRunning::Exit => {
114                            elp.send_event(UserEvent::ServerExitEvent)
115                                .expect("Event Loop Closed!");
116                            break;
117                        }
118                        ContinueRunning::ExitWithError(e) => {
119                            elp.send_event(UserEvent::ServerStatusEvent(ServerStatus::Error(
120                                e.to_string(),
121                            )))
122                            .expect("Event Loop Closed!");
123                            break;
124                        }
125                    }
126                }
127            });
128        }
129
130        // We have to request a redraw here to have the icon actually show up.
131        // Winit only exposes a redraw method on the Window so we use core-foundation directly-ish.
132        #[cfg(target_os = "macos")]
133        {
134            use objc2_core_foundation::CFRunLoop;
135            let rl = CFRunLoop::main().unwrap();
136            CFRunLoop::wake_up(&rl);
137        }
138    }
139
140    fn user_event(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop, event: UserEvent) {
141        if let UserEvent::ServerExitEvent = event {
142            if let Some(rt) = self.runtime.take() {
143                rt.shutdown_timeout(Duration::from_secs(10));
144            }
145            _event_loop.exit();
146        }
147
148        if let Some(ms) = &self.menu_state
149            && ms.quit_matches(event)
150        {
151            if let Some(rt) = self.runtime.take() {
152                rt.shutdown_timeout(Duration::from_secs(10));
153            }
154            _event_loop.exit();
155        }
156    }
157}
158
159#[derive(Error, Debug)]
160pub enum TrayWrapperError {
161    #[error("Unable to load the icon from buffer")]
162    IconLoad(#[from] ImageError),
163    #[error("Tray Icon Bad Icon")]
164    BadIcon(#[from] BadIcon),
165    #[error("Failure to pre-create menu")]
166    MenuError(#[from] tray_icon::menu::Error),
167    #[error(transparent)]
168    RunTime(#[from] std::io::Error),
169}