Skip to main content

ravenrs_platform_native/
tray.rs

1//! The icon beside the clock that keeps an app running with its window closed.
2//!
3//! Each system has its own way of showing it. macOS and Windows use `tray-icon`, on
4//! the thread of the event loop. Linux uses `ksni`, a StatusNotifierItem in pure Rust,
5//! so that building an app asks for no GTK; there the icon only appears if something
6//! hosts it, which plain GNOME does not. That is why [`show`] can fail, and why
7//! whoever calls it then closes the app: an app hidden behind an icon nobody draws
8//! could never be closed again.
9
10use std::io::{Read, Write};
11use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
12use std::sync::Arc;
13use std::time::Duration;
14
15use ravenrs_core::ImageData;
16
17/// Keeps the app running beside the clock when its window is closed. Give it to
18/// [`AppIdentity::close_to_tray`](crate::AppIdentity::close_to_tray).
19///
20/// The menu has two items: one opens the window again, in the state it was left,
21/// and the other really closes the app. Where the system shows no such icon, such as
22/// plain GNOME, closing the window closes the app as usual. Needs the `tray` feature.
23///
24/// ```
25/// use ravenrs_platform_native::SystemTray;
26///
27/// let tray = SystemTray::new("Notes").open("Open Notes").quit("Quit");
28/// ```
29#[derive(Clone, Debug)]
30#[cfg_attr(not(feature = "tray"), allow(dead_code, reason = "no icon to show them on"))]
31pub struct SystemTray {
32    name: String,
33    open: String,
34    quit: String,
35}
36
37impl SystemTray {
38    /// An icon for the app called `name`, which is also its tooltip.
39    pub fn new(name: impl Into<String>) -> Self {
40        let name = name.into();
41        Self { open: format!("Open {name}"), quit: "Quit".to_owned(), name }
42    }
43
44    /// Sets the words of the item that opens the window again.
45    pub fn open(mut self, label: impl Into<String>) -> Self {
46        self.open = label.into();
47        self
48    }
49
50    /// Sets the words of the item that closes the app.
51    pub fn quit(mut self, label: impl Into<String>) -> Self {
52        self.quit = label.into();
53        self
54    }
55}
56
57/// What the person asked for, from the icon or from opening the app a second time.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59#[cfg_attr(not(feature = "tray"), allow(dead_code, reason = "only the icon offers to quit"))]
60pub(crate) enum TrayAction {
61    Open,
62    Quit,
63}
64
65/// Runs on whatever thread the system answers on.
66pub(crate) type Notify = Arc<dyn Fn(TrayAction) + Send + Sync>;
67
68/// Puts the icon on screen, until the result is dropped. `Err` says why there is none.
69pub(crate) fn show(tray: &SystemTray, id: &str, icon: &ImageData, notify: Notify) -> Result<Shown, String> {
70    system::show(tray, id, icon, notify).map(Shown)
71}
72
73/// An icon on screen.
74pub(crate) struct Shown(#[allow(dead_code, reason = "holding it is what keeps the icon")] system::Icon);
75
76#[cfg(all(feature = "tray", any(target_os = "macos", target_os = "windows")))]
77mod system {
78    use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
79    use tray_icon::{TrayIcon, TrayIconBuilder};
80
81    use super::{ImageData, Notify, SystemTray, TrayAction};
82
83    const OPEN: &str = "ravenrs:open";
84    const QUIT: &str = "ravenrs:quit";
85
86    pub(super) type Icon = TrayIcon;
87
88    pub(super) fn show(
89        tray: &SystemTray,
90        _id: &str,
91        icon: &ImageData,
92        notify: Notify,
93    ) -> Result<Icon, String> {
94        // Only the first one stays, and they all say the same.
95        MenuEvent::set_event_handler(Some(move |event: MenuEvent| match event.id.as_ref() {
96            OPEN => notify(TrayAction::Open),
97            QUIT => notify(TrayAction::Quit),
98            _ => {}
99        }));
100        let open = MenuItem::with_id(OPEN, &tray.open, true, None);
101        let quit = MenuItem::with_id(QUIT, &tray.quit, true, None);
102        let menu =
103            Menu::with_items(&[&open, &PredefinedMenuItem::separator(), &quit]).map_err(|e| e.to_string())?;
104        let image = tray_icon::Icon::from_rgba(icon.pixels().to_vec(), icon.width(), icon.height())
105            .map_err(|e| e.to_string())?;
106        TrayIconBuilder::new()
107            .with_icon(image)
108            .with_tooltip(&tray.name)
109            .with_menu(Box::new(menu))
110            .with_menu_on_left_click(true)
111            .build()
112            .map_err(|e| e.to_string())
113    }
114}
115
116#[cfg(all(feature = "tray", target_os = "linux"))]
117#[path = "tray_linux.rs"]
118mod system;
119
120#[cfg(not(all(feature = "tray", any(target_os = "macos", target_os = "windows", target_os = "linux"))))]
121mod system {
122    use super::{ImageData, Notify, SystemTray};
123
124    pub(super) type Icon = ();
125
126    pub(super) fn show(_: &SystemTray, _: &str, _: &ImageData, _: Notify) -> Result<Icon, String> {
127        Err("this build has no tray icon".to_owned())
128    }
129}
130
131/// Whether the system has this app in front, where it can tell. A click on the app's
132/// icon in the Dock brings it to the front, and macOS says nothing else that winit hears.
133#[cfg(all(feature = "tray", target_os = "macos"))]
134pub(crate) fn app_is_active() -> Option<bool> {
135    let thread = objc2::MainThreadMarker::new()?;
136    Some(objc2_app_kit::NSApplication::sharedApplication(thread).isActive())
137}
138
139#[cfg(not(all(feature = "tray", target_os = "macos")))]
140pub(crate) fn app_is_active() -> Option<bool> {
141    None
142}
143
144/// How long one running copy of the app takes to answer another, at most.
145const ANSWER_TIME: Duration = Duration::from_millis(500);
146
147/// Tells a copy of the app already running to open its window. `true` if there is one.
148pub(crate) fn ask_running_copy(id: &str) -> bool {
149    let (address, token) = (instance_address(id), instance_token(id));
150    let Ok(mut stream) = TcpStream::connect_timeout(&address, ANSWER_TIME) else {
151        return false;
152    };
153    let _ = stream.set_read_timeout(Some(ANSWER_TIME));
154    let _ = stream.set_write_timeout(Some(ANSWER_TIME));
155    if stream.write_all(token.as_bytes()).is_err() {
156        return false;
157    }
158    let mut answer = vec![0; token.len()];
159    // Something else on this port does not know the token, and is none of our business.
160    stream.read_exact(&mut answer).is_ok() && answer == token.as_bytes()
161}
162
163/// Listens for other copies of the app for as long as it runs: with a window closed
164/// behind an icon, opening the app again must bring that window back, and not start
165/// a second app over the same data.
166pub(crate) fn listen_for_other_copies(id: &str, notify: Notify) -> Result<(), String> {
167    let listener = TcpListener::bind(instance_address(id)).map_err(|e| e.to_string())?;
168    let token = instance_token(id);
169    std::thread::Builder::new()
170        .name("ravenrs-instance".to_owned())
171        .spawn(move || {
172            for stream in listener.incoming().flatten() {
173                if answer_copy(stream, &token) {
174                    notify(TrayAction::Open);
175                }
176            }
177        })
178        .map(|_| ())
179        .map_err(|e| e.to_string())
180}
181
182fn answer_copy(mut stream: TcpStream, token: &str) -> bool {
183    let _ = stream.set_read_timeout(Some(ANSWER_TIME));
184    let _ = stream.set_write_timeout(Some(ANSWER_TIME));
185    let mut asked = vec![0; token.len()];
186    stream.read_exact(&mut asked).is_ok()
187        && asked == token.as_bytes()
188        && stream.write_all(token.as_bytes()).is_ok()
189}
190
191/// Told apart by person too, so that two people on one computer each have their app.
192fn instance_token(id: &str) -> String {
193    let person = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default();
194    format!("ravenrs-instance:{id}:{person}\n")
195}
196
197/// A port of the dynamic range, the same for every copy of this app of this person.
198fn instance_address(id: &str) -> SocketAddr {
199    const FIRST_PORT: u32 = 49152;
200    const PORTS: u32 = 16384;
201    let hash = instance_token(id)
202        .bytes()
203        .fold(0x811c_9dc5_u32, |hash, byte| (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193));
204    let port = u16::try_from(FIRST_PORT + hash % PORTS).unwrap_or(u16::MAX);
205    SocketAddr::from((Ipv4Addr::LOCALHOST, port))
206}
207
208#[cfg(test)]
209mod tests {
210    #![allow(clippy::expect_used, reason = "tests fail loudly")]
211
212    use std::sync::mpsc::channel;
213
214    use super::*;
215
216    #[test]
217    fn a_second_copy_finds_the_first_and_opens_its_window() {
218        let id = format!("ravenrs-test-{}", std::process::id());
219        assert!(!ask_running_copy(&id), "nobody is running yet");
220
221        let (send, heard) = channel();
222        let notify: Notify = Arc::new(move |action| send.send(action).expect("the test is listening"));
223        listen_for_other_copies(&id, notify).expect("the port is free");
224
225        assert!(ask_running_copy(&id));
226        assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(TrayAction::Open));
227    }
228
229    #[test]
230    fn something_else_on_the_port_is_not_a_copy_of_the_app() {
231        let id = format!("ravenrs-test-other-{}", std::process::id());
232        let listener = TcpListener::bind(instance_address(&id)).expect("the port is free");
233        std::thread::spawn(move || {
234            if let Ok((mut stream, _)) = listener.accept() {
235                let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n");
236            }
237        });
238        assert!(!ask_running_copy(&id));
239    }
240
241    #[test]
242    fn the_address_stays_the_same_and_in_the_dynamic_range() {
243        let address = instance_address("notes");
244        assert_eq!(address, instance_address("notes"));
245        assert!(address.ip().is_loopback() && address.port() >= 49152);
246    }
247}