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
15/// Whether `name` is the Windows synthetic frame-server camera.
16///
17/// Windows exposes a "Windows Virtual Camera Device" (and similarly-named virtual
18/// sources) that register the camera interface but aren't physical capture devices;
19/// exclude them so the camera list reflects real hardware. Windows-only so Linux/macOS
20/// virtual-camera naming (e.g. OBS) is left untouched.
21#[cfg(any(target_os = "windows", test))]
22fn is_windows_virtual_camera(name: &str) -> bool {
23    name.to_lowercase().contains("virtual camera")
24}
25
26pub fn clean_camera_name(name: &str) -> String {
27    let trimmed = name.trim();
28    if trimmed.starts_with("Integrated Camera:") {
29        return "Integrated Camera".to_string();
30    }
31    if trimmed.starts_with("Integrated Webcam:") {
32        return "Integrated Webcam".to_string();
33    }
34    trimmed.to_string()
35}
36
37#[cfg(target_os = "macos")]
38pub fn parse_macos_camera(stdout: &str) -> Vec<String> {
39    let mut devices = Vec::new();
40    let mut in_cameras = false;
41    for line in stdout.lines() {
42        let trimmed = line.trim();
43        let indent = line.len() - line.trim_start().len();
44        if trimmed.starts_with("Video Support:")
45            || trimmed.starts_with("Camera:")
46            || trimmed.starts_with("Cameras:")
47        {
48            in_cameras = true;
49            continue;
50        }
51        if in_cameras {
52            if indent < 4
53                && !trimmed.is_empty()
54                && !trimmed.starts_with("Camera")
55                && !trimmed.starts_with("Video Support")
56            {
57                in_cameras = false;
58                continue;
59            }
60            if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
61                let name = trimmed.trim_end_matches(':').trim().to_string();
62                if !name.is_empty() && is_real_camera(&name) {
63                    let cleaned = clean_camera_name(&name);
64                    if !devices.contains(&cleaned) {
65                        devices.push(cleaned);
66                    }
67                }
68            }
69        }
70    }
71    devices
72}
73
74pub(crate) fn detect_camera() -> Vec<String> {
75    #[cfg(target_os = "linux")]
76    {
77        let mut cameras = Vec::new();
78        if let Ok(entries) = std::fs::read_dir("/sys/class/video4linux") {
79            for entry in entries.filter_map(|e| e.ok()) {
80                let path = entry.path().join("name");
81                if path.exists() {
82                    if let Ok(name) = std::fs::read_to_string(path) {
83                        let trimmed = name.trim().to_string();
84                        if !trimmed.is_empty() && is_real_camera(&trimmed) {
85                            let cleaned = clean_camera_name(&trimmed);
86                            if !cameras.contains(&cleaned) {
87                                cameras.push(cleaned);
88                            }
89                        }
90                    }
91                }
92            }
93        }
94        cameras
95    }
96
97    #[cfg(target_os = "macos")]
98    {
99        let mut cameras = crate::macos_ffi::get_usb_cameras();
100        // Filter out IR/depth cameras that match USB UVC class but aren't real cameras
101        cameras.retain(|name| is_real_camera(name));
102        cameras
103    }
104
105    #[cfg(target_os = "windows")]
106    {
107        // Enumerate present devices exposing the KSCATEGORY_VIDEO_CAMERA *interface* class,
108        // then apply the same real-camera filter, name cleanup, and de-duplication as the
109        // other platforms. This deliberately replaces the earlier Camera + Image *setup*-class
110        // enumeration: scanners/printers live in the Image (WIA) setup class alongside some
111        // real webcams (e.g. a Logitech BRIO), so enumerating that class listed a scanner
112        // (e.g. "EPSON ET-3850 Series") as a camera. Only real cameras register the video
113        // camera interface, so filtering by it excludes scanners while keeping Image-class
114        // webcams.
115        let mut cameras = Vec::new();
116        for name in crate::win_setupapi::present_interface_device_names(
117            &crate::win_setupapi::KSCATEGORY_VIDEO_CAMERA,
118        ) {
119            if is_real_camera(&name) && !is_windows_virtual_camera(&name) {
120                let cleaned = clean_camera_name(&name);
121                if !cleaned.is_empty() && !cameras.contains(&cleaned) {
122                    cameras.push(cleaned);
123                }
124            }
125        }
126        cameras
127    }
128
129    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
130    {
131        Vec::new()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_is_real_camera() {
141        assert!(!is_real_camera("Infrared Camera"));
142        assert!(!is_real_camera("IR Camera"));
143        assert!(!is_real_camera("Integrated IR Camera"));
144        assert!(!is_real_camera("Depth Camera"));
145        assert!(is_real_camera("FaceTime HD Camera"));
146        assert!(is_real_camera("Integrated Camera"));
147        assert!(is_real_camera("HD Webcam C920"));
148    }
149
150    #[test]
151    fn test_is_windows_virtual_camera() {
152        // The Windows synthetic frame-server camera is excluded...
153        assert!(is_windows_virtual_camera("Windows Virtual Camera Device"));
154        assert!(is_windows_virtual_camera("OBS Virtual Camera"));
155        // ...while physical webcams are kept.
156        assert!(!is_windows_virtual_camera("Logitech BRIO"));
157        assert!(!is_windows_virtual_camera("ASUS FHD webcam"));
158    }
159
160    #[test]
161    fn test_clean_camera_name() {
162        assert_eq!(
163            clean_camera_name("Integrated Camera: Real"),
164            "Integrated Camera"
165        );
166        assert_eq!(
167            clean_camera_name("Integrated Webcam: HD"),
168            "Integrated Webcam"
169        );
170        assert_eq!(clean_camera_name("  HD Webcam C920  "), "HD Webcam C920");
171        assert_eq!(
172            clean_camera_name("FaceTime HD Camera"),
173            "FaceTime HD Camera"
174        );
175    }
176
177    #[cfg(target_os = "macos")]
178    #[test]
179    fn test_parse_macos_camera() {
180        let sample = "Camera:\n\n    FaceTime HD Camera:\n\n      Model ID: UVC Camera VendorID_1452 ProductID_34068\n      Unique ID: 0x8020000005ac8514\n";
181        assert_eq!(
182            parse_macos_camera(sample),
183            vec!["FaceTime HD Camera".to_string()]
184        );
185    }
186}