1use std::path::{Path, PathBuf};
35#[cfg(unix)]
36use std::{io::Write as _, os::unix::net::UnixStream, time::Duration};
37
38#[cfg(unix)]
39const REFRESH_PAYLOAD: &[u8] = b"refresh\n";
40const SOCKET_EXTENSION: &str = "sock";
41#[cfg(unix)]
44const CONNECT_TIMEOUT: Duration = Duration::from_millis(50);
45
46#[must_use]
50pub fn socket_dir() -> PathBuf {
51 std::env::var_os("XDG_RUNTIME_DIR")
52 .map(PathBuf::from)
53 .unwrap_or_else(|| PathBuf::from("/tmp"))
54}
55
56#[cfg(unix)]
57fn euid() -> u32 {
58 unsafe { libc::geteuid() }
61}
62
63#[cfg(not(unix))]
64fn euid() -> u32 {
65 0
66}
67
68fn socket_prefix() -> String {
71 format!("modde-{}-", euid())
72}
73
74#[must_use]
77pub fn gui_socket_path() -> PathBuf {
78 let pid = std::process::id();
79 socket_dir().join(format!("{}{pid}.{SOCKET_EXTENSION}", socket_prefix()))
80}
81
82pub fn cleanup_socket(path: &Path) {
85 let _ = std::fs::remove_file(path);
86}
87
88pub fn notify_refresh() -> usize {
98 #[cfg(not(unix))]
99 {
100 0
101 }
102 #[cfg(unix)]
103 {
104 notify_refresh_in(&socket_dir())
105 }
106}
107
108pub fn notify_refresh_in(dir: &Path) -> usize {
112 #[cfg(not(unix))]
113 {
114 let _ = dir;
115 0
116 }
117 #[cfg(unix)]
118 {
119 let prefix = socket_prefix();
120 let suffix = format!(".{SOCKET_EXTENSION}");
121 let Ok(entries) = std::fs::read_dir(dir) else {
122 return 0;
123 };
124
125 let mut delivered = 0usize;
126 for entry in entries.flatten() {
127 let name = entry.file_name();
128 let Some(name_str) = name.to_str() else {
129 continue;
130 };
131 if !name_str.starts_with(&prefix) || !name_str.ends_with(&suffix) {
132 continue;
133 }
134 let path = entry.path();
135 if notify_refresh_at(&path) {
136 delivered += 1;
137 } else {
138 let _ = std::fs::remove_file(&path);
141 }
142 }
143 delivered
144 }
145}
146
147pub fn notify_refresh_at(path: &Path) -> bool {
150 #[cfg(not(unix))]
151 {
152 let _ = path;
153 false
154 }
155 #[cfg(unix)]
156 {
157 let Ok(mut stream) = UnixStream::connect(path) else {
158 return false;
159 };
160 let _ = stream.set_write_timeout(Some(CONNECT_TIMEOUT));
161 stream.write_all(REFRESH_PAYLOAD).is_ok()
162 }
163}
164
165#[cfg(all(test, unix))]
166mod tests {
167 use super::*;
168 use std::io::Read as _;
169 use std::os::unix::net::UnixListener;
170 use std::sync::atomic::{AtomicUsize, Ordering};
171 use std::thread;
172 use std::time::Duration;
173 use tempfile::TempDir;
174
175 static FAKE_PID: AtomicUsize = AtomicUsize::new(1);
178
179 fn fake_socket_path(dir: &Path) -> PathBuf {
180 let pid = FAKE_PID.fetch_add(1, Ordering::Relaxed);
181 dir.join(format!("{}test{pid}.{SOCKET_EXTENSION}", socket_prefix()))
182 }
183
184 fn spawn_drain(listener: UnixListener) -> thread::JoinHandle<Vec<u8>> {
187 thread::spawn(move || {
188 let (mut stream, _) = listener.accept().unwrap();
189 let mut buf = Vec::new();
190 stream.read_to_end(&mut buf).unwrap();
191 buf
192 })
193 }
194
195 #[test]
198 fn notify_at_returns_false_when_no_listener() {
199 let tmp = TempDir::new().unwrap();
200 let path = fake_socket_path(tmp.path());
201 assert!(!notify_refresh_at(&path));
202 }
203
204 #[test]
205 fn notify_at_delivers_to_listener() {
206 let tmp = TempDir::new().unwrap();
207 let path = fake_socket_path(tmp.path());
208
209 let listener = UnixListener::bind(&path).unwrap();
210 let handle = spawn_drain(listener);
211
212 thread::sleep(Duration::from_millis(50));
213 assert!(notify_refresh_at(&path));
214 assert_eq!(handle.join().unwrap(), REFRESH_PAYLOAD);
215 }
216
217 #[test]
220 fn notify_in_returns_zero_for_empty_dir() {
221 let tmp = TempDir::new().unwrap();
222 assert_eq!(notify_refresh_in(tmp.path()), 0);
223 }
224
225 #[test]
226 fn notify_in_returns_zero_for_missing_dir() {
227 let tmp = TempDir::new().unwrap();
230 let missing = tmp.path().join("does-not-exist");
231 assert_eq!(notify_refresh_in(&missing), 0);
232 }
233
234 #[test]
235 fn notify_in_delivers_to_every_listener() {
236 let tmp = TempDir::new().unwrap();
239 let mut handles = Vec::new();
240 for _ in 0..3 {
241 let path = fake_socket_path(tmp.path());
242 let listener = UnixListener::bind(&path).unwrap();
243 handles.push(spawn_drain(listener));
244 }
245
246 thread::sleep(Duration::from_millis(50));
247 assert_eq!(notify_refresh_in(tmp.path()), 3);
248 for h in handles {
249 assert_eq!(h.join().unwrap(), REFRESH_PAYLOAD);
250 }
251 }
252
253 #[test]
254 fn notify_in_garbage_collects_stale_sockets_and_keeps_live_ones() {
255 let tmp = TempDir::new().unwrap();
260
261 let stale_a = fake_socket_path(tmp.path());
262 let stale_b = fake_socket_path(tmp.path());
263 std::fs::write(&stale_a, b"").unwrap();
264 std::fs::write(&stale_b, b"").unwrap();
265
266 let live_path = fake_socket_path(tmp.path());
267 let listener = UnixListener::bind(&live_path).unwrap();
268 let handle = spawn_drain(listener);
269
270 thread::sleep(Duration::from_millis(50));
271 let delivered = notify_refresh_in(tmp.path());
272 assert_eq!(delivered, 1, "only the live listener should receive");
273 assert!(!stale_a.exists(), "stale socket A should be GC'd");
274 assert!(!stale_b.exists(), "stale socket B should be GC'd");
275 assert!(live_path.exists(), "live socket must not be GC'd");
276 assert_eq!(handle.join().unwrap(), REFRESH_PAYLOAD);
277 }
278
279 #[test]
280 fn notify_in_skips_files_outside_the_user_prefix() {
281 let tmp = TempDir::new().unwrap();
285 let other_user = tmp.path().join("modde-99999-pid42.sock");
286 std::fs::write(&other_user, b"").unwrap();
287
288 let delivered = notify_refresh_in(tmp.path());
289 assert_eq!(delivered, 0);
290 assert!(other_user.exists(), "other-user file must be left alone");
291 }
292
293 #[test]
294 fn notify_in_skips_files_with_other_extensions() {
295 let tmp = TempDir::new().unwrap();
298 let lock = tmp.path().join(format!("{}pid1.lock", socket_prefix()));
299 std::fs::write(&lock, b"").unwrap();
300
301 assert_eq!(notify_refresh_in(tmp.path()), 0);
302 assert!(lock.exists(), "non-socket files must be left alone");
303 }
304
305 #[test]
308 fn cleanup_socket_removes_file() {
309 let tmp = TempDir::new().unwrap();
310 let path = fake_socket_path(tmp.path());
311 std::fs::write(&path, b"").unwrap();
312 assert!(path.exists());
313 cleanup_socket(&path);
314 assert!(!path.exists());
315 }
316
317 #[test]
318 fn cleanup_socket_is_idempotent() {
319 let tmp = TempDir::new().unwrap();
322 let path = tmp.path().join("never-existed.sock");
323 cleanup_socket(&path);
324 cleanup_socket(&path); }
326
327 #[test]
328 fn gui_socket_path_includes_pid() {
329 let path = gui_socket_path();
332 let name = path.file_name().unwrap().to_string_lossy().to_string();
333 let prefix = socket_prefix();
334 assert!(
335 name.starts_with(&prefix),
336 "expected prefix {prefix} in {name}"
337 );
338 assert!(name.ends_with(".sock"), "expected .sock suffix in {name}");
339 let pid = std::process::id().to_string();
340 assert!(name.contains(&pid), "expected pid {pid} in {name}");
341 }
342}