Skip to main content

entracte_lib/
camera.rs

1use std::sync::atomic::AtomicBool;
2use std::sync::Arc;
3
4pub fn spawn_monitor(active: Arc<AtomicBool>) {
5    #[cfg(target_os = "macos")]
6    macos::spawn(active);
7    #[cfg(target_os = "windows")]
8    windows::spawn(active);
9    #[cfg(target_os = "linux")]
10    linux::spawn(active);
11    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
12    let _ = active;
13}
14
15#[cfg(target_os = "macos")]
16mod macos {
17    use std::io::{BufRead, BufReader};
18    use std::process::{Command, Stdio};
19    use std::sync::atomic::{AtomicBool, Ordering};
20    use std::sync::Arc;
21    use std::thread;
22
23    // Absolute path keeps `$PATH` lookups from picking up a planted
24    // `log` binary earlier in `PATH`. `/usr/bin/log` is the canonical
25    // location on every supported macOS release.
26    pub(super) const LOG_BIN: &str = "/usr/bin/log";
27
28    pub fn spawn(active: Arc<AtomicBool>) {
29        thread::spawn(move || {
30            let Ok(mut child) = Command::new(LOG_BIN)
31                .args([
32                    "stream",
33                    "--style",
34                    "compact",
35                    "--predicate",
36                    "eventMessage contains \"Post event kCameraStream\"",
37                    "--info",
38                ])
39                .stdout(Stdio::piped())
40                .stderr(Stdio::null())
41                .spawn()
42            else {
43                return;
44            };
45            let Some(stdout) = child.stdout.take() else {
46                return;
47            };
48            let reader = BufReader::new(stdout);
49            for line in reader.lines().map_while(Result::ok) {
50                if line.contains("kCameraStreamStart") {
51                    active.store(true, Ordering::Relaxed);
52                } else if line.contains("kCameraStreamStop") {
53                    active.store(false, Ordering::Relaxed);
54                }
55            }
56        });
57    }
58}
59
60// Same rationale as `video.rs::POLL_INTERVAL`: 10s is the sweet spot
61// between catching a just-started webcam session and not hammering
62// the registry / `/proc` walker many times a minute.
63#[cfg(any(target_os = "windows", target_os = "linux"))]
64const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
65
66#[cfg(target_os = "windows")]
67mod windows {
68    use std::sync::atomic::{AtomicBool, Ordering};
69    use std::sync::Arc;
70    use std::thread;
71    use winreg::enums::HKEY_CURRENT_USER;
72    use winreg::RegKey;
73
74    const KEY_PATH: &str =
75        "Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\webcam";
76
77    pub fn spawn(active: Arc<AtomicBool>) {
78        thread::spawn(move || loop {
79            active.store(check(), Ordering::Relaxed);
80            thread::sleep(super::POLL_INTERVAL);
81        });
82    }
83
84    fn check() -> bool {
85        let hkcu = RegKey::predef(HKEY_CURRENT_USER);
86        let Ok(root) = hkcu.open_subkey(KEY_PATH) else {
87            return false;
88        };
89        if any_active_app(&root) {
90            return true;
91        }
92        if let Ok(non_packaged) = root.open_subkey("NonPackaged") {
93            if any_active_app(&non_packaged) {
94                return true;
95            }
96        }
97        false
98    }
99
100    fn any_active_app(key: &RegKey) -> bool {
101        for name in key.enum_keys().filter_map(Result::ok) {
102            if name == "NonPackaged" {
103                continue;
104            }
105            let Ok(app_key) = key.open_subkey(&name) else {
106                continue;
107            };
108            if let Ok(stop) = app_key.get_value::<u64, _>("LastUsedTimeStop") {
109                if stop == 0 {
110                    return true;
111                }
112            }
113        }
114        false
115    }
116}
117
118#[cfg(target_os = "linux")]
119mod linux {
120    use std::fs;
121    use std::sync::atomic::{AtomicBool, Ordering};
122    use std::sync::Arc;
123    use std::thread;
124
125    pub fn spawn(active: Arc<AtomicBool>) {
126        thread::spawn(move || loop {
127            active.store(check(), Ordering::Relaxed);
128            thread::sleep(super::POLL_INTERVAL);
129        });
130    }
131
132    fn check() -> bool {
133        let Ok(proc_dir) = fs::read_dir("/proc") else {
134            return false;
135        };
136        for entry in proc_dir.filter_map(Result::ok) {
137            let path = entry.path();
138            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
139                continue;
140            };
141            if !name.bytes().all(|b| b.is_ascii_digit()) {
142                continue;
143            }
144            let Ok(fds) = fs::read_dir(path.join("fd")) else {
145                continue;
146            };
147            for fd in fds.filter_map(Result::ok) {
148                let Ok(target) = fs::read_link(fd.path()) else {
149                    continue;
150                };
151                if let Some(target_str) = target.to_str() {
152                    if target_str.starts_with("/dev/video") {
153                        return true;
154                    }
155                }
156            }
157        }
158        false
159    }
160}
161
162#[cfg(all(test, target_os = "macos"))]
163mod macos_bin_tests {
164    use super::macos::LOG_BIN;
165
166    #[test]
167    fn log_bin_is_absolute_and_non_empty() {
168        assert!(!LOG_BIN.is_empty());
169        assert!(
170            LOG_BIN.starts_with('/'),
171            "expected absolute path, got {LOG_BIN}"
172        );
173    }
174}