1use 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#[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 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 pub fn open(mut self, label: impl Into<String>) -> Self {
46 self.open = label.into();
47 self
48 }
49
50 pub fn quit(mut self, label: impl Into<String>) -> Self {
52 self.quit = label.into();
53 self
54 }
55}
56
57#[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
65pub(crate) type Notify = Arc<dyn Fn(TrayAction) + Send + Sync>;
67
68pub(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
73pub(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 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#[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
144const ANSWER_TIME: Duration = Duration::from_millis(500);
146
147pub(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 stream.read_exact(&mut answer).is_ok() && answer == token.as_bytes()
161}
162
163pub(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
191fn 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
197fn 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}