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/// Classify one line of macOS `log stream` output: `Some(true)` when the
16/// camera turned on, `Some(false)` when it turned off, `None` for an
17/// unrelated line. Start wins if a single line somehow carries both
18/// markers, matching the original `if/else if` ordering. Kept un-gated so
19/// the start/stop contract is unit-tested on every OS — only the macOS
20/// log-stream reader calls it in non-test builds.
21///
22/// Two signals are recognised:
23///   * Legacy (pre-macOS 26) `AppleCameraAssistant` posts
24///     `kCameraStreamStart`/`kCameraStreamStop` events.
25///   * macOS 26+ stopped posting those on Apple Silicon (#113). Control
26///     Center instead publishes the *full set* of in-use cameras as
27///     `Frame publisher cameras changed to [...]`. The payload is keyed by
28///     client, e.g. `[us.zoom.xos: ["0x…"]]`; "every camera released" is an
29///     empty collection — `[]` on earlier macOS, `[:]` (an empty
30///     *dictionary*) on macOS 26 (#158). A non-empty payload means at least
31///     one camera is live. This aggregate is more robust than the old
32///     per-stream events.
33#[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/// Decide whether Control Center's published camera set is empty. `rest`
48/// is everything after `"cameras changed to "`. `Some(false)` for an empty
49/// collection — `[]` (earlier macOS) or `[:]` (the empty-dictionary form
50/// macOS 26 emits on release, #158) — and `Some(true)` for a non-empty one.
51/// Returns `None` when the payload is redacted (`<private>`) or otherwise
52/// has no bracketed collection — we can't tell the state, so we leave it
53/// unchanged rather than guess.
54#[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    // An empty dictionary prints as `[:]`; drop the leading colon so the
59    // empty check below treats it the same as the empty array `[]`. A
60    // non-empty payload (`[bundle.id: [...]]`) has content before the colon,
61    // so this strip is a no-op there.
62    let inner = inner.strip_prefix(':').map_or(inner, str::trim_start);
63    Some(!inner.starts_with(']'))
64}
65
66/// The Windows webcam-in-use rule: an app's `LastUsedTimeStop` of `0`
67/// means it is *currently* streaming (a non-zero value is the timestamp it
68/// stopped). Extracted so the rule itself is testable without a registry;
69/// the registry walk in `windows::any_active_app` feeds it. Un-gated so
70/// it's tested everywhere; only the Windows walk calls it in non-test builds.
71#[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    // Absolute path keeps `$PATH` lookups from picking up a planted
85    // `log` binary earlier in `PATH`. `/usr/bin/log` is the canonical
86    // location on every supported macOS release.
87    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// Same rationale as `video.rs::POLL_INTERVAL`: 10s is the sweet spot
121// between catching a just-started webcam session and not hammering
122// the registry / `/proc` walker many times a minute.
123#[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        // macOS 26 publishes the in-use set as a dictionary keyed by client;
287        // "all released" is the empty-dictionary literal `[:]`, not `[]`
288        // (#158). Captured verbatim from a macOS 26.5 log stream.
289        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        // The non-empty macOS 26 form, captured verbatim: one client holding
306        // a camera. The leading-colon strip must not fire here.
307        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}