Skip to main content

scxtui/
watch.rs

1// SPDX-License-Identifier: GPL-2.0
2
3//! inotify on the files that decide the lists and the next start: the
4//! two catalog files and the unit's environment file. Each is
5//! replaced by a rename (scxcli writes through a scratch file), so the
6//! watch is on the directories, for the names in question. A change
7//! triggers one `scxcli list` on the next tick instead of a timer.
8//!
9//! The unit's state and the kernel's are not files inotify can watch,
10//! so those keep the slow poll in the event loop.
11
12use std::collections::HashSet;
13use std::ffi::OsString;
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result};
17use inotify::{EventMask, Inotify, WatchMask};
18
19pub struct Watcher {
20    inotify: Inotify,
21    /// Watch descriptor -> the file names of interest in that directory.
22    names: Vec<(inotify::WatchDescriptor, HashSet<OsString>)>,
23    buffer: Vec<u8>,
24}
25
26impl Watcher {
27    /// Watches the directories of `files`. A directory that does not
28    /// exist yet (`/etc/scx` without an administrator's catalog) is
29    /// skipped, and the periodic poll covers it.
30    pub fn new(files: &[PathBuf]) -> Result<Self> {
31        let inotify = Inotify::init().context("inotify init failed")?;
32        let mut names: Vec<(inotify::WatchDescriptor, HashSet<OsString>)> = Vec::new();
33        for file in files {
34            let (Some(dir), Some(name)) = (file.parent(), file.file_name()) else {
35                continue;
36            };
37            if !dir.is_dir() {
38                continue;
39            }
40            match inotify.watches().add(
41                dir,
42                WatchMask::CLOSE_WRITE
43                    | WatchMask::MOVED_TO
44                    | WatchMask::CREATE
45                    | WatchMask::DELETE,
46            ) {
47                Ok(wd) => match names.iter_mut().find(|(w, _)| *w == wd) {
48                    Some((_, set)) => {
49                        set.insert(name.to_owned());
50                    }
51                    None => {
52                        names.push((wd, HashSet::from([name.to_owned()])));
53                    }
54                },
55                Err(err) => {
56                    return Err(err).with_context(|| format!("cannot watch {}", dir.display()));
57                }
58            }
59        }
60        Ok(Self {
61            inotify,
62            names,
63            buffer: vec![0; 4096],
64        })
65    }
66
67    /// Whether any watched file changed since the last call. Never
68    /// blocks.
69    pub fn changed(&mut self) -> bool {
70        let Ok(events) = self.inotify.read_events(&mut self.buffer) else {
71            return false;
72        };
73        let mut changed = false;
74        for event in events {
75            if event.mask.contains(EventMask::Q_OVERFLOW) {
76                changed = true;
77                continue;
78            }
79            let Some(name) = event.name else { continue };
80            if self
81                .names
82                .iter()
83                .any(|(wd, set)| *wd == event.wd && set.contains(name))
84            {
85                changed = true;
86            }
87        }
88        changed
89    }
90
91    /// The files worth watching for a listing.
92    pub fn files_for(config: Option<&Path>) -> Vec<PathBuf> {
93        let mut files = vec![
94            PathBuf::from(crate::model::ADMIN_PATH),
95            PathBuf::from(crate::model::SHIPPED_PATH),
96        ];
97        if let Some(config) = config {
98            files.push(config.to_path_buf());
99        }
100        files
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::fs;
108
109    #[test]
110    fn a_rename_into_a_watched_name_is_a_change_and_other_names_are_not() {
111        let dir = std::env::temp_dir().join(format!("scxtui-watch-{}", std::process::id()));
112        let _ = fs::remove_dir_all(&dir);
113        fs::create_dir_all(&dir).unwrap();
114        let file = dir.join("catalog.json");
115        let mut watcher = Watcher::new(&[file.clone(), dir.join("missing-dir").join("x")]).unwrap();
116        assert!(!watcher.changed());
117
118        fs::write(dir.join("other"), "x").unwrap();
119        assert!(!watcher.changed(), "an unrelated file is ignored");
120
121        let tmp = dir.join(".scratch");
122        fs::write(&tmp, "{}").unwrap();
123        fs::rename(&tmp, &file).unwrap();
124        assert!(watcher.changed(), "a rename into place is seen");
125        assert!(!watcher.changed(), "and reported once");
126
127        fs::write(&file, "{ }").unwrap();
128        assert!(watcher.changed(), "an in-place write is seen");
129        let _ = fs::remove_dir_all(&dir);
130    }
131}