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_attr(not(target_os = "macos"), allow(dead_code))]
34pub(crate) fn classify_camera_line(line: &str) -> Option<bool> {
35 if line.contains("kCameraStreamStart") {
36 return Some(true);
37 }
38 if line.contains("kCameraStreamStop") {
39 return Some(false);
40 }
41 if let Some((_, rest)) = line.split_once("cameras changed to ") {
42 return classify_camera_set(rest);
43 }
44 None
45}
46
47#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
55fn classify_camera_set(rest: &str) -> Option<bool> {
56 let open = rest.find('[')?;
57 let inner = rest[open + 1..].trim_start();
58 let inner = inner.strip_prefix(':').map_or(inner, str::trim_start);
63 Some(!inner.starts_with(']'))
64}
65
66#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
72pub(crate) fn app_is_active(last_used_time_stop: Option<u64>) -> bool {
73 matches!(last_used_time_stop, Some(0))
74}
75
76#[cfg(target_os = "macos")]
77mod macos {
78 use std::io::{BufRead, BufReader};
79 use std::process::{Command, Stdio};
80 use std::sync::atomic::{AtomicBool, Ordering};
81 use std::sync::Arc;
82 use std::thread;
83
84 pub(super) const LOG_BIN: &str = "/usr/bin/log";
88
89 pub fn spawn(active: Arc<AtomicBool>) {
90 thread::spawn(move || {
91 let Ok(mut child) = Command::new(LOG_BIN)
92 .args([
93 "stream",
94 "--style",
95 "compact",
96 "--predicate",
97 "eventMessage contains \"Post event kCameraStream\" \
98 or eventMessage contains \"Frame publisher cameras changed to\"",
99 "--info",
100 ])
101 .stdout(Stdio::piped())
102 .stderr(Stdio::null())
103 .spawn()
104 else {
105 return;
106 };
107 let Some(stdout) = child.stdout.take() else {
108 return;
109 };
110 let reader = BufReader::new(stdout);
111 for line in reader.lines().map_while(Result::ok) {
112 if let Some(state) = super::classify_camera_line(&line) {
113 active.store(state, Ordering::Relaxed);
114 }
115 }
116 });
117 }
118}
119
120#[cfg(any(target_os = "windows", target_os = "linux"))]
124const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
125
126#[cfg(target_os = "windows")]
127mod windows {
128 use std::sync::atomic::{AtomicBool, Ordering};
129 use std::sync::Arc;
130 use std::thread;
131 use winreg::enums::HKEY_CURRENT_USER;
132 use winreg::RegKey;
133
134 const KEY_PATH: &str =
135 "Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\webcam";
136
137 pub fn spawn(active: Arc<AtomicBool>) {
138 thread::spawn(move || loop {
139 active.store(check(), Ordering::Relaxed);
140 thread::sleep(super::POLL_INTERVAL);
141 });
142 }
143
144 fn check() -> bool {
145 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
146 let Ok(root) = hkcu.open_subkey(KEY_PATH) else {
147 return false;
148 };
149 if any_active_app(&root) {
150 return true;
151 }
152 if let Ok(non_packaged) = root.open_subkey("NonPackaged") {
153 if any_active_app(&non_packaged) {
154 return true;
155 }
156 }
157 false
158 }
159
160 fn any_active_app(key: &RegKey) -> bool {
161 for name in key.enum_keys().filter_map(Result::ok) {
162 if name == "NonPackaged" {
163 continue;
164 }
165 let Ok(app_key) = key.open_subkey(&name) else {
166 continue;
167 };
168 if super::app_is_active(app_key.get_value::<u64, _>("LastUsedTimeStop").ok()) {
169 return true;
170 }
171 }
172 false
173 }
174}
175
176#[cfg(target_os = "linux")]
177mod linux {
178 use std::fs;
179 use std::sync::atomic::{AtomicBool, Ordering};
180 use std::sync::Arc;
181 use std::thread;
182
183 pub fn spawn(active: Arc<AtomicBool>) {
184 thread::spawn(move || loop {
185 active.store(check(), Ordering::Relaxed);
186 thread::sleep(super::POLL_INTERVAL);
187 });
188 }
189
190 fn check() -> bool {
191 let Ok(proc_dir) = fs::read_dir("/proc") else {
192 return false;
193 };
194 for entry in proc_dir.filter_map(Result::ok) {
195 let path = entry.path();
196 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
197 continue;
198 };
199 if !name.bytes().all(|b| b.is_ascii_digit()) {
200 continue;
201 }
202 let Ok(fds) = fs::read_dir(path.join("fd")) else {
203 continue;
204 };
205 for fd in fds.filter_map(Result::ok) {
206 let Ok(target) = fs::read_link(fd.path()) else {
207 continue;
208 };
209 if let Some(target_str) = target.to_str() {
210 if target_str.starts_with("/dev/video") {
211 return true;
212 }
213 }
214 }
215 }
216 false
217 }
218}
219
220#[cfg(all(test, target_os = "macos"))]
221mod macos_bin_tests {
222 use super::macos::LOG_BIN;
223
224 #[test]
225 fn log_bin_is_absolute_and_non_empty() {
226 assert!(!LOG_BIN.is_empty());
227 assert!(
228 LOG_BIN.starts_with('/'),
229 "expected absolute path, got {LOG_BIN}"
230 );
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::{app_is_active, classify_camera_line};
237
238 #[test]
239 fn classify_start_line_is_on() {
240 let line = "2026-05-30 ... Post event kCameraStreamStart for ...";
241 assert_eq!(classify_camera_line(line), Some(true));
242 }
243
244 #[test]
245 fn classify_stop_line_is_off() {
246 let line = "2026-05-30 ... Post event kCameraStreamStop for ...";
247 assert_eq!(classify_camera_line(line), Some(false));
248 }
249
250 #[test]
251 fn classify_unrelated_line_is_none() {
252 assert_eq!(classify_camera_line("some other log line"), None);
253 assert_eq!(classify_camera_line(""), None);
254 }
255
256 #[test]
257 fn classify_prefers_start_when_both_markers_present() {
258 let line = "kCameraStreamStart ... kCameraStreamStop";
259 assert_eq!(classify_camera_line(line), Some(true));
260 }
261
262 #[test]
263 fn classify_control_center_non_empty_set_is_on() {
264 let line = "2026-06-03 ControlCenter[1169:2621] \
265 [com.apple.controlcenter:captureFrameReceiver] Frame publisher \
266 cameras changed to [us.zoom.xos: [\"0x1000002e1a4c01\"]]";
267 assert_eq!(classify_camera_line(line), Some(true));
268 }
269
270 #[test]
271 fn classify_control_center_empty_set_is_off() {
272 let line = "2026-06-03 ControlCenter[1169:2621] \
273 [com.apple.controlcenter:captureFrameReceiver] Frame publisher \
274 cameras changed to []";
275 assert_eq!(classify_camera_line(line), Some(false));
276 }
277
278 #[test]
279 fn classify_control_center_empty_set_tolerates_inner_space() {
280 let line = "Frame publisher cameras changed to [ ]";
281 assert_eq!(classify_camera_line(line), Some(false));
282 }
283
284 #[test]
285 fn classify_control_center_empty_dict_is_off_macos26() {
286 let line = "2026-06-15 ControlCenter[808:1afb] \
290 [com.apple.controlcenter:captureFrameReceiver] Frame publisher \
291 cameras changed to [:]";
292 assert_eq!(classify_camera_line(line), Some(false));
293 }
294
295 #[test]
296 fn classify_control_center_empty_dict_tolerates_inner_space() {
297 assert_eq!(
298 classify_camera_line("Frame publisher cameras changed to [ : ]"),
299 Some(false)
300 );
301 }
302
303 #[test]
304 fn classify_control_center_single_client_dict_is_on_macos26() {
305 let line = "Frame publisher cameras changed to \
308 [com.apple.PhotoBooth: [\"EBB0ACC7-C7DB-49F1-B1FD-C77F4E234E8C\"]]";
309 assert_eq!(classify_camera_line(line), Some(true));
310 }
311
312 #[test]
313 fn classify_control_center_redacted_set_is_unknown() {
314 let line = "Frame publisher cameras changed to <private>";
315 assert_eq!(classify_camera_line(line), None);
316 }
317
318 #[test]
319 fn app_active_only_when_stop_is_zero() {
320 assert!(app_is_active(Some(0)));
321 assert!(!app_is_active(Some(133_000_000_000_000_000)));
322 assert!(!app_is_active(None));
323 }
324}