Skip to main content

retch_sysinfo/
camera.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Camera and webcam detection.
5
6pub fn is_real_camera(name: &str) -> bool {
7    let name_lower = name.to_lowercase();
8    !name_lower.contains("infrared")
9        && !name_lower.contains("ir camera")
10        && !name_lower.contains("integrated i")
11        && !name_lower.contains("integrated ir")
12        && !name_lower.contains("depth camera")
13}
14
15pub fn clean_camera_name(name: &str) -> String {
16    let trimmed = name.trim();
17    if trimmed.starts_with("Integrated Camera:") {
18        return "Integrated Camera".to_string();
19    }
20    if trimmed.starts_with("Integrated Webcam:") {
21        return "Integrated Webcam".to_string();
22    }
23    trimmed.to_string()
24}
25
26#[cfg(target_os = "macos")]
27pub fn parse_macos_camera(stdout: &str) -> Vec<String> {
28    let mut devices = Vec::new();
29    let mut in_cameras = false;
30    for line in stdout.lines() {
31        let trimmed = line.trim();
32        let indent = line.len() - line.trim_start().len();
33        if trimmed.starts_with("Video Support:")
34            || trimmed.starts_with("Camera:")
35            || trimmed.starts_with("Cameras:")
36        {
37            in_cameras = true;
38            continue;
39        }
40        if in_cameras {
41            if indent < 4
42                && !trimmed.is_empty()
43                && !trimmed.starts_with("Camera")
44                && !trimmed.starts_with("Video Support")
45            {
46                in_cameras = false;
47                continue;
48            }
49            if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
50                let name = trimmed.trim_end_matches(':').trim().to_string();
51                if !name.is_empty() && is_real_camera(&name) {
52                    let cleaned = clean_camera_name(&name);
53                    if !devices.contains(&cleaned) {
54                        devices.push(cleaned);
55                    }
56                }
57            }
58        }
59    }
60    devices
61}
62
63pub(crate) fn detect_camera() -> Vec<String> {
64    #[cfg(target_os = "linux")]
65    {
66        let mut cameras = Vec::new();
67        if let Ok(entries) = std::fs::read_dir("/sys/class/video4linux") {
68            for entry in entries.filter_map(|e| e.ok()) {
69                let path = entry.path().join("name");
70                if path.exists() {
71                    if let Ok(name) = std::fs::read_to_string(path) {
72                        let trimmed = name.trim().to_string();
73                        if !trimmed.is_empty() && is_real_camera(&trimmed) {
74                            let cleaned = clean_camera_name(&trimmed);
75                            if !cameras.contains(&cleaned) {
76                                cameras.push(cleaned);
77                            }
78                        }
79                    }
80                }
81            }
82        }
83        cameras
84    }
85
86    #[cfg(target_os = "macos")]
87    {
88        let mut cameras = crate::macos_ffi::get_usb_cameras();
89        // Filter out IR/depth cameras that match USB UVC class but aren't real cameras
90        cameras.retain(|name| is_real_camera(name));
91        cameras
92    }
93
94    #[cfg(target_os = "windows")]
95    {
96        let cmd = "Get-PnpDevice -Class Camera,Image -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'OK' } | Select-Object -ExpandProperty FriendlyName";
97        if let Ok(output) = std::process::Command::new("powershell")
98            .args(["-Command", cmd])
99            .output()
100        {
101            if let Ok(stdout) = String::from_utf8(output.stdout) {
102                let mut cameras = Vec::new();
103                for line in stdout.lines() {
104                    let trimmed = line.trim().to_string();
105                    if !trimmed.is_empty() && is_real_camera(&trimmed) {
106                        let cleaned = clean_camera_name(&trimmed);
107                        if !cameras.contains(&cleaned) {
108                            cameras.push(cleaned);
109                        }
110                    }
111                }
112                return cameras;
113            }
114        }
115        Vec::new()
116    }
117
118    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
119    {
120        Vec::new()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_is_real_camera() {
130        assert!(!is_real_camera("Infrared Camera"));
131        assert!(!is_real_camera("IR Camera"));
132        assert!(!is_real_camera("Integrated IR Camera"));
133        assert!(!is_real_camera("Depth Camera"));
134        assert!(is_real_camera("FaceTime HD Camera"));
135        assert!(is_real_camera("Integrated Camera"));
136        assert!(is_real_camera("HD Webcam C920"));
137    }
138
139    #[test]
140    fn test_clean_camera_name() {
141        assert_eq!(
142            clean_camera_name("Integrated Camera: Real"),
143            "Integrated Camera"
144        );
145        assert_eq!(
146            clean_camera_name("Integrated Webcam: HD"),
147            "Integrated Webcam"
148        );
149        assert_eq!(clean_camera_name("  HD Webcam C920  "), "HD Webcam C920");
150        assert_eq!(
151            clean_camera_name("FaceTime HD Camera"),
152            "FaceTime HD Camera"
153        );
154    }
155
156    #[cfg(target_os = "macos")]
157    #[test]
158    fn test_parse_macos_camera() {
159        let sample = "Camera:\n\n    FaceTime HD Camera:\n\n      Model ID: UVC Camera VendorID_1452 ProductID_34068\n      Unique ID: 0x8020000005ac8514\n";
160        assert_eq!(
161            parse_macos_camera(sample),
162            vec!["FaceTime HD Camera".to_string()]
163        );
164    }
165}