From fdc852864fb8dba88cf6270424d3aff7fb389066 Mon Sep 17 00:00:00 2001 From: David Budnick Date: Fri, 26 Jun 2026 10:34:18 -0500 Subject: [PATCH] feat(camera): add Logitech webcam support --- Cargo.lock | 14 + Cargo.toml | 1 + crates/openlogi-assets/src/metadata.rs | 32 +- crates/openlogi-camera/Cargo.toml | 47 + crates/openlogi-camera/src/capture.rs | 459 ++++++++++ crates/openlogi-camera/src/capture_types.rs | 45 + crates/openlogi-camera/src/capture_windows.rs | 405 +++++++++ crates/openlogi-camera/src/controls.rs | 130 +++ crates/openlogi-camera/src/lib.rs | 331 +++++++ crates/openlogi-camera/src/macos.rs | 197 ++++ crates/openlogi-camera/src/uvc.rs | 774 ++++++++++++++++ crates/openlogi-camera/src/uvc_windows.rs | 407 +++++++++ crates/openlogi-cli/Cargo.toml | 2 + crates/openlogi-cli/src/cmd/camera.rs | 101 +++ crates/openlogi-cli/src/cmd/list.rs | 30 +- crates/openlogi-cli/src/cmd/mod.rs | 10 + crates/openlogi-cli/src/cmd/snapshot.rs | 57 ++ crates/openlogi-core/src/config.rs | 92 ++ crates/openlogi-core/src/device.rs | 2 + crates/openlogi-core/src/diagnostics.rs | 1 + crates/openlogi-gui/Cargo.toml | 1 + crates/openlogi-gui/bundle/gui-dev/Info.plist | 1 + crates/openlogi-gui/locales/da.yml | 22 + crates/openlogi-gui/locales/de.yml | 22 + crates/openlogi-gui/locales/el.yml | 22 + crates/openlogi-gui/locales/en.yml | 22 + crates/openlogi-gui/locales/es.yml | 22 + crates/openlogi-gui/locales/fi.yml | 22 + crates/openlogi-gui/locales/fr.yml | 22 + crates/openlogi-gui/locales/it.yml | 22 + crates/openlogi-gui/locales/ja.yml | 22 + crates/openlogi-gui/locales/ko.yml | 22 + crates/openlogi-gui/locales/nb.yml | 22 + crates/openlogi-gui/locales/nl.yml | 22 + crates/openlogi-gui/locales/pl.yml | 22 + crates/openlogi-gui/locales/pt-BR.yml | 22 + crates/openlogi-gui/locales/pt-PT.yml | 22 + crates/openlogi-gui/locales/ru.yml | 22 + crates/openlogi-gui/locales/sv.yml | 22 + crates/openlogi-gui/locales/zh-CN.yml | 22 + crates/openlogi-gui/locales/zh-HK.yml | 22 + crates/openlogi-gui/locales/zh-TW.yml | 22 + crates/openlogi-gui/src/app.rs | 184 +++- crates/openlogi-gui/src/asset/images.rs | 8 +- crates/openlogi-gui/src/asset/sync.rs | 25 +- .../src/components/camera_controls.rs | 842 ++++++++++++++++++ .../src/components/camera_preview.rs | 172 ++++ .../openlogi-gui/src/components/carousel.rs | 83 +- crates/openlogi-gui/src/components/mod.rs | 2 + crates/openlogi-gui/src/main.rs | 56 +- .../openlogi-gui/src/platform/permissions.rs | 17 + crates/openlogi-gui/src/state.rs | 125 ++- crates/openlogi-gui/src/state/devices.rs | 87 +- crates/openlogi-gui/src/windows/settings.rs | 7 +- .../src/windows/settings/permissions.rs | 29 +- 55 files changed, 5134 insertions(+), 82 deletions(-) create mode 100644 crates/openlogi-camera/Cargo.toml create mode 100644 crates/openlogi-camera/src/capture.rs create mode 100644 crates/openlogi-camera/src/capture_types.rs create mode 100644 crates/openlogi-camera/src/capture_windows.rs create mode 100644 crates/openlogi-camera/src/controls.rs create mode 100644 crates/openlogi-camera/src/lib.rs create mode 100644 crates/openlogi-camera/src/macos.rs create mode 100644 crates/openlogi-camera/src/uvc.rs create mode 100644 crates/openlogi-camera/src/uvc_windows.rs create mode 100644 crates/openlogi-cli/src/cmd/camera.rs create mode 100644 crates/openlogi-cli/src/cmd/snapshot.rs create mode 100644 crates/openlogi-gui/src/components/camera_controls.rs create mode 100644 crates/openlogi-gui/src/components/camera_preview.rs diff --git a/Cargo.lock b/Cargo.lock index 9b1b68fc..decd7975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4888,6 +4888,17 @@ dependencies = [ "ureq", ] +[[package]] +name = "openlogi-camera" +version = "0.6.19" +dependencies = [ + "block", + "objc", + "serde", + "tracing", + "windows 0.61.3", +] + [[package]] name = "openlogi-cli" version = "0.6.19" @@ -4895,8 +4906,10 @@ dependencies = [ "anyhow", "clap", "openlogi-assets", + "openlogi-camera", "openlogi-core", "openlogi-hid", + "png 0.17.16", "tokio", "tracing", "tracing-subscriber", @@ -4937,6 +4950,7 @@ dependencies = [ "opener", "openlogi-agent-core", "openlogi-assets", + "openlogi-camera", "openlogi-core", "openlogi-hid", "openlogi-hook", diff --git a/Cargo.toml b/Cargo.toml index 0782dafc..5ef382e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/openlogi-inject", "crates/openlogi-hidpp", "crates/openlogi-hid", + "crates/openlogi-camera", "crates/openlogi-assets", "crates/openlogi-cli", "crates/openlogi-agent-core", diff --git a/crates/openlogi-assets/src/metadata.rs b/crates/openlogi-assets/src/metadata.rs index cdf0cc83..f2b48731 100644 --- a/crates/openlogi-assets/src/metadata.rs +++ b/crates/openlogi-assets/src/metadata.rs @@ -74,12 +74,16 @@ pub struct Assignment { /// `map_slot_name`-style consumers treat unknown names as "no hotspot". #[serde(rename = "slotName", default)] pub slot_name: String, + /// Camera depots ship marker-less settings-slot assignments (under the + /// `device_camera_image` entry, which no hotspot consumer reads); a + /// missing marker defaults to the origin rather than failing the file. + #[serde(default)] pub marker: Point, #[serde(default)] pub label: Direction, } -#[derive(Debug, Deserialize, Clone, Copy)] +#[derive(Debug, Deserialize, Clone, Copy, Default)] pub struct Point { pub x: f32, pub y: f32, @@ -145,4 +149,30 @@ mod tests { assert_eq!((origin.width, origin.height), (3598, 1315)); assert_eq!(meta.images[0].assignments[0].slot_name, ""); } + + /// Camera depots (StreamCam) list settings-slot assignments with no + /// `marker` under their `device_camera_image` entry. Parsing must not + /// fail wholesale, and `assignments()` must not surface them (it reads + /// only the `device_buttons_image` entry). + #[test] + fn camera_metadata_without_markers_parses() { + let json = r#"{ + "images": [ + { "key": "device_image", "origin": { "width": 1280, "height": 800 } }, + { + "key": "device_camera_image", + "origin": { "width": 396, "height": 396 }, + "assignments": [ + { "slotId": "streamcam-0893_webcam_camera_settings", + "slotName": "SLOT_NAME_WEBCAM_CAMERA_SETTINGS", + "disableAssignmentClick": true } + ] + } + ] + }"#; + let meta: Metadata = serde_json::from_str(json).expect("camera schema must parse"); + let origin = meta.origin().expect("origin survives"); + assert_eq!((origin.width, origin.height), (1280, 800)); + assert_eq!(meta.assignments().count(), 0); + } } diff --git a/crates/openlogi-camera/Cargo.toml b/crates/openlogi-camera/Cargo.toml new file mode 100644 index 00000000..06674cc8 --- /dev/null +++ b/crates/openlogi-camera/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "openlogi-camera" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Generic Logitech UVC webcam discovery for OpenLogi (AVFoundation on macOS, DirectShow on Windows)." +keywords = ["logitech", "uvc", "webcam", "camera", "avfoundation"] +categories = ["hardware-support"] + +[dependencies] +serde = { workspace = true } +tracing = { workspace = true } + +# AVFoundation (AVCaptureDevice) enumeration — same objc 0.2 toolchain the GUI's +# menu-bar / permission FFI uses, so it's already proven in this workspace. +[target.'cfg(target_os = "macos")'.dependencies] +objc = "0.2" +block = "0.1" + +# DirectShow enumeration + IAMVideoProcAmp / IAMCameraControl UVC controls. +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Media_DirectShow", + "Win32_Media_MediaFoundation", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Variant", + "Win32_System_Ole", +] } + +# Mirrors [workspace.lints], but allows the legacy `cfg(cargo-clippy)` check the +# objc 0.2 macros emit (same as openlogi-gui / openlogi-hook). unsafe_code stays +# denied; the AVFoundation module opts in locally with #[expect(unsafe_code)]. +[lints.rust] +unsafe_code = "deny" +unexpected_cfgs = { level = "allow", check-cfg = [] } + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +unwrap_used = "warn" +expect_used = "warn" +missing_errors_doc = "allow" +doc_markdown = "allow" diff --git a/crates/openlogi-camera/src/capture.rs b/crates/openlogi-camera/src/capture.rs new file mode 100644 index 00000000..81c14be0 --- /dev/null +++ b/crates/openlogi-camera/src/capture.rs @@ -0,0 +1,459 @@ +//! AVFoundation camera capture: a one-shot snapshot and a live frame stream. +//! +//! Both open an `AVCaptureSession` on the chosen camera and read BGRA frames +//! through an `AVCaptureVideoDataOutput` delegate. Frames are kept in gpui's +//! native **BGRA** order so the preview uploads them with no channel swap; the +//! snapshot path swaps to RGBA once when it encodes the PNG. Capturing (unlike +//! enumeration) needs Camera permission *and* an app bundle carrying +//! `NSCameraUsageDescription`; from an unbundled binary macOS denies access, +//! which surfaces as [`CaptureError::AccessDenied`]. + +#![expect( + unsafe_code, + reason = "AVFoundation / CoreMedia / CoreVideo capture FFI" +)] +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_possible_wrap, + reason = "pixel dimensions and FourCC constants are bounded and copied verbatim" +)] + +use std::ffi::{CString, c_void}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use block::ConcreteBlock; +use objc::declare::ClassDecl; +use objc::rc::StrongPtr; +use objc::runtime::{BOOL, Class, NO, Object, Sel}; +use objc::{class, msg_send, sel, sel_impl}; + +pub use crate::capture_types::{CaptureError, Frame}; + +/// kCVPixelFormatType_32BGRA ('BGRA'). +const PIXEL_FORMAT_32BGRA: u32 = 0x4247_5241; +/// kCVPixelBufferLock_ReadOnly. +const LOCK_READ_ONLY: u64 = 1; + +// The most recent frame the delegate decoded, behind an `Arc` so the preview's +// poll hands out a cheap refcount bump instead of copying the whole buffer. A +// process previews one camera at a time, so a single global sink is enough and +// keeps the delegate stateless. +static LATEST: OnceLock>>> = OnceLock::new(); +fn latest() -> &'static Mutex>> { + LATEST.get_or_init(|| Mutex::new(None)) +} + +/// Increments on every delivered frame, so a poller can tell a new frame from a +/// repeat without comparing pixel buffers. +static FRAME_GEN: AtomicU64 = AtomicU64::new(0); + +/// Target max width for delegate downsampling (0 = full resolution). Previews +/// set this so an oversized buffer decimates down in one strided pass instead +/// of copying (and uploading) pixels the preview can never show. +static PREVIEW_TARGET_W: AtomicU32 = AtomicU32::new(0); + +#[link(name = "AVFoundation", kind = "framework")] +unsafe extern "C" { + static AVMediaTypeVideo: *const Object; + static AVCaptureSessionPreset1280x720: *const Object; +} + +#[link(name = "CoreMedia", kind = "framework")] +unsafe extern "C" { + fn CMSampleBufferGetImageBuffer(sbuf: *mut Object) -> *mut Object; +} + +#[link(name = "CoreVideo", kind = "framework")] +unsafe extern "C" { + static kCVPixelBufferPixelFormatTypeKey: *const Object; + fn CVPixelBufferLockBaseAddress(pb: *mut Object, flags: u64) -> i32; + fn CVPixelBufferUnlockBaseAddress(pb: *mut Object, flags: u64) -> i32; + fn CVPixelBufferGetBaseAddress(pb: *mut Object) -> *mut c_void; + fn CVPixelBufferGetBytesPerRow(pb: *mut Object) -> usize; + fn CVPixelBufferGetWidth(pb: *mut Object) -> usize; + fn CVPixelBufferGetHeight(pb: *mut Object) -> usize; +} + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + static kCFRunLoopDefaultMode: *const c_void; + fn CFRunLoopRunInMode( + mode: *const c_void, + seconds: f64, + return_after_source_handled: BOOL, + ) -> i32; +} + +unsafe extern "C" { + fn dispatch_queue_create(label: *const i8, attr: *const c_void) -> *mut Object; +} + +/// Delegate callback: `captureOutput:didOutputSampleBuffer:fromConnection:`. +/// Copies the sample buffer's BGRA pixels (optionally decimated) into [`latest`]. +extern "C" fn did_output( + _this: &Object, + _sel: Sel, + _output: *mut Object, + sbuf: *mut Object, + _conn: *mut Object, +) { + // SAFETY: `sbuf` is a valid CMSampleBuffer delivered by AVFoundation; the + // image buffer is locked for the span of the read and unlocked before return. + unsafe { + let pb = CMSampleBufferGetImageBuffer(sbuf); + if pb.is_null() || CVPixelBufferLockBaseAddress(pb, LOCK_READ_ONLY) != 0 { + return; + } + let base = CVPixelBufferGetBaseAddress(pb).cast::(); + let bytes_per_row = CVPixelBufferGetBytesPerRow(pb); + let width = CVPixelBufferGetWidth(pb); + let height = CVPixelBufferGetHeight(pb); + let target = PREVIEW_TARGET_W.load(Ordering::Relaxed) as usize; + let step = if target > 0 && width > target { + width.div_ceil(target) + } else { + 1 + }; + let out_w = width / step; + let out_h = height / step; + if !base.is_null() && out_w > 0 && out_h > 0 { + let mut bgra = vec![0u8; out_w * out_h * 4]; + let dst = bgra.as_mut_ptr(); + if step == 1 { + // Source is already BGRA (kCVPixelFormatType_32BGRA) — one + // memcpy per row, skipping any driver row padding. + for oy in 0..out_h { + let row = base.add(oy * bytes_per_row); + std::ptr::copy_nonoverlapping(row, dst.add(oy * out_w * 4), out_w * 4); + } + } else { + for oy in 0..out_h { + let row = base.add(oy * step * bytes_per_row); + for ox in 0..out_w { + let src = row.add(ox * step * 4); + let out = (oy * out_w + ox) * 4; + std::ptr::copy_nonoverlapping(src, dst.add(out), 4); + } + } + } + if let Ok(mut slot) = latest().lock() { + *slot = Some(Arc::new(Frame { + width: out_w as u32, + height: out_h as u32, + bgra, + })); + FRAME_GEN.fetch_add(1, Ordering::Relaxed); + } + } + CVPixelBufferUnlockBaseAddress(pb, LOCK_READ_ONLY); + } +} + +fn delegate_class() -> *const Class { + static CACHE: OnceLock = OnceLock::new(); + let ptr = *CACHE.get_or_init(|| { + let superclass = class!(NSObject); + match ClassDecl::new("OLCameraFrameDelegate", superclass) { + Some(mut decl) => { + // SAFETY: the registered selector matches `did_output`'s ABI + // (the standard sample-buffer delegate signature). + unsafe { + decl.add_method( + sel!(captureOutput:didOutputSampleBuffer:fromConnection:), + did_output + as extern "C" fn(&Object, Sel, *mut Object, *mut Object, *mut Object), + ); + } + std::ptr::from_ref::(decl.register()) as usize + } + // Already registered (re-entry): look it up. + None => Class::get("OLCameraFrameDelegate") + .map_or(std::ptr::null::() as usize, |c| { + std::ptr::from_ref(c) as usize + }), + } + }); + ptr as *const Class +} + +/// Current Camera authorization: `Some(true)` usable, `Some(false)` denied, +/// `None` undetermined (caller should request access). +fn authorization() -> Option { + let cls = class!(AVCaptureDevice); + // SAFETY: documented class method returning an AVAuthorizationStatus NSInteger. + let status: isize = + unsafe { msg_send![cls, authorizationStatusForMediaType: AVMediaTypeVideo] }; + match status { + 3 => Some(true), + 1 | 2 => Some(false), + _ => None, + } +} + +/// Request Camera access and block until the user answers (or `timeout`). +fn request_access(timeout: Duration) -> bool { + let answered = std::sync::Arc::new(Mutex::new(None::)); + let sink = answered.clone(); + let handler = ConcreteBlock::new(move |granted: BOOL| { + if let Ok(mut slot) = sink.lock() { + *slot = Some(granted != NO); + } + }); + let handler = handler.copy(); + let cls = class!(AVCaptureDevice); + // SAFETY: documented async class method taking an AVMediaType + a + // `void(^)(BOOL)` completion block; the block outlives the call below. + unsafe { + let _: () = msg_send![cls, requestAccessForMediaType: AVMediaTypeVideo completionHandler: &*handler]; + } + let deadline = Instant::now() + timeout; + loop { + if let Ok(slot) = answered.lock() + && let Some(granted) = *slot + { + return granted; + } + if Instant::now() >= deadline { + return false; + } + run_loop_tick(0.05); + } +} + +/// Ensure the process may use the camera, requesting access if undetermined. +fn ensure_access() -> Result<(), CaptureError> { + match authorization() { + Some(true) => Ok(()), + None if request_access(Duration::from_secs(30)) => Ok(()), + _ => Err(CaptureError::AccessDenied), + } +} + +/// Whether the process currently holds Camera permission, without prompting. +/// Lets the GUI start a preview only when access is already granted (so it never +/// blocks the UI thread on the permission dialog). +#[must_use] +pub fn camera_access_granted() -> bool { + matches!(authorization(), Some(true)) +} + +/// Current Camera permission as a tri-state, without prompting. Lets the +/// Settings window distinguish Denied from not-yet-asked. +#[must_use] +pub fn camera_authorization() -> crate::CameraAuthorization { + match authorization() { + Some(true) => crate::CameraAuthorization::Granted, + Some(false) => crate::CameraAuthorization::Denied, + None => crate::CameraAuthorization::Undetermined, + } +} + +/// Pump the current thread's run loop briefly so AVFoundation callbacks fire. +fn run_loop_tick(seconds: f64) { + // SAFETY: `kCFRunLoopDefaultMode` is a valid mode constant; the call returns + // after `seconds` or the first handled source. + unsafe { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, seconds, NO); + } +} + +fn device_with_unique_id(unique_id: &str) -> Option { + let cls = class!(AVCaptureDevice); + let Ok(ns) = CString::new(unique_id) else { + return None; + }; + // SAFETY: building an autoreleased NSString from a valid C string, then a + // `deviceWithUniqueID:` lookup; the result is retained into a StrongPtr. + unsafe { + let nsstr: *mut Object = msg_send![class!(NSString), stringWithUTF8String: ns.as_ptr()]; + let device: *mut Object = msg_send![cls, deviceWithUniqueID: nsstr]; + if device.is_null() { + None + } else { + Some(StrongPtr::retain(device)) + } + } +} + +/// A running capture session. Frames flow to the delegate on a background +/// dispatch queue and land in [`latest`]; dropping the session stops it. +struct Session { + handle: StrongPtr, + _output: StrongPtr, + _delegate: StrongPtr, +} + +impl Drop for Session { + fn drop(&mut self) { + // SAFETY: `self.session` is a valid, retained AVCaptureSession. + unsafe { + let _: () = msg_send![*self.handle, stopRunning]; + } + } +} + +/// Authorize, wire up, and start a capture session on `unique_id`. Frames begin +/// arriving in [`latest`] shortly after this returns. +fn open_session(unique_id: &str, low_res: bool) -> Result { + ensure_access()?; + let device = device_with_unique_id(unique_id).ok_or(CaptureError::NotFound)?; + if let Ok(mut slot) = latest().lock() { + *slot = None; + } + // Previews cap at 720p-wide frames (the preview preset below already + // delivers exactly that; the decimator only kicks in if a camera ignores + // the preset and streams wider). Snapshots keep full resolution. + PREVIEW_TARGET_W.store(if low_res { 1280 } else { 0 }, Ordering::Relaxed); + + // SAFETY: standard AVCaptureSession wiring with documented selectors; every + // object added is retained by the session, and the session is stopped on Drop. + unsafe { + let session: *mut Object = msg_send![class!(AVCaptureSession), new]; + if session.is_null() { + return Err(CaptureError::Setup("AVCaptureSession".into())); + } + let session = StrongPtr::new(session); + + let mut err: *mut Object = std::ptr::null_mut(); + let input: *mut Object = msg_send![ + class!(AVCaptureDeviceInput), + deviceInputWithDevice: *device error: std::ptr::addr_of_mut!(err) + ]; + if input.is_null() { + return Err(CaptureError::Setup("AVCaptureDeviceInput".into())); + } + let can_in: BOOL = msg_send![*session, canAddInput: input]; + if can_in == NO { + return Err(CaptureError::Setup("session rejected input".into())); + } + let _: () = msg_send![*session, addInput: input]; + + // Preview streams capture at 720p — sharp on a Retina-scale preview + // (the 480pt box is 960 physical pixels wide) while still a fraction + // of the native 1080p per-frame copy + texture upload. + if low_res { + let can: BOOL = + msg_send![*session, canSetSessionPreset: AVCaptureSessionPreset1280x720]; + if can != NO { + let _: () = msg_send![*session, setSessionPreset: AVCaptureSessionPreset1280x720]; + } + } + + let output: *mut Object = msg_send![class!(AVCaptureVideoDataOutput), new]; + let output = StrongPtr::new(output); + let num: *mut Object = + msg_send![class!(NSNumber), numberWithUnsignedInt: PIXEL_FORMAT_32BGRA]; + let settings: *mut Object = msg_send![ + class!(NSDictionary), + dictionaryWithObject: num forKey: kCVPixelBufferPixelFormatTypeKey + ]; + let _: () = msg_send![*output, setVideoSettings: settings]; + let _: () = msg_send![*output, setAlwaysDiscardsLateVideoFrames: true]; + + let delegate_cls = delegate_class(); + if delegate_cls.is_null() { + return Err(CaptureError::Setup("delegate class".into())); + } + let cls_ref: &Class = &*delegate_cls; + let delegate: *mut Object = msg_send![cls_ref, new]; + let delegate = StrongPtr::new(delegate); + let queue = dispatch_queue_create(c"org.openlogi.camera".as_ptr(), std::ptr::null()); + let _: () = msg_send![*output, setSampleBufferDelegate: *delegate queue: queue]; + + let can_out: BOOL = msg_send![*session, canAddOutput: *output]; + if can_out == NO { + return Err(CaptureError::Setup("session rejected output".into())); + } + let _: () = msg_send![*session, addOutput: *output]; + + // Selfie-mirror the live preview (not snapshots): a webcam self-view is + // expected to read like a mirror. The driver flips on the connection, so + // it costs zero per-frame CPU and never alters the outbound camera feed. + if low_res { + let conn: *mut Object = msg_send![*output, connectionWithMediaType: AVMediaTypeVideo]; + if !conn.is_null() { + let supported: BOOL = msg_send![conn, isVideoMirroringSupported]; + if supported != NO { + let _: () = msg_send![conn, setAutomaticallyAdjustsVideoMirroring: false]; + let _: () = msg_send![conn, setVideoMirrored: true]; + } + } + } + + let _: () = msg_send![*session, startRunning]; + + Ok(Session { + handle: session, + _output: output, + _delegate: delegate, + }) + } +} + +/// Capture a single full-resolution [`Frame`] (BGRA) from the camera with +/// `unique_id`. +/// +/// # Errors +/// [`CaptureError::AccessDenied`] when Camera permission isn't (and can't be) +/// granted, [`CaptureError::NotFound`] for an unknown id, [`CaptureError::Timeout`] +/// when no frame arrives, or [`CaptureError::Setup`] on AVFoundation errors. +pub fn capture_frame(unique_id: &str, timeout: Duration) -> Result { + let _session = open_session(unique_id, false)?; + let deadline = Instant::now() + timeout; + loop { + if let Ok(mut slot) = latest().lock() + && let Some(frame) = slot.take() + { + return Ok(Arc::unwrap_or_clone(frame)); + } + if Instant::now() >= deadline { + return Err(CaptureError::Timeout); + } + run_loop_tick(0.03); + } +} + +/// A live preview stream. Holds the session open; [`CameraStream::latest_frame`] +/// returns the most recent frame each time it's polled. Dropping it stops the +/// camera. +pub struct CameraStream { + _session: Session, +} + +impl CameraStream { + /// The most recently delivered frame, or `None` before the first arrives. + /// Returns a shared [`Arc`] so polling at preview rate never copies the + /// pixel buffer. + #[must_use] + pub fn latest_frame(&self) -> Option> { + latest().lock().ok().and_then(|slot| slot.clone()) + } + + /// Take the most recent frame out of the slot (the next delivered frame + /// refills it). A sole consumer that unwraps the [`Arc`] gets the pixel + /// buffer without copying it. + #[must_use] + pub fn take_frame(&self) -> Option> { + latest().lock().ok().and_then(|mut slot| slot.take()) + } + + /// A counter that increments on every delivered frame, so the preview can + /// skip rebuilding its texture when no new frame has arrived. + #[must_use] + pub fn frame_generation(&self) -> u64 { + FRAME_GEN.load(Ordering::Relaxed) + } +} + +/// Start a live capture stream on the camera with `unique_id`. +/// +/// # Errors +/// Same as [`capture_frame`], minus `Timeout` (frames are polled, not awaited). +pub fn start_stream(unique_id: &str) -> Result { + Ok(CameraStream { + _session: open_session(unique_id, true)?, + }) +} diff --git a/crates/openlogi-camera/src/capture_types.rs b/crates/openlogi-camera/src/capture_types.rs new file mode 100644 index 00000000..99a57225 --- /dev/null +++ b/crates/openlogi-camera/src/capture_types.rs @@ -0,0 +1,45 @@ +//! Platform-independent capture vocabulary shared by every capture backend +//! (AVFoundation on macOS, Media Foundation on Windows, stubs elsewhere). + +/// One decoded camera frame, tightly-packed BGRA8 (`width * height * 4` bytes) — +/// gpui's native texture order, so the preview uploads it without a channel +/// swap. The snapshot path swaps to RGBA when it writes the PNG. +#[derive(Clone)] +pub struct Frame { + pub width: u32, + pub height: u32, + pub bgra: Vec, +} + +/// Why a capture attempt failed. +#[derive(Debug, Clone)] +pub enum CaptureError { + /// Camera permission is denied/restricted, or this process can't request + /// it (e.g. an unbundled macOS binary with no `NSCameraUsageDescription`). + AccessDenied, + /// No camera matched the requested unique id. + NotFound, + /// The session ran but produced no frame within the timeout. + Timeout, + /// A platform capture object failed to construct. + Setup(String), + /// Capture has no backend on this platform. + Unsupported, +} + +impl std::fmt::Display for CaptureError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AccessDenied => write!( + f, + "camera access denied — grant Camera permission (on macOS, run inside an app bundle with NSCameraUsageDescription)" + ), + Self::NotFound => write!(f, "no camera matched that id"), + Self::Timeout => write!(f, "camera produced no frame in time"), + Self::Setup(s) => write!(f, "capture setup failed: {s}"), + Self::Unsupported => write!(f, "camera capture is not implemented on this platform"), + } + } +} + +impl std::error::Error for CaptureError {} diff --git a/crates/openlogi-camera/src/capture_windows.rs b/crates/openlogi-camera/src/capture_windows.rs new file mode 100644 index 00000000..a1150deb --- /dev/null +++ b/crates/openlogi-camera/src/capture_windows.rs @@ -0,0 +1,405 @@ +//! Media Foundation camera capture (Windows): a one-shot snapshot and a live +//! frame stream. +//! +//! A dedicated reader thread owns the whole Media Foundation object graph — +//! device activation, `IMFSourceReader`, format negotiation — and pulls +//! samples synchronously, decoding into the same tightly-packed BGRA +//! [`Frame`]s the macOS backend produces (RGB32 sample memory is BGRA in +//! little-endian byte order, so gpui uploads them without a channel swap). +//! Dropping the [`CameraStream`] stops the thread, which releases the device +//! (camera LED off). +//! +//! There is no per-app consent prompt to drive here: desktop apps see the +//! camera unless the system-wide privacy toggle blocks them, which surfaces +//! as an activation error — reported as [`CaptureError::AccessDenied`]. + +#![expect( + unsafe_code, + reason = "Media Foundation COM (device activation + IMFSourceReader sample loop)" +)] +#![allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_sign_loss, + reason = "pixel dimensions and strides are bounded and copied verbatim" +)] + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use windows::Win32::Media::MediaFoundation::{ + IMFActivate, IMFMediaSource, IMFSourceReader, MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE, + MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID, + MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK, MF_MT_DEFAULT_STRIDE, + MF_MT_FRAME_SIZE, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE, MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, + MF_SOURCE_READER_FIRST_VIDEO_STREAM, MF_VERSION, MFCreateAttributes, MFCreateMediaType, + MFCreateSourceReaderFromMediaSource, MFEnumDeviceSources, MFMediaType_Video, MFSTARTUP_LITE, + MFStartup, MFVideoFormat_RGB32, +}; +use windows::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx, CoTaskMemFree}; + +pub use crate::capture_types::{CaptureError, Frame}; + +/// The preview's target frame width: matches the macOS backend's 720p preset — +/// Retina-sharp in the 480pt preview box without 1080p copy/upload cost. The +/// native format closest to this width wins. +const TARGET_WIDTH: u32 = 1280; + +/// How long [`start_stream`] waits for the reader thread to finish setup. +const SETUP_TIMEOUT: Duration = Duration::from_secs(5); + +/// The latest decoded frame plus its generation counter, shared between the +/// reader thread and the polling preview. +struct Shared { + latest: Mutex>>, + generation: AtomicU64, + stop: AtomicBool, +} + +/// A live preview stream. Holds the reader thread; [`CameraStream::take_frame`] +/// hands out the most recent frame each time it's polled. Dropping it stops +/// the camera. +pub struct CameraStream { + shared: Arc, + reader: Option>, +} + +impl CameraStream { + /// The most recently delivered frame, or `None` before the first arrives. + #[must_use] + pub fn latest_frame(&self) -> Option> { + self.shared.latest.lock().ok().and_then(|slot| slot.clone()) + } + + /// Take the most recent frame out of the slot (the next delivered frame + /// refills it). A sole consumer that unwraps the [`Arc`] gets the pixel + /// buffer without copying it. + #[must_use] + pub fn take_frame(&self) -> Option> { + self.shared + .latest + .lock() + .ok() + .and_then(|mut slot| slot.take()) + } + + /// A counter that increments on every delivered frame, so the preview can + /// skip rebuilding its texture when no new frame has arrived. + #[must_use] + pub fn frame_generation(&self) -> u64 { + self.shared.generation.load(Ordering::Relaxed) + } +} + +impl Drop for CameraStream { + fn drop(&mut self) { + self.shared.stop.store(true, Ordering::Relaxed); + // The reader wakes from its blocking ReadSample within a frame + // interval, sees the flag, and releases the device on its way out. + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +/// Start a live capture stream on the camera with `unique_id`. +/// +/// # Errors +/// [`CaptureError::NotFound`] for an unknown id, [`CaptureError::AccessDenied`] +/// when the system privacy toggle blocks cameras, or [`CaptureError::Setup`] +/// on Media Foundation errors. +pub fn start_stream(unique_id: &str) -> Result { + let shared = Arc::new(Shared { + latest: Mutex::new(None), + generation: AtomicU64::new(0), + stop: AtomicBool::new(false), + }); + let (setup_tx, setup_rx) = mpsc::channel(); + let thread_shared = Arc::clone(&shared); + let id = unique_id.to_string(); + let reader = std::thread::Builder::new() + .name("openlogi-camera-reader".into()) + .spawn(move || reader_thread(&id, &thread_shared, &setup_tx)) + .map_err(|e| CaptureError::Setup(e.to_string()))?; + + match setup_rx.recv_timeout(SETUP_TIMEOUT) { + Ok(Ok(())) => Ok(CameraStream { + shared, + reader: Some(reader), + }), + Ok(Err(e)) => { + let _ = reader.join(); + Err(e) + } + Err(_) => { + shared.stop.store(true, Ordering::Relaxed); + Err(CaptureError::Timeout) + } + } +} + +/// Capture a single [`Frame`] from the camera with `unique_id`. +/// +/// # Errors +/// As [`start_stream`], plus [`CaptureError::Timeout`] when no frame arrives. +pub fn capture_frame(unique_id: &str, timeout: Duration) -> Result { + let stream = start_stream(unique_id)?; + let deadline = Instant::now() + timeout; + loop { + if let Some(frame) = stream.take_frame() { + return Ok(Arc::unwrap_or_clone(frame)); + } + if Instant::now() >= deadline { + return Err(CaptureError::Timeout); + } + std::thread::sleep(Duration::from_millis(30)); + } +} + +/// Desktop apps are governed only by the system-wide privacy toggle, which +/// can't be queried up front — report usable and let activation surface a +/// denial. +#[must_use] +pub fn camera_access_granted() -> bool { + true +} + +/// Windows has no per-app camera consent for desktop apps. +#[must_use] +pub fn camera_authorization() -> crate::CameraAuthorization { + crate::CameraAuthorization::Granted +} + +/// The reader thread: builds the Media Foundation graph, reports the outcome +/// through `setup`, then pulls and decodes samples until told to stop. +fn reader_thread(unique_id: &str, shared: &Shared, setup: &mpsc::Sender>) { + // SAFETY: COM + MF init on this thread; every interface is released by the + // `windows` wrappers when the thread's locals drop. + let reader = unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + if let Err(e) = MFStartup(MF_VERSION, MFSTARTUP_LITE) { + let _ = setup.send(Err(CaptureError::Setup(e.to_string()))); + return; + } + match open_reader(unique_id) { + Ok(opened) => opened, + Err(e) => { + let _ = setup.send(Err(e)); + return; + } + } + }; + let (reader, stride_hint) = reader; + let _ = setup.send(Ok(())); + + while !shared.stop.load(Ordering::Relaxed) { + // SAFETY: synchronous ReadSample with documented out-params; the + // sample and its buffer are released when the wrappers drop. + unsafe { + let (mut flags, mut sample) = (0u32, None); + if reader + .ReadSample( + MF_SOURCE_READER_FIRST_VIDEO_STREAM.0 as u32, + 0, + None, + Some(&raw mut flags), + None, + Some(&raw mut sample), + ) + .is_err() + { + break; + } + let Some(sample) = sample else { continue }; + let Ok(buffer) = sample.ConvertToContiguousBuffer() else { + continue; + }; + let (mut data, mut len) = (std::ptr::null_mut(), 0u32); + if buffer + .Lock(&raw mut data, None, Some(&raw mut len)) + .is_err() + { + continue; + } + store_frame(shared, data, len as usize, stride_hint); + let _ = buffer.Unlock(); + } + } +} + +/// Frame geometry negotiated at setup: dimensions plus the RGB32 stride (a +/// negative stride means the rows arrive bottom-up and must be flipped). +#[derive(Clone, Copy)] +struct StrideHint { + width: u32, + height: u32, + stride: i32, +} + +/// Build the source reader for `unique_id`: activate the matching device, +/// pick the native format closest to [`TARGET_WIDTH`], and negotiate RGB32 +/// output (Media Foundation inserts the decoder/converter). +unsafe fn open_reader(unique_id: &str) -> Result<(IMFSourceReader, StrideHint), CaptureError> { + unsafe { + let source = activate_source(unique_id)?; + + let mut reader_attrs = None; + MFCreateAttributes(&raw mut reader_attrs, 1).map_err(setup_err)?; + let reader_attrs = reader_attrs.ok_or_else(|| setup_err("MFCreateAttributes"))?; + reader_attrs + .SetUINT32(&MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, 1) + .map_err(setup_err)?; + let reader = MFCreateSourceReaderFromMediaSource(&source, &reader_attrs) + .map_err(|e| access_or_setup(&e))?; + + // Prefer the native type closest to the preview's target width, so a + // 4K-capable camera doesn't stream (and we don't convert) 8x the + // pixels the preview can show. + let stream = MF_SOURCE_READER_FIRST_VIDEO_STREAM.0 as u32; + let mut best: Option<(u32, u64)> = None; + let mut index = 0u32; + while let Ok(native) = reader.GetNativeMediaType(stream, index) { + if let Ok(size) = native.GetUINT64(&MF_MT_FRAME_SIZE) { + let width = (size >> 32) as u32; + let score = width.abs_diff(TARGET_WIDTH); + if best.is_none_or(|(s, _)| score < s) { + best = Some((score, size)); + } + } + index += 1; + } + + let output = MFCreateMediaType().map_err(setup_err)?; + output + .SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video) + .map_err(setup_err)?; + output + .SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_RGB32) + .map_err(setup_err)?; + if let Some((_, size)) = best { + output + .SetUINT64(&MF_MT_FRAME_SIZE, size) + .map_err(setup_err)?; + } + reader + .SetCurrentMediaType(stream, None, &output) + .map_err(setup_err)?; + + // Read the negotiated geometry back — the converter may have kept the + // native size, and the stride tells us whether rows arrive bottom-up. + let current = reader.GetCurrentMediaType(stream).map_err(setup_err)?; + let size = current.GetUINT64(&MF_MT_FRAME_SIZE).map_err(setup_err)?; + let width = (size >> 32) as u32; + let height = (size & 0xFFFF_FFFF) as u32; + let stride = current + .GetUINT32(&MF_MT_DEFAULT_STRIDE) + .map_or(width as i32 * 4, |s| s as i32); + Ok(( + reader, + StrideHint { + width, + height, + stride, + }, + )) + } +} + +/// Activate the video-capture device whose Media Foundation symbolic link +/// matches `unique_id` (the DirectShow device path — the same device-interface +/// string, compared case-insensitively). +unsafe fn activate_source(unique_id: &str) -> Result { + unsafe { + let mut enum_attrs = None; + MFCreateAttributes(&raw mut enum_attrs, 1).map_err(setup_err)?; + let enum_attrs = enum_attrs.ok_or_else(|| setup_err("MFCreateAttributes"))?; + enum_attrs + .SetGUID( + &MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE, + &MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID, + ) + .map_err(setup_err)?; + + let (mut devices, mut count) = (std::ptr::null_mut::>(), 0u32); + MFEnumDeviceSources(&enum_attrs, &raw mut devices, &raw mut count).map_err(setup_err)?; + let list = std::slice::from_raw_parts(devices, count as usize); + let mut chosen = None; + for activate in list.iter().flatten() { + let (mut link, mut len) = (windows::core::PWSTR::null(), 0u32); + if activate + .GetAllocatedString( + &MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK, + &raw mut link, + &raw mut len, + ) + .is_err() + { + continue; + } + let matches = link + .to_string() + .is_ok_and(|s| s.eq_ignore_ascii_case(unique_id)); + CoTaskMemFree(Some(link.as_ptr().cast())); + if matches { + chosen = Some(activate.clone()); + break; + } + } + let result = match chosen { + Some(activate) => activate + .ActivateObject::() + .map_err(|e| access_or_setup(&e)), + None => Err(CaptureError::NotFound), + }; + CoTaskMemFree(Some(devices.cast())); + result + } +} + +/// Copy one locked RGB32 sample into a tightly-packed BGRA [`Frame`] in the +/// shared slot, flipping bottom-up rows when the stride is negative. +fn store_frame(shared: &Shared, data: *mut u8, len: usize, hint: StrideHint) { + let (width, height) = (hint.width as usize, hint.height as usize); + let row_bytes = width * 4; + let stride = hint.stride.unsigned_abs() as usize; + if width == 0 || height == 0 || data.is_null() || stride * (height - 1) + row_bytes > len { + return; + } + let mut bgra = vec![0u8; row_bytes * height]; + for y in 0..height { + // A negative stride means the buffer's first row is the bottom line. + let src_row = if hint.stride < 0 { height - 1 - y } else { y }; + // SAFETY: both row offsets are bounds-checked against `len` above. + unsafe { + std::ptr::copy_nonoverlapping( + data.add(src_row * stride), + bgra.as_mut_ptr().add(y * row_bytes), + row_bytes, + ); + } + } + if let Ok(mut slot) = shared.latest.lock() { + *slot = Some(Arc::new(Frame { + width: hint.width, + height: hint.height, + bgra, + })); + shared.generation.fetch_add(1, Ordering::Relaxed); + } +} + +fn setup_err(e: impl std::fmt::Display) -> CaptureError { + CaptureError::Setup(e.to_string()) +} + +/// Map an activation failure to AccessDenied when the system privacy toggle +/// is the cause (E_ACCESSDENIED), Setup otherwise. +fn access_or_setup(e: &windows::core::Error) -> CaptureError { + const E_ACCESSDENIED: windows::core::HRESULT = windows::core::HRESULT(0x8007_0005_u32 as i32); + if e.code() == E_ACCESSDENIED { + CaptureError::AccessDenied + } else { + CaptureError::Setup(e.to_string()) + } +} diff --git a/crates/openlogi-camera/src/controls.rs b/crates/openlogi-camera/src/controls.rs new file mode 100644 index 00000000..cfbf0063 --- /dev/null +++ b/crates/openlogi-camera/src/controls.rs @@ -0,0 +1,130 @@ +//! Platform-independent control vocabulary shared by every UVC backend +//! (IOKit on macOS, DirectShow on Windows, stubs elsewhere). + +/// One adjustable camera control, mapped to a UVC selector by each backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CameraControl { + Zoom, + Focus, + Exposure, + Brightness, + Contrast, + Saturation, + Sharpness, + WhiteBalance, + Tint, +} + +impl CameraControl { + /// Every control, in the order the UI lists them (lens first, then image). + pub const ALL: [Self; 9] = [ + Self::Zoom, + Self::Focus, + Self::Exposure, + Self::Brightness, + Self::Contrast, + Self::Saturation, + Self::Sharpness, + Self::WhiteBalance, + Self::Tint, + ]; + + /// Stable snake_case identifier used for config persistence and the CLI. + #[must_use] + pub fn name(self) -> &'static str { + match self { + Self::Zoom => "zoom", + Self::Focus => "focus", + Self::Exposure => "exposure", + Self::Brightness => "brightness", + Self::Contrast => "contrast", + Self::Saturation => "saturation", + Self::Sharpness => "sharpness", + Self::WhiteBalance => "white_balance", + Self::Tint => "tint", + } + } + + /// The auto-mode toggle that gates this control, if the device has one. + #[must_use] + pub fn auto_toggle(self) -> Option { + match self { + Self::Focus => Some(AutoToggle::Focus), + Self::Exposure => Some(AutoToggle::Exposure), + Self::WhiteBalance => Some(AutoToggle::WhiteBalance), + _ => None, + } + } +} + +/// An auto-mode toggle paired with a manual control (focus / exposure / white +/// balance). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutoToggle { + Focus, + Exposure, + WhiteBalance, +} + +impl AutoToggle { + /// Every toggle, matching [`CameraControl::auto_toggle`] pairs. + pub const ALL: [Self; 3] = [Self::Focus, Self::Exposure, Self::WhiteBalance]; + + /// Stable snake_case identifier used for config persistence and the CLI. + #[must_use] + pub fn name(self) -> &'static str { + match self { + Self::Focus => "focus_auto", + Self::Exposure => "exposure_auto", + Self::WhiteBalance => "white_balance_auto", + } + } +} + +/// One auto toggle's live and default state, read from the device. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AutoState { + pub current: bool, + pub default: bool, +} + +/// Everything the controls UI needs, read in a single device-open: each +/// supported control's range and each supported auto toggle's state. +#[derive(Debug, Clone, Default)] +pub struct CameraState { + pub controls: Vec<(CameraControl, ControlRange)>, + pub autos: Vec<(AutoToggle, AutoState)>, +} + +/// The device's reported range and current value for a control. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ControlRange { + pub min: i32, + pub max: i32, + pub default: i32, + pub current: i32, +} + +/// Why a UVC control operation failed. +#[derive(Debug, Clone)] +pub enum ControlError { + /// No matching camera device (or it exposes no controllable unit). + NotFound, + /// The camera rejected or didn't support the control — or the platform + /// has no UVC control backend at all. + Unsupported, + /// A platform API call failed (open, bind, or the control transfer). + Io(String), +} + +impl std::fmt::Display for ControlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound => write!(f, "no matching UVC device"), + Self::Unsupported => write!(f, "camera does not support that control"), + Self::Io(s) => write!(f, "platform error: {s}"), + } + } +} + +impl std::error::Error for ControlError {} diff --git a/crates/openlogi-camera/src/lib.rs b/crates/openlogi-camera/src/lib.rs new file mode 100644 index 00000000..80147cf1 --- /dev/null +++ b/crates/openlogi-camera/src/lib.rs @@ -0,0 +1,331 @@ +//! Generic discovery of Logitech USB Video Class (UVC) webcams. +//! +//! Mice and keyboards speak Logitech's proprietary HID++ (over a Bolt/Unifying +//! receiver or directly) — see the `openlogi-hid` crate. Webcams don't: every +//! Logitech camera (StreamCam, Brio, C920, C922, C270, C930e, …) is a standard +//! UVC device and enumerates the same way. So detection keys off the USB vendor +//! id (`0x046d`) rather than any per-model quirk — plug in *any* Logitech +//! camera and it's recognised, with no model table to maintain. +//! +//! macOS has the full backend (AVFoundation capture + IOKit UVC controls); +//! Windows matches it with Media Foundation capture and DirectShow controls; +//! other platforms return an empty list. + +use serde::Serialize; + +mod controls; +pub use controls::{AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange}; + +mod capture_types; +pub use capture_types::{CaptureError, Frame}; + +#[cfg(target_os = "macos")] +mod macos; + +#[cfg(target_os = "macos")] +mod capture; +#[cfg(target_os = "macos")] +pub use capture::{ + CameraStream, camera_access_granted, camera_authorization, capture_frame, start_stream, +}; + +#[cfg(target_os = "windows")] +mod capture_windows; +#[cfg(target_os = "windows")] +pub use capture_windows::{ + CameraStream, camera_access_granted, camera_authorization, capture_frame, start_stream, +}; + +#[cfg(target_os = "macos")] +mod uvc; +#[cfg(target_os = "macos")] +pub use uvc::{ + apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control, +}; + +#[cfg(target_os = "windows")] +mod uvc_windows; +#[cfg(target_os = "windows")] +pub use uvc_windows::{ + apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control, +}; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +mod capture { + //! Stub capture backend for platforms without one. + use std::sync::Arc; + use std::time::Duration; + + use crate::capture_types::{CaptureError, Frame}; + + /// Stub: returns [`CaptureError::Unsupported`] on this platform. + pub fn capture_frame(_unique_id: &str, _timeout: Duration) -> Result { + Err(CaptureError::Unsupported) + } + + /// Stub live stream (never yields a frame on this platform). + pub struct CameraStream; + + impl CameraStream { + #[must_use] + pub fn latest_frame(&self) -> Option> { + None + } + + #[must_use] + pub fn take_frame(&self) -> Option> { + None + } + + #[must_use] + pub fn frame_generation(&self) -> u64 { + 0 + } + } + + /// Stub: returns [`CaptureError::Unsupported`] on this platform. + pub fn start_stream(_unique_id: &str) -> Result { + Err(CaptureError::Unsupported) + } + + /// Stub: camera access is never granted on this platform. + #[must_use] + pub fn camera_access_granted() -> bool { + false + } + + /// Stub: camera permission is always undetermined on this platform. + #[must_use] + pub fn camera_authorization() -> crate::CameraAuthorization { + crate::CameraAuthorization::Undetermined + } +} +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub use capture::{ + CameraStream, camera_access_granted, camera_authorization, capture_frame, start_stream, +}; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +mod uvc { + //! Stub UVC control backend for platforms without one. + use crate::controls::{AutoToggle, CameraControl, CameraState, ControlError, ControlRange}; + + /// Stub: no UVC backend on this platform. + pub fn control_range(_id: &str, _c: CameraControl) -> Result { + Err(ControlError::Unsupported) + } + + /// Stub: no UVC backend on this platform. + pub fn control_ranges(_id: &str) -> Result, ControlError> { + Ok(Vec::new()) + } + + /// Stub: no UVC backend on this platform. + pub fn read_camera_state(_id: &str) -> Result { + Ok(CameraState::default()) + } + + /// Stub: no UVC backend on this platform. + pub fn set_control(_id: &str, _c: CameraControl, _value: i32) -> Result<(), ControlError> { + Err(ControlError::Unsupported) + } + + /// Stub: no UVC backend on this platform. + pub fn set_auto(_id: &str, _t: AutoToggle, _on: bool) -> Result<(), ControlError> { + Err(ControlError::Unsupported) + } + + /// Stub: no UVC backend on this platform. + pub fn apply_settings( + _id: &str, + _autos: &[(AutoToggle, bool)], + _values: &[(CameraControl, i32)], + ) -> Result<(), ControlError> { + Err(ControlError::Unsupported) + } +} +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub use uvc::{ + apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control, +}; + +/// Logitech's USB vendor id. Reported in decimal (`1133`) inside an +/// `AVCaptureDevice` modelID, and in hex (`046d`) most everywhere else. +pub const LOGITECH_VID: u16 = 0x046d; + +/// Tri-state Camera permission, mirroring macOS `AVAuthorizationStatus`. Off +/// macOS there is no consent model, so the backend reports +/// [`CameraAuthorization::Undetermined`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CameraAuthorization { + /// The process may open cameras. + Granted, + /// The user denied access, or the system restricts it. + Denied, + /// Not yet requested — opening a camera will prompt. + Undetermined, +} + +/// A connected USB Video Class camera. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Camera { + /// Human-readable name, e.g. `"Logitech StreamCam"`. + pub name: String, + /// Stable per-device identifier from the OS capture layer. Keys the device + /// in the UI so two identical cameras stay distinct. + pub unique_id: String, + /// USB vendor id (`0x046d` for Logitech). + pub vendor_id: u16, + /// USB product id (e.g. `0x0893` for the StreamCam). + pub product_id: u16, + /// Largest supported frame size `(width, height)`, when the OS reports the + /// device's formats. Read from metadata only — no capture, no permission. + pub max_resolution: Option<(u32, u32)>, + /// Highest supported frame rate (fps) across all formats, when known. + pub max_fps: Option, +} + +/// Whether this platform has a live-capture backend (preview + snapshot). +/// Enumeration and UVC controls can be supported without it. +#[must_use] +pub const fn capture_supported() -> bool { + cfg!(any(target_os = "macos", target_os = "windows")) +} + +/// Serializes UVC device seizes against enumeration within this process. +/// `USBDeviceOpenSeize` briefly detaches the camera's kernel driver, and an +/// enumeration racing that window sees no camera at all — which read as the +/// camera "disappearing" from the device list mid-slider-drag once +/// enumeration moved off the UI thread. Control paths hold this for the +/// seize's lifetime; enumeration takes it for the duration of the scan. +#[cfg(target_os = "macos")] +pub(crate) static USB_QUIESCE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Enumerate every connected **Logitech** UVC camera. +/// +/// Non-Logitech cameras (the built-in FaceTime camera, virtual cameras, other +/// vendors' webcams) are filtered out. Returns an empty list on platforms with +/// no capture backend, or when no Logitech camera is attached. +#[must_use] +pub fn enumerate_cameras() -> Vec { + enumerate_all() + .into_iter() + .filter(|camera| camera.vendor_id == LOGITECH_VID) + .collect() +} + +#[cfg(target_os = "macos")] +fn enumerate_all() -> Vec { + // Wait out any in-flight control seize so the scan can't land in the + // window where the kernel driver is detached (poisoning is impossible — + // holders never panic — but recover anyway rather than unwrap). + let _quiesce = USB_QUIESCE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + macos::enumerate() + .iter() + .filter_map(|raw| { + let mut camera = Camera::from_raw(&raw.name, &raw.unique_id, &raw.model_id)?; + if raw.max_width > 0 && raw.max_height > 0 { + camera.max_resolution = Some((raw.max_width, raw.max_height)); + } + if raw.max_fps > 0 { + camera.max_fps = Some(raw.max_fps); + } + Some(camera) + }) + .collect() +} + +#[cfg(target_os = "windows")] +fn enumerate_all() -> Vec { + uvc_windows::enumerate() +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn enumerate_all() -> Vec { + Vec::new() +} + +#[cfg(any(test, target_os = "macos"))] +impl Camera { + /// Build a [`Camera`] from an OS-reported `(name, unique_id, model_id)`. + /// + /// Returns `None` when `model_id` carries no USB vendor/product id — i.e. + /// it isn't a real USB camera (the macOS FaceTime camera's modelID is just + /// `"FaceTime HD Camera"`), so it can't be attributed to a vendor and is + /// dropped before the Logitech filter even runs. Format fields start `None`; + /// the platform backend fills them in. + fn from_raw(name: &str, unique_id: &str, model_id: &str) -> Option { + let (vendor_id, product_id) = parse_vid_pid(model_id)?; + Some(Self { + name: name.to_string(), + unique_id: unique_id.to_string(), + vendor_id, + product_id, + max_resolution: None, + max_fps: None, + }) + } +} + +/// Pull the USB vendor/product id out of an `AVCaptureDevice` modelID such as +/// `"UVC Camera VendorID_1133 ProductID_2195"`. Both ids are **decimal** in +/// that string (1133 == 0x046d, 2195 == 0x0893). `None` if either marker is +/// absent. +#[cfg(any(test, target_os = "macos"))] +fn parse_vid_pid(model_id: &str) -> Option<(u16, u16)> { + let vendor_id = parse_marker(model_id, "VendorID_")?; + let product_id = parse_marker(model_id, "ProductID_")?; + Some((vendor_id, product_id)) +} + +/// Read the decimal number immediately following `marker` in `haystack`. +#[cfg(any(test, target_os = "macos"))] +fn parse_marker(haystack: &str, marker: &str) -> Option { + let rest = haystack.split(marker).nth(1)?; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + digits.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_logitech_streamcam_model_id() { + assert_eq!( + parse_vid_pid("UVC Camera VendorID_1133 ProductID_2195"), + Some((0x046d, 0x0893)) + ); + } + + #[test] + fn rejects_model_id_without_usb_ids() { + assert_eq!(parse_vid_pid("FaceTime HD Camera"), None); + assert_eq!(parse_vid_pid("VendorID_1133 only"), None); + } + + #[test] + fn from_raw_keeps_usb_cameras_and_drops_the_rest() { + assert_eq!( + Camera::from_raw( + "Logitech StreamCam", + "0x1123000046d0893", + "UVC Camera VendorID_1133 ProductID_2195", + ), + Some(Camera { + name: "Logitech StreamCam".to_string(), + unique_id: "0x1123000046d0893".to_string(), + vendor_id: LOGITECH_VID, + product_id: 0x0893, + max_resolution: None, + max_fps: None, + }) + ); + assert_eq!( + Camera::from_raw("FaceTime HD Camera", "uuid", "FaceTime HD Camera"), + None + ); + } +} diff --git a/crates/openlogi-camera/src/macos.rs b/crates/openlogi-camera/src/macos.rs new file mode 100644 index 00000000..a23ffc44 --- /dev/null +++ b/crates/openlogi-camera/src/macos.rs @@ -0,0 +1,197 @@ +//! AVFoundation-backed camera enumeration. +//! +//! `+[AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo]` returns every +//! video-capable capture device macOS knows about; for each we read the same +//! `localizedName` / `uniqueID` / `modelID` strings `system_profiler +//! SPCameraDataType` reports, plus the device's supported formats (resolution + +//! frame rate). Vendor parsing + the Logitech filter live in the +//! platform-independent parent module. +//! +//! All of this is metadata — no capture session is opened, so no Camera +//! permission is required. + +#![expect( + unsafe_code, + reason = "AVFoundation (AVCaptureDevice) camera-enumeration FFI" +)] + +use std::ffi::CStr; +use std::os::raw::c_char; + +use objc::runtime::{Class, Object}; +use objc::{msg_send, sel, sel_impl}; + +/// Raw camera metadata as reported by `AVCaptureDevice`, before vendor parsing. +pub(crate) struct RawCamera { + pub name: String, + pub unique_id: String, + pub model_id: String, + /// Largest supported frame size, `(0, 0)` if none was reported. + pub max_width: u32, + pub max_height: u32, + /// Highest supported frame rate (fps) at any format, `0` if none. + pub max_fps: u32, +} + +// `AVMediaTypeVideo` is an `NSString` constant exported by AVFoundation; the +// framework must be linked for it and the `AVCaptureDevice` class to resolve. +#[link(name = "AVFoundation", kind = "framework")] +unsafe extern "C" { + static AVMediaTypeVideo: *const Object; +} + +#[repr(C)] +struct CMVideoDimensions { + width: i32, + height: i32, +} + +#[link(name = "CoreMedia", kind = "framework")] +unsafe extern "C" { + fn CMVideoFormatDescriptionGetDimensions(desc: *mut Object) -> CMVideoDimensions; +} + +/// Enumerate every video `AVCaptureDevice`, as raw metadata. The Logitech +/// filter is applied by the caller in `lib.rs`. +pub(crate) fn enumerate() -> Vec { + let (Some(pool_cls), Some(device_cls)) = ( + Class::get("NSAutoreleasePool"), + Class::get("AVCaptureDevice"), + ) else { + return Vec::new(); + }; + + // SAFETY: `NSAutoreleasePool` / `AVCaptureDevice` exist once AVFoundation is + // linked. Every message uses a documented selector and matching types; the + // returned array + its devices are autoreleased, so an explicit pool brackets + // the work and every string is copied into an owned `String` before it drains. + unsafe { + let pool: *mut Object = msg_send![pool_cls, new]; + let devices: *mut Object = msg_send![device_cls, devicesWithMediaType: AVMediaTypeVideo]; + + let mut out = Vec::new(); + if !devices.is_null() { + let count: usize = msg_send![devices, count]; + out.reserve(count); + for i in 0..count { + let device: *mut Object = msg_send![devices, objectAtIndex: i]; + if device.is_null() { + continue; + } + let name_obj: *mut Object = msg_send![device, localizedName]; + let uid_obj: *mut Object = msg_send![device, uniqueID]; + let model_obj: *mut Object = msg_send![device, modelID]; + if let (Some(name), Some(unique_id), Some(model_id)) = + (nsstring(name_obj), nsstring(uid_obj), nsstring(model_obj)) + { + let (max_width, max_height, max_fps) = best_format(device); + out.push(RawCamera { + name, + unique_id, + model_id, + max_width, + max_height, + max_fps, + }); + } + } + } + + let _: () = msg_send![pool, drain]; + out + } +} + +/// Largest `(width, height, max_fps)` among the device's supported formats, or +/// `(0, 0, 0)` when none are reported. Reads format metadata only — no capture +/// session, so no Camera permission is needed. +fn best_format(device: *mut Object) -> (u32, u32, u32) { + // SAFETY: `device` is a valid `AVCaptureDevice`; `formats` is an autoreleased + // `NSArray` of `AVCaptureDeviceFormat`, each exposing a `CMFormatDescription` + // and frame-rate ranges via documented selectors. + unsafe { + let formats: *mut Object = msg_send![device, formats]; + if formats.is_null() { + return (0, 0, 0); + } + let count: usize = msg_send![formats, count]; + let mut best = (0u32, 0u32, 0u32); + for i in 0..count { + let format: *mut Object = msg_send![formats, objectAtIndex: i]; + if format.is_null() { + continue; + } + let desc: *mut Object = msg_send![format, formatDescription]; + if desc.is_null() { + continue; + } + let dims = CMVideoFormatDescriptionGetDimensions(desc); + let w = u32::try_from(dims.width).unwrap_or(0); + let h = u32::try_from(dims.height).unwrap_or(0); + let fps = max_frame_rate(format); + let area = u64::from(w) * u64::from(h); + let best_area = u64::from(best.0) * u64::from(best.1); + if area > best_area || (w == best.0 && h == best.1 && fps > best.2) { + best = (w, h, fps); + } + } + best + } +} + +/// Highest `maxFrameRate` across a format's `videoSupportedFrameRateRanges`. +fn max_frame_rate(format: *mut Object) -> u32 { + // SAFETY: documented selectors on a valid `AVCaptureDeviceFormat` / + // `AVFrameRateRange`; `maxFrameRate` returns a `double`. + unsafe { + let ranges: *mut Object = msg_send![format, videoSupportedFrameRateRanges]; + if ranges.is_null() { + return 0; + } + let count: usize = msg_send![ranges, count]; + let mut max = 0.0f64; + for i in 0..count { + let range: *mut Object = msg_send![ranges, objectAtIndex: i]; + if range.is_null() { + continue; + } + let r: f64 = msg_send![range, maxFrameRate]; + if r > max { + max = r; + } + } + round_fps(max) + } +} + +/// Round a frame rate to the nearest whole fps (so 59.94 reads as 60). +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "fps is rounded, finite, and clamped to a small non-negative range" +)] +fn round_fps(rate: f64) -> u32 { + if rate.is_finite() && rate > 0.0 { + rate.round() as u32 + } else { + 0 + } +} + +/// Copy an `NSString` into an owned Rust `String`. `None` for a null pointer or +/// non-UTF-8 contents. +fn nsstring(s: *mut Object) -> Option { + if s.is_null() { + return None; + } + // SAFETY: `s` is a non-null `NSString`; `UTF8String` yields a NUL-terminated + // C string valid for the lifetime of the (autoreleased) string, which we + // copy out of immediately. + unsafe { + let utf8: *const c_char = msg_send![s, UTF8String]; + if utf8.is_null() { + return None; + } + Some(CStr::from_ptr(utf8).to_string_lossy().into_owned()) + } +} diff --git a/crates/openlogi-camera/src/uvc.rs b/crates/openlogi-camera/src/uvc.rs new file mode 100644 index 00000000..4371fb99 --- /dev/null +++ b/crates/openlogi-camera/src/uvc.rs @@ -0,0 +1,774 @@ +//! Device-level UVC Processing-Unit controls (brightness/contrast/…) over IOKit. +//! +//! These are *not* AVFoundation settings: they're USB Video Class control +//! transfers to the camera's Processing Unit, so a change lands in the camera's +//! own registers and is seen by every app — Google Meet, Zoom, OBS — not just +//! our preview. This is the same mechanism `uvc-util` and "Webcam Settings" use, +//! and it works while the camera is streaming because the request rides the +//! default control endpoint, which the streaming driver does not own. +//! +//! Flow: match the USB device by vendor/product id (disambiguating on the +//! AVFoundation `unique_id`'s location id when several identical cameras are +//! attached), open it via the IOKit `IOUSBDeviceInterface` plug-in, parse the +//! configuration descriptor for the VideoControl interface number and the +//! Processing-Unit id, then issue UVC `GET_*`/`SET_CUR` requests. + +#![expect( + unsafe_code, + reason = "IOKit USB (IOUSBDeviceInterface) control-transfer FFI for UVC Processing-Unit controls" +)] +#![allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_sign_loss, + reason = "UVC payloads are bounded 16-bit values copied verbatim" +)] + +use std::ffi::{CString, c_void}; +use std::ptr; + +/// Which UVC entity a control request addresses: the Camera Terminal (lens: +/// zoom/focus/exposure) or the Processing Unit (image: brightness/…). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Unit { + CameraTerminal, + Processing, +} + +pub use crate::controls::{ + AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange, +}; + +impl CameraControl { + fn unit(self) -> Unit { + match self { + Self::Zoom | Self::Focus | Self::Exposure => Unit::CameraTerminal, + _ => Unit::Processing, + } + } + + /// UVC control selector (Camera Terminal §A.9.4, Processing Unit §A.9.5). + #[allow( + clippy::match_same_arms, + reason = "Focus (CT) and Tint (PU) share 0x06 by coincidence — they address different units" + )] + fn selector(self) -> u16 { + match self { + Self::Zoom => 0x0B, // CT_ZOOM_ABSOLUTE_CONTROL + Self::Focus => 0x06, // CT_FOCUS_ABSOLUTE_CONTROL + Self::Exposure => 0x04, // CT_EXPOSURE_TIME_ABSOLUTE_CONTROL + Self::Brightness => 0x02, // PU_BRIGHTNESS_CONTROL + Self::Contrast => 0x03, // PU_CONTRAST_CONTROL + Self::Saturation => 0x07, // PU_SATURATION_CONTROL + Self::Sharpness => 0x08, // PU_SHARPNESS_CONTROL + Self::WhiteBalance => 0x0A, // PU_WHITE_BALANCE_TEMPERATURE_CONTROL + Self::Tint => 0x06, // PU_HUE_CONTROL + } + } + + /// Payload size in bytes (exposure time is a dwExposureTimeAbsolute u32). + fn len(self) -> usize { + match self { + Self::Exposure => 4, + _ => 2, + } + } + + /// Brightness and hue are signed controls; the rest are unsigned. + fn signed(self) -> bool { + matches!(self, Self::Brightness | Self::Tint) + } +} + +impl AutoToggle { + fn unit(self) -> Unit { + match self { + Self::Focus | Self::Exposure => Unit::CameraTerminal, + Self::WhiteBalance => Unit::Processing, + } + } + + fn selector(self) -> u16 { + match self { + Self::Focus => 0x08, // CT_FOCUS_AUTO_CONTROL + Self::Exposure => 0x02, // CT_AE_MODE_CONTROL + Self::WhiteBalance => 0x0B, // PU_WHITE_BALANCE_TEMPERATURE_AUTO_CONTROL + } + } +} + +// ── UVC constants ──────────────────────────────────────────────────────────── +const UVC_SET_CUR: u8 = 0x01; +const UVC_GET_CUR: u8 = 0x81; +const UVC_GET_MIN: u8 = 0x82; +const UVC_GET_MAX: u8 = 0x83; +const UVC_GET_DEF: u8 = 0x87; +// bmRequestType: class request to an interface recipient. Bit 7 = data direction. +const RT_GET: u8 = 0xA1; // device-to-host | class | interface +const RT_SET: u8 = 0x21; // host-to-device | class | interface + +const CC_VIDEO: u8 = 0x0E; +const SC_VIDEOCONTROL: u8 = 0x01; +const DESC_INTERFACE: u8 = 0x04; +const DESC_CS_INTERFACE: u8 = 0x24; +const VC_INPUT_TERMINAL: u8 = 0x02; +const VC_PROCESSING_UNIT: u8 = 0x05; +/// wTerminalType for a camera sensor input terminal (ITT_CAMERA). +const ITT_CAMERA: u16 = 0x0201; + +// UVC AE-mode bitmap bits (CT_AE_MODE_CONTROL): everything except fully +// manual counts as "auto" for the toggle. +const AE_MANUAL: u8 = 0x01; +/// Auto modes to try when enabling auto-exposure, most- to least-automatic +/// (full auto, aperture priority, shutter priority) — cameras support subsets. +const AE_AUTO_MODES: [u8; 3] = [0x02, 0x08, 0x04]; + +const KIO_RETURN_SUCCESS: i32 = 0; + +/// Hold the process-wide seize/enumeration lock — see [`crate::USB_QUIESCE`]. +fn quiesce() -> std::sync::MutexGuard<'static, ()> { + crate::USB_QUIESCE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Read a control's min/max/default/current straight from the device. +/// +/// # Errors +/// [`ControlError::NotFound`] when no USB device matches, [`ControlError::Io`] +/// on an IOKit failure, or [`ControlError::Unsupported`] if the camera NAKs the +/// request. +pub fn control_range( + unique_id: &str, + control: CameraControl, +) -> Result { + let _quiesce = quiesce(); + let dev = UsbDevice::open_for(unique_id)?; + let min = dev.get(control, UVC_GET_MIN)?; + let max = dev.get(control, UVC_GET_MAX)?; + let default = dev.get(control, UVC_GET_DEF)?; + let current = dev.get(control, UVC_GET_CUR).unwrap_or(default); + Ok(ControlRange { + min, + max, + default, + current, + }) +} + +/// Read every supported control in a single device-open (controls the camera +/// NAKs are skipped). Batching keeps the device-seize count down — important +/// while the camera is streaming. +/// +/// # Errors +/// [`ControlError::NotFound`] when no USB device matches. +pub fn control_ranges(unique_id: &str) -> Result, ControlError> { + Ok(read_camera_state(unique_id)?.controls) +} + +/// Read every supported control range *and* auto-toggle state in a single +/// device-open — what the GUI controls panel builds itself from. +/// +/// # Errors +/// [`ControlError::NotFound`] when no USB device matches. +pub fn read_camera_state(unique_id: &str) -> Result { + let _quiesce = quiesce(); + let dev = UsbDevice::open_for(unique_id)?; + let mut state = CameraState::default(); + for control in CameraControl::ALL { + if let (Ok(min), Ok(max), Ok(default)) = ( + dev.get(control, UVC_GET_MIN), + dev.get(control, UVC_GET_MAX), + dev.get(control, UVC_GET_DEF), + ) { + let current = dev.get(control, UVC_GET_CUR).unwrap_or(default); + state.controls.push(( + control, + ControlRange { + min, + max, + default, + current, + }, + )); + } + } + for toggle in AutoToggle::ALL { + if let (Ok(current), Ok(default)) = ( + dev.get_auto(toggle, UVC_GET_CUR), + dev.get_auto(toggle, UVC_GET_DEF), + ) { + state.autos.push((toggle, AutoState { current, default })); + } + } + Ok(state) +} + +/// Write a control's current value to the device. Persists in the camera's +/// registers, so other apps observe it too. +/// +/// # Errors +/// As [`control_range`]. +pub fn set_control( + unique_id: &str, + control: CameraControl, + value: i32, +) -> Result<(), ControlError> { + let _quiesce = quiesce(); + let dev = UsbDevice::open_for(unique_id)?; + dev.set(control, value) +} + +/// Switch an auto mode (focus / exposure / white balance) on or off. +/// +/// # Errors +/// As [`control_range`]. +pub fn set_auto(unique_id: &str, toggle: AutoToggle, on: bool) -> Result<(), ControlError> { + let _quiesce = quiesce(); + let dev = UsbDevice::open_for(unique_id)?; + dev.set_auto(toggle, on) +} + +/// Apply a batch of auto toggles and control values in a single device-open — +/// how profiles and saved-state reapplication write, so the seize count stays +/// at one no matter how many controls change. Autos land first so a manual +/// value isn't rejected by a still-armed auto mode. Every write is attempted +/// (one rejection doesn't abandon the rest), but any failure surfaces so +/// callers never persist or display a batch the hardware didn't take. +/// +/// # Errors +/// [`ControlError::NotFound`] when no USB device matches; otherwise the first +/// per-write error after attempting the whole batch. +pub fn apply_settings( + unique_id: &str, + autos: &[(AutoToggle, bool)], + values: &[(CameraControl, i32)], +) -> Result<(), ControlError> { + let _quiesce = quiesce(); + let dev = UsbDevice::open_for(unique_id)?; + let mut first_err = None; + for (toggle, on) in autos { + if let Err(e) = dev.set_auto(*toggle, *on) { + first_err.get_or_insert(e); + } + } + for (control, value) in values { + if let Err(e) = dev.set(*control, *value) { + first_err.get_or_insert(e); + } + } + first_err.map_or(Ok(()), Err) +} + +// ── AVFoundation unique-id → USB location id ───────────────────────────────── +// macOS UVC `uniqueID`s are `` — but the +// location comes out *unpadded* (a StreamCam on bus 0x01123000 yields +// `0x1123000046d0893`, 15 digits). So the location is everything **except** +// the trailing 8 vid+pid digits; taking a fixed leading 8 would swallow a +// nibble of the vid and shift the location. Only used to pick between two +// identical cameras; matching is primarily by vendor id. +fn location_hint(unique_id: &str) -> Option { + let hex = unique_id.strip_prefix("0x").unwrap_or(unique_id); + let location = hex.get(..hex.len().checked_sub(8)?)?; + if location.is_empty() { + return None; + } + u32::from_str_radix(location, 16).ok() +} + +// ── IOKit / CoreFoundation FFI ─────────────────────────────────────────────── +type IoReturn = i32; +type IoService = u32; +type IoIterator = u32; +type CfUuidRef = *const c_void; + +#[repr(C)] +#[derive(Clone, Copy)] +struct CfUuidBytes { + bytes: [u8; 16], +} + +#[repr(C)] +struct IoUsbDevRequest { + bm_request_type: u8, + b_request: u8, + w_value: u16, + w_index: u16, + w_length: u16, + p_data: *mut c_void, + w_len_done: u32, +} + +// IOCFPlugInInterface — we only need the IUnknown head (QueryInterface/Release). +#[repr(C)] +struct PlugInInterface { + _reserved: *mut c_void, + query_interface: extern "C" fn(*mut c_void, CfUuidBytes, *mut *mut c_void) -> i32, + add_ref: extern "C" fn(*mut c_void) -> u32, + release: extern "C" fn(*mut c_void) -> u32, +} + +// IOUSBDeviceInterface vtable (IOUSBLib.h). Slots we don't call are typed as +// opaque pointers so the offsets of the ones we *do* call stay correct. +#[repr(C)] +struct UsbDeviceInterface { + _reserved: *mut c_void, + query_interface: extern "C" fn(*mut c_void, CfUuidBytes, *mut *mut c_void) -> i32, + add_ref: extern "C" fn(*mut c_void) -> u32, + release: extern "C" fn(*mut c_void) -> u32, + create_device_async_event_source: *const c_void, + get_device_async_event_source: *const c_void, + create_device_async_port: *const c_void, + get_device_async_port: *const c_void, + usb_device_open: extern "C" fn(*mut c_void) -> IoReturn, + usb_device_close: extern "C" fn(*mut c_void) -> IoReturn, + get_device_class: *const c_void, + get_device_sub_class: *const c_void, + get_device_protocol: *const c_void, + get_device_vendor: extern "C" fn(*mut c_void, *mut u16) -> IoReturn, + get_device_product: extern "C" fn(*mut c_void, *mut u16) -> IoReturn, + get_device_release_number: *const c_void, + get_device_address: *const c_void, + get_device_bus_power_available: *const c_void, + get_device_speed: *const c_void, + get_number_of_configurations: extern "C" fn(*mut c_void, *mut u8) -> IoReturn, + get_location_id: extern "C" fn(*mut c_void, *mut u32) -> IoReturn, + get_configuration_descriptor_ptr: extern "C" fn(*mut c_void, u8, *mut *const u8) -> IoReturn, + get_configuration: *const c_void, + set_configuration: *const c_void, + get_bus_frame_number: *const c_void, + reset_device: *const c_void, + device_request: extern "C" fn(*mut c_void, *mut IoUsbDevRequest) -> IoReturn, + device_request_async: *const c_void, + create_interface_iterator: *const c_void, + // IOUSBDeviceInterface182 adds OpenSeize: open even while the kernel video + // driver holds the device for streaming, so controls work during a preview. + usb_device_open_seize: extern "C" fn(*mut c_void) -> IoReturn, + // remaining methods unused +} + +#[link(name = "IOKit", kind = "framework")] +unsafe extern "C" { + static kIOMainPortDefault: u32; + fn IOServiceMatching(name: *const i8) -> *mut c_void; + fn IOServiceGetMatchingServices( + main_port: u32, + matching: *mut c_void, + existing: *mut IoIterator, + ) -> IoReturn; + fn IOIteratorNext(iterator: IoIterator) -> IoService; + fn IOObjectRelease(object: u32) -> IoReturn; + fn IOCreatePlugInInterfaceForService( + service: IoService, + plugin_type: CfUuidRef, + interface_type: CfUuidRef, + plug_in: *mut *mut *mut PlugInInterface, + score: *mut i32, + ) -> IoReturn; + fn IODestroyPlugInInterface(interface: *mut *mut PlugInInterface) -> IoReturn; +} + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFUUIDCreateFromUUIDBytes(allocator: *const c_void, bytes: CfUuidBytes) -> CfUuidRef; + fn CFUUIDGetUUIDBytes(uuid: CfUuidRef) -> CfUuidBytes; + fn CFRelease(cf: *const c_void); +} + +// IOUSBLib UUIDs, as raw bytes (UUID order). +const KIO_USB_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [ + 0x9d, 0xc7, 0xb7, 0x80, 0x9e, 0xc0, 0x11, 0xd4, 0xa5, 0x4f, 0x00, 0x0a, 0x27, 0x05, 0x28, 0x61, +]; +const KIO_CF_PLUGIN_INTERFACE_ID: [u8; 16] = [ + 0xc2, 0x44, 0xe8, 0x58, 0x10, 0x9c, 0x11, 0xd4, 0x91, 0xd4, 0x00, 0x50, 0xe4, 0xc6, 0x42, 0x6f, +]; +// kIOUSBDeviceInterfaceID182 — the first version exposing USBDeviceOpenSeize. +const KIO_USB_DEVICE_INTERFACE_ID: [u8; 16] = [ + 0x15, 0x2f, 0xc4, 0x96, 0x48, 0x91, 0x11, 0xd5, 0x9d, 0x52, 0x00, 0x0a, 0x27, 0x80, 0x1e, 0x86, +]; + +/// An opened IOKit USB device interface, with its UVC topology resolved. Closes +/// and releases on drop. +struct UsbDevice { + dev: *mut *mut UsbDeviceInterface, + vc_interface: u8, + /// Processing-Unit id (image controls). + unit_id: u8, + /// Camera (input) Terminal id (lens controls); `None` when the descriptor + /// lists no camera terminal — lens controls then report `Unsupported`. + terminal_id: Option, +} + +impl UsbDevice { + /// Find and open the Logitech USB device backing `unique_id`, resolving its + /// VideoControl interface and Processing-Unit id. + fn open_for(unique_id: &str) -> Result { + let want_vid = crate::LOGITECH_VID; + // The pid is the trailing 4 hex of the uniqueID's id portion; we don't + // strictly need it for matching (we open every Logitech UVC device and + // pick the one whose location matches), but parse it as a fallback. + let want_location = location_hint(unique_id); + + // SAFETY: standard IOKit device enumeration; each retained object is + // released, and the matching dictionary is consumed by the call. + unsafe { + let class = CString::new("IOUSBDevice").map_err(|e| ControlError::Io(e.to_string()))?; + let matching = IOServiceMatching(class.as_ptr()); + if matching.is_null() { + return Err(ControlError::Io("IOServiceMatching".into())); + } + let mut iter: IoIterator = 0; + if IOServiceGetMatchingServices(kIOMainPortDefault, matching, &raw mut iter) + != KIO_RETURN_SUCCESS + { + return Err(ControlError::Io("IOServiceGetMatchingServices".into())); + } + + let mut chosen: Option = None; + loop { + let service = IOIteratorNext(iter); + if service == 0 { + break; + } + match Self::try_open(service, want_vid, want_location) { + Some(found) => { + // With a parseable location hint only that camera may + // be opened — falling back to "first Logitech camera" + // could write another webcam's registers when two are + // attached. Without a hint (an unparseable unique id), + // the first Logitech camera stays the best effort. + let exact = + want_location.is_some_and(|l| found.matched_location == Some(l)); + IOObjectRelease(service); + if exact { + if let Some(prev) = chosen.take() { + prev.into_device().close(); + } + chosen = Some(found); + break; + } else if want_location.is_none() && chosen.is_none() { + chosen = Some(found); + } else { + found.into_device().close(); + } + } + None => { + IOObjectRelease(service); + } + } + } + IOObjectRelease(iter); + + chosen + .map(Opened::into_device) + .ok_or(ControlError::NotFound) + } + } + + /// The entity id addressed for `unit`, or `Unsupported` when the camera's + /// descriptor lists no camera terminal. + fn entity(&self, unit: Unit) -> Result { + match unit { + Unit::Processing => Ok(self.unit_id), + Unit::CameraTerminal => self.terminal_id.ok_or(ControlError::Unsupported), + } + } + + /// Issue a UVC GET request (`req` = GET_MIN/MAX/DEF/CUR), returning the + /// control-sized little-endian value, sign-extended per the control. + fn get(&self, control: CameraControl, req: u8) -> Result { + let entity = self.entity(control.unit())?; + let mut buf = [0u8; 4]; + let len = control.len(); + self.transfer(RT_GET, req, control.selector(), entity, &mut buf[..len])?; + Ok(match (len, control.signed()) { + (4, _) => i32::try_from(u32::from_le_bytes(buf)).unwrap_or(i32::MAX), + (_, true) => i32::from(i16::from_le_bytes([buf[0], buf[1]])), + (_, false) => i32::from(u16::from_le_bytes([buf[0], buf[1]])), + }) + } + + /// Issue a UVC SET_CUR request with `value` truncated to the control's size. + fn set(&self, control: CameraControl, value: i32) -> Result<(), ControlError> { + let entity = self.entity(control.unit())?; + let mut buf = (value as u32).to_le_bytes(); + let len = control.len(); + self.transfer( + RT_SET, + UVC_SET_CUR, + control.selector(), + entity, + &mut buf[..len], + ) + } + + /// Read an auto toggle (`req` = GET_CUR/GET_DEF) as a boolean. For the + /// AE-mode bitmap anything but fully-manual counts as auto. + fn get_auto(&self, toggle: AutoToggle, req: u8) -> Result { + let entity = self.entity(toggle.unit())?; + let mut buf = [0u8; 1]; + self.transfer(RT_GET, req, toggle.selector(), entity, &mut buf)?; + Ok(match toggle { + AutoToggle::Exposure => buf[0] != AE_MANUAL, + _ => buf[0] != 0, + }) + } + + /// Switch an auto toggle. Enabling auto-exposure tries each AE mode the + /// camera might support, most-automatic first. + fn set_auto(&self, toggle: AutoToggle, on: bool) -> Result<(), ControlError> { + let entity = self.entity(toggle.unit())?; + let selector = toggle.selector(); + let candidates: &[u8] = match (toggle, on) { + (AutoToggle::Exposure, true) => &AE_AUTO_MODES, + (AutoToggle::Exposure, false) => &[AE_MANUAL], + (_, true) => &[1], + (_, false) => &[0], + }; + let mut last = ControlError::Unsupported; + for &mode in candidates { + match self.transfer(RT_SET, UVC_SET_CUR, selector, entity, &mut [mode]) { + Ok(()) => return Ok(()), + Err(e) => last = e, + } + } + Err(last) + } + + fn transfer( + &self, + request_type: u8, + request: u8, + selector: u16, + entity: u8, + data: &mut [u8], + ) -> Result<(), ControlError> { + let mut req = IoUsbDevRequest { + bm_request_type: request_type, + b_request: request, + w_value: selector << 8, + w_index: (u16::from(entity) << 8) | u16::from(self.vc_interface), + w_length: data.len() as u16, + p_data: data.as_mut_ptr().cast::(), + w_len_done: 0, + }; + // SAFETY: `self.dev` is a live IOUSBDeviceInterface**; DeviceRequest reads + // `req` and writes into the `data` buffer it points at. + let rc = unsafe { ((**self.dev).device_request)(self.dev.cast::(), &raw mut req) }; + if rc != KIO_RETURN_SUCCESS { + return Err(ControlError::Unsupported); + } + Ok(()) + } + + fn close(self) { + // Drop handles the teardown. + drop(self); + } +} + +impl Drop for UsbDevice { + fn drop(&mut self) { + // SAFETY: `self.dev` is a live interface we opened; close then release it. + unsafe { + let _ = ((**self.dev).usb_device_close)(self.dev.cast::()); + ((**self.dev).release)(self.dev.cast::()); + } + } +} + +/// A device that matched on vendor id, carrying the location id it reported so +/// the caller can prefer an exact-location match. +struct Opened { + device: UsbDevice, + matched_location: Option, +} + +impl Opened { + fn into_device(self) -> UsbDevice { + self.device + } +} + +impl UsbDevice { + /// Try to build an [`Opened`] from an `io_service_t`: query the device + /// interface, match the vendor id, open it, and resolve its UVC topology. + unsafe fn try_open( + service: IoService, + want_vid: u16, + _want_location: Option, + ) -> Option { + unsafe { + let user_client = make_uuid(&KIO_USB_DEVICE_USER_CLIENT_TYPE_ID); + let plugin_type = make_uuid(&KIO_CF_PLUGIN_INTERFACE_ID); + let mut plugin: *mut *mut PlugInInterface = ptr::null_mut(); + let mut score: i32 = 0; + let rc = IOCreatePlugInInterfaceForService( + service, + user_client, + plugin_type, + &raw mut plugin, + &raw mut score, + ); + CFRelease(user_client); + CFRelease(plugin_type); + if rc != KIO_RETURN_SUCCESS || plugin.is_null() { + return None; + } + + let dev_uuid_ref = make_uuid(&KIO_USB_DEVICE_INTERFACE_ID); + let dev_uuid = CFUUIDGetUUIDBytes(dev_uuid_ref); + CFRelease(dev_uuid_ref); + let mut dev_ptr: *mut c_void = ptr::null_mut(); + let qrc = + ((**plugin).query_interface)(plugin.cast::(), dev_uuid, &raw mut dev_ptr); + IODestroyPlugInInterface(plugin); + if qrc != 0 || dev_ptr.is_null() { + return None; + } + let dev = dev_ptr.cast::<*mut UsbDeviceInterface>(); + + let mut vid: u16 = 0; + ((**dev).get_device_vendor)(dev.cast::(), &raw mut vid); + if vid != want_vid { + ((**dev).release)(dev.cast::()); + return None; + } + + let mut location: u32 = 0; + let loc_ok = ((**dev).get_location_id)(dev.cast::(), &raw mut location) + == KIO_RETURN_SUCCESS; + + // Seize (not plain open) so a control transfer succeeds even while + // the camera is streaming in this or another app. Callers batch their + // reads/writes into one open to keep this churn low. + if ((**dev).usb_device_open_seize)(dev.cast::()) != KIO_RETURN_SUCCESS { + ((**dev).release)(dev.cast::()); + return None; + } + + let Some(topology) = video_control_topology(dev) else { + let _ = ((**dev).usb_device_close)(dev.cast::()); + ((**dev).release)(dev.cast::()); + return None; + }; + + Some(Opened { + device: UsbDevice { + dev, + vc_interface: topology.vc_interface, + unit_id: topology.processing_unit, + terminal_id: topology.camera_terminal, + }, + matched_location: loc_ok.then_some(location), + }) + } + } +} + +/// The VideoControl entities a control request can address, parsed from the +/// configuration descriptor. +struct VcTopology { + vc_interface: u8, + processing_unit: u8, + camera_terminal: Option, +} + +/// Parse the configuration descriptor for the VideoControl interface number, +/// the Processing-Unit id, and the camera (input) terminal id. +unsafe fn video_control_topology(dev: *mut *mut UsbDeviceInterface) -> Option { + unsafe { + let mut num_configs: u8 = 0; + ((**dev).get_number_of_configurations)(dev.cast::(), &raw mut num_configs); + for cfg in 0..num_configs { + let mut desc: *const u8 = ptr::null(); + if ((**dev).get_configuration_descriptor_ptr)(dev.cast::(), cfg, &raw mut desc) + != KIO_RETURN_SUCCESS + || desc.is_null() + { + continue; + } + // wTotalLength at offset 2..4 (little-endian). + let total = u16::from(*desc.add(2)) | (u16::from(*desc.add(3)) << 8); + if let Some(found) = scan_descriptors(desc, total as usize) { + return Some(found); + } + } + None + } +} + +/// Walk a configuration descriptor blob, collecting the first VideoControl +/// interface's Processing-Unit and camera-terminal entity ids. +unsafe fn scan_descriptors(base: *const u8, total: usize) -> Option { + unsafe { + let mut off = 0usize; + let mut vc_interface: Option = None; + let mut camera_terminal: Option = None; + while off + 2 <= total { + let len = *base.add(off) as usize; + if len < 2 || off + len > total { + break; + } + let dtype = *base.add(off + 1); + if dtype == DESC_INTERFACE && len >= 9 { + let class = *base.add(off + 5); + let sub = *base.add(off + 6); + vc_interface = (class == CC_VIDEO && sub == SC_VIDEOCONTROL) + .then(|| *base.add(off + 2)) + .or(vc_interface); + } else if dtype == DESC_CS_INTERFACE && len >= 4 && vc_interface.is_some() { + let subtype = *base.add(off + 2); + // bUnitID / bTerminalID sit at offset 3 in both descriptors; + // an input terminal's wTerminalType (offset 4..6) must be the + // camera sensor — skip composite/other input terminals. + if subtype == VC_INPUT_TERMINAL && len >= 8 { + let ttype = + u16::from(*base.add(off + 4)) | (u16::from(*base.add(off + 5)) << 8); + if ttype == ITT_CAMERA && camera_terminal.is_none() { + camera_terminal = Some(*base.add(off + 3)); + } + } else if subtype == VC_PROCESSING_UNIT { + return vc_interface.map(|vc| VcTopology { + vc_interface: vc, + processing_unit: *base.add(off + 3), + camera_terminal, + }); + } + } + off += len; + } + None + } +} + +unsafe fn make_uuid(bytes: &[u8; 16]) -> CfUuidRef { + unsafe { CFUUIDCreateFromUUIDBytes(ptr::null(), CfUuidBytes { bytes: *bytes }) } +} + +#[cfg(test)] +mod tests { + use super::location_hint; + + /// AVFoundation prints the location id unpadded: a StreamCam on bus + /// 0x01123000 yields a 15-digit id whose leading run is only 7 digits. + /// Taking a fixed 8 would swallow a vid nibble and shift the location — + /// which made every control write fail closed with `NotFound` (the bug + /// the exact-match requirement exposed). + #[test] + fn unpadded_location_parses() { + assert_eq!(location_hint("0x1123000046d0893"), Some(0x0112_3000)); + } + + #[test] + fn padded_location_parses() { + assert_eq!(location_hint("0x14110000046d082d"), Some(0x1411_0000)); + } + + #[test] + fn too_short_ids_yield_no_hint() { + assert_eq!(location_hint("0x46d0893"), None); + assert_eq!(location_hint("46d0893"), None); + assert_eq!(location_hint(""), None); + } +} diff --git a/crates/openlogi-camera/src/uvc_windows.rs b/crates/openlogi-camera/src/uvc_windows.rs new file mode 100644 index 00000000..5baa5d09 --- /dev/null +++ b/crates/openlogi-camera/src/uvc_windows.rs @@ -0,0 +1,407 @@ +//! Device-level UVC controls over DirectShow (Windows). +//! +//! Windows exposes the same UVC controls the macOS backend reaches over raw +//! IOKit USB, but pre-mapped by the OS: `IAMVideoProcAmp` carries the image +//! controls (brightness/contrast/…) and `IAMCameraControl` the lens controls +//! (zoom/focus/exposure), each with per-property auto/manual flags. Writes +//! land in the camera's own registers, so — exactly as on macOS — a change is +//! seen by every app that opens the camera. +//! +//! Enumeration also lives here: the DirectShow video-input category yields +//! each camera's friendly name and its device path, whose embedded +//! `vid_xxxx&pid_xxxx` markers give the USB identity. The device path doubles +//! as the camera's stable [`Camera::unique_id`]. + +#![expect( + unsafe_code, + reason = "DirectShow COM (device enumeration + IAMVideoProcAmp / IAMCameraControl)" +)] + +use windows::Win32::Media::DirectShow::{IAMCameraControl, IAMVideoProcAmp, IBaseFilter}; +use windows::Win32::System::Com::StructuredStorage::IPropertyBag; +use windows::Win32::System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, IEnumMoniker, + IMoniker, +}; +use windows::Win32::System::Variant::{VARIANT, VT_BSTR}; +use windows::core::{GUID, Interface, w}; + +use crate::Camera; +use crate::controls::{ + AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange, +}; + +/// CLSID_SystemDeviceEnum — the DirectShow device-category enumerator. +const CLSID_SYSTEM_DEVICE_ENUM: GUID = GUID::from_u128(0x62be5d10_60eb_11d0_bd3b_00a0c911ce86); +/// CLSID_VideoInputDeviceCategory — webcams and other video capture sources. +const CLSID_VIDEO_INPUT_DEVICE_CATEGORY: GUID = + GUID::from_u128(0x860bb310_5d01_11d0_bd3b_00a0c911ce86); +// VideoProcAmp / CameraControl property ids (strmif.h). Raw values rather +// than the generated enums so the mapping reads like the UVC tables. +const VPA_BRIGHTNESS: i32 = 0; +const VPA_CONTRAST: i32 = 1; +const VPA_HUE: i32 = 2; +const VPA_SATURATION: i32 = 3; +const VPA_SHARPNESS: i32 = 4; +const VPA_WHITE_BALANCE: i32 = 6; +const CC_ZOOM: i32 = 3; +const CC_EXPOSURE: i32 = 4; +const CC_FOCUS: i32 = 6; +/// `*_Flags_Auto` / `*_Flags_Manual` share values across both interfaces. +const FLAG_AUTO: i32 = 0x1; +const FLAG_MANUAL: i32 = 0x2; + +/// Which DirectShow interface carries a control, plus its property id. +#[derive(Clone, Copy)] +enum Prop { + VideoProcAmp(i32), + CameraControl(i32), +} + +impl CameraControl { + fn prop(self) -> Prop { + match self { + Self::Zoom => Prop::CameraControl(CC_ZOOM), + Self::Focus => Prop::CameraControl(CC_FOCUS), + Self::Exposure => Prop::CameraControl(CC_EXPOSURE), + Self::Brightness => Prop::VideoProcAmp(VPA_BRIGHTNESS), + Self::Contrast => Prop::VideoProcAmp(VPA_CONTRAST), + Self::Saturation => Prop::VideoProcAmp(VPA_SATURATION), + Self::Sharpness => Prop::VideoProcAmp(VPA_SHARPNESS), + Self::WhiteBalance => Prop::VideoProcAmp(VPA_WHITE_BALANCE), + Self::Tint => Prop::VideoProcAmp(VPA_HUE), + } + } +} + +impl AutoToggle { + /// The property whose auto/manual flag backs this toggle. + fn prop(self) -> Prop { + match self { + Self::Focus => Prop::CameraControl(CC_FOCUS), + Self::Exposure => Prop::CameraControl(CC_EXPOSURE), + Self::WhiteBalance => Prop::VideoProcAmp(VPA_WHITE_BALANCE), + } + } +} + +/// Enumerate every video-input device DirectShow reports, with the USB +/// vendor/product ids parsed out of the device path. Non-USB sources (virtual +/// cameras) carry no `vid_`/`pid_` markers and are dropped. +pub fn enumerate() -> Vec { + monikers() + .map(|monikers| { + monikers + .into_iter() + .filter_map(|m| camera_from_moniker(&m)) + .collect() + }) + .unwrap_or_default() +} + +/// Read a control's min/max/default/current straight from the device. +/// +/// # Errors +/// [`ControlError::NotFound`] when no camera matches `unique_id`, +/// [`ControlError::Unsupported`] when the camera lacks the control. +pub fn control_range( + unique_id: &str, + control: CameraControl, +) -> Result { + let dev = Device::open(unique_id)?; + dev.range(control.prop()).map(|(range, _)| range) +} + +/// Read every supported control in a single device bind. +/// +/// # Errors +/// [`ControlError::NotFound`] when no camera matches `unique_id`. +pub fn control_ranges(unique_id: &str) -> Result, ControlError> { + Ok(read_camera_state(unique_id)?.controls) +} + +/// Read every supported control range *and* auto-toggle state in a single +/// device bind — what the GUI controls panel builds itself from. +/// +/// # Errors +/// [`ControlError::NotFound`] when no camera matches `unique_id`. +pub fn read_camera_state(unique_id: &str) -> Result { + let dev = Device::open(unique_id)?; + let mut state = CameraState::default(); + for control in CameraControl::ALL { + if let Ok((range, _)) = dev.range(control.prop()) { + state.controls.push((control, range)); + } + } + for toggle in AutoToggle::ALL { + if let Ok((_, caps)) = dev.range(toggle.prop()) + && caps & FLAG_AUTO != 0 + && let Ok(current) = dev.auto_engaged(toggle.prop()) + { + // DirectShow reports which modes exist but not a factory default; + // auto-capable properties ship with auto engaged on every Logitech + // camera, so that is the reset target. + state.autos.push(( + toggle, + AutoState { + current, + default: true, + }, + )); + } + } + Ok(state) +} + +/// Write a control's current value (switching that property to manual). +/// +/// # Errors +/// As [`control_range`]. +pub fn set_control( + unique_id: &str, + control: CameraControl, + value: i32, +) -> Result<(), ControlError> { + let dev = Device::open(unique_id)?; + dev.set(control.prop(), value, FLAG_MANUAL) +} + +/// Switch an auto mode (focus / exposure / white balance) on or off. +/// +/// # Errors +/// As [`control_range`]. +pub fn set_auto(unique_id: &str, toggle: AutoToggle, on: bool) -> Result<(), ControlError> { + let dev = Device::open(unique_id)?; + dev.set_auto(toggle.prop(), on) +} + +/// Apply a batch of auto toggles and control values in a single device bind. +/// Every write is attempted, but any failure surfaces so callers never persist +/// a batch the hardware didn't take. +/// +/// # Errors +/// [`ControlError::NotFound`] when no camera matches `unique_id`; otherwise the +/// first per-write error after attempting the whole batch. +pub fn apply_settings( + unique_id: &str, + autos: &[(AutoToggle, bool)], + values: &[(CameraControl, i32)], +) -> Result<(), ControlError> { + let dev = Device::open(unique_id)?; + let mut first_err = None; + for (toggle, on) in autos { + if let Err(e) = dev.set_auto(toggle.prop(), *on) { + first_err.get_or_insert(e); + } + } + for (control, value) in values { + if let Err(e) = dev.set(control.prop(), *value, FLAG_MANUAL) { + first_err.get_or_insert(e); + } + } + first_err.map_or(Ok(()), Err) +} + +/// A camera's bound capture filter, with the two control interfaces it may +/// implement (a camera without lens motors typically lacks `IAMCameraControl`). +struct Device { + proc_amp: Option, + camera_control: Option, +} + +impl Device { + /// Bind the capture filter whose device path equals `unique_id`. Exact + /// match only — guessing another camera could adjust the wrong hardware. + fn open(unique_id: &str) -> Result { + let monikers = monikers().map_err(|e| ControlError::Io(e.to_string()))?; + for moniker in monikers { + if read_property(&moniker, w!("DevicePath")).as_deref() != Some(unique_id) { + continue; + } + // SAFETY: documented moniker → filter bind; the returned interface + // pointers are reference-counted by the `windows` wrappers. + let filter: IBaseFilter = unsafe { moniker.BindToObject(None, None) } + .map_err(|e| ControlError::Io(e.to_string()))?; + return Ok(Self { + proc_amp: filter.cast().ok(), + camera_control: filter.cast().ok(), + }); + } + Err(ControlError::NotFound) + } + + /// GetRange for `prop`: the control's bounds plus its capability flags. + fn range(&self, prop: Prop) -> Result<(ControlRange, i32), ControlError> { + let (mut min, mut max, mut step, mut default, mut caps) = (0, 0, 0, 0, 0); + // SAFETY: documented COM calls writing the five out-params. + unsafe { + match prop { + Prop::VideoProcAmp(id) => self + .proc_amp + .as_ref() + .ok_or(ControlError::Unsupported)? + .GetRange( + id, + &raw mut min, + &raw mut max, + &raw mut step, + &raw mut default, + &raw mut caps, + ), + Prop::CameraControl(id) => self + .camera_control + .as_ref() + .ok_or(ControlError::Unsupported)? + .GetRange( + id, + &raw mut min, + &raw mut max, + &raw mut step, + &raw mut default, + &raw mut caps, + ), + } + } + .map_err(|_| ControlError::Unsupported)?; + let current = self.get(prop).map_or(default, |(value, _)| value); + Ok(( + ControlRange { + min, + max, + default, + current, + }, + caps, + )) + } + + /// Get for `prop`: the current value and its auto/manual flags. + fn get(&self, prop: Prop) -> Result<(i32, i32), ControlError> { + let (mut value, mut flags) = (0, 0); + // SAFETY: documented COM calls writing the two out-params. + unsafe { + match prop { + Prop::VideoProcAmp(id) => self + .proc_amp + .as_ref() + .ok_or(ControlError::Unsupported)? + .Get(id, &raw mut value, &raw mut flags), + Prop::CameraControl(id) => self + .camera_control + .as_ref() + .ok_or(ControlError::Unsupported)? + .Get(id, &raw mut value, &raw mut flags), + } + } + .map_err(|_| ControlError::Unsupported)?; + Ok((value, flags)) + } + + /// Whether `prop` currently runs in auto mode. + fn auto_engaged(&self, prop: Prop) -> Result { + Ok(self.get(prop)?.1 & FLAG_AUTO != 0) + } + + /// Set `prop` to `value` under `flags` (auto or manual). + fn set(&self, prop: Prop, value: i32, flags: i32) -> Result<(), ControlError> { + // SAFETY: documented COM calls; the device validates the value. + unsafe { + match prop { + Prop::VideoProcAmp(id) => self + .proc_amp + .as_ref() + .ok_or(ControlError::Unsupported)? + .Set(id, value, flags), + Prop::CameraControl(id) => self + .camera_control + .as_ref() + .ok_or(ControlError::Unsupported)? + .Set(id, value, flags), + } + } + .map_err(|_| ControlError::Unsupported) + } + + /// Engage or release auto mode, keeping the current value in place. + fn set_auto(&self, prop: Prop, on: bool) -> Result<(), ControlError> { + let value = self.get(prop).map_or(0, |(v, _)| v); + self.set(prop, value, if on { FLAG_AUTO } else { FLAG_MANUAL }) + } +} + +/// Every video-input moniker DirectShow reports (empty when the category has +/// no devices, which the enumerator signals with `S_FALSE`). +fn monikers() -> windows::core::Result> { + // SAFETY: standard COM setup + documented enumerator calls. Double + // initialization (or an existing STA on this thread) is harmless here — + // the enumerator works under either apartment model. + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + let dev_enum: windows::Win32::Media::DirectShow::ICreateDevEnum = + CoCreateInstance(&CLSID_SYSTEM_DEVICE_ENUM, None, CLSCTX_INPROC_SERVER)?; + let mut enum_moniker: Option = None; + // S_FALSE (an empty category) is Ok with no enumerator — handled below. + dev_enum.CreateClassEnumerator( + &CLSID_VIDEO_INPUT_DEVICE_CATEGORY, + &raw mut enum_moniker, + 0, + )?; + let Some(enum_moniker) = enum_moniker else { + return Ok(Vec::new()); + }; + let mut all = Vec::new(); + loop { + let mut chunk = [const { None }; 8]; + let mut fetched = 0; + let hr = enum_moniker.Next(&mut chunk, Some(&raw mut fetched)); + all.extend(chunk.into_iter().take(fetched as usize).flatten()); + if hr.is_err() || fetched == 0 { + break; + } + } + Ok(all) + } +} + +/// Build a [`Camera`] from one moniker: friendly name + device path, with the +/// USB ids parsed from the path's `vid_xxxx&pid_xxxx` markers. +fn camera_from_moniker(moniker: &IMoniker) -> Option { + let unique_id = read_property(moniker, w!("DevicePath"))?; + let (vendor_id, product_id) = parse_device_path_ids(&unique_id)?; + let name = read_property(moniker, w!("FriendlyName")).unwrap_or_else(|| "Camera".into()); + Some(Camera { + name, + unique_id, + vendor_id, + product_id, + max_resolution: None, + max_fps: None, + }) +} + +/// Read one string property (`FriendlyName` / `DevicePath`) from a moniker's +/// property bag. +fn read_property(moniker: &IMoniker, name: windows::core::PCWSTR) -> Option { + // SAFETY: documented property-bag reads; the VARIANT is only interpreted + // as a BSTR when the bag reports that type. + unsafe { + let bag: IPropertyBag = moniker.BindToStorage(None, None).ok()?; + let mut value = VARIANT::default(); + bag.Read(name, &raw mut value, None).ok()?; + if value.Anonymous.Anonymous.vt != VT_BSTR { + return None; + } + Some(value.Anonymous.Anonymous.Anonymous.bstrVal.to_string()) + } +} + +/// Pull the hex USB vendor/product ids out of a device path such as +/// `\\?\usb#vid_046d&pid_0893&mi_00#…`. +fn parse_device_path_ids(path: &str) -> Option<(u16, u16)> { + let lower = path.to_ascii_lowercase(); + let hex_after = |marker: &str| -> Option { + let rest = lower.split(marker).nth(1)?; + u16::from_str_radix(rest.get(..4)?, 16).ok() + }; + Some((hex_after("vid_")?, hex_after("pid_")?)) +} diff --git a/crates/openlogi-cli/Cargo.toml b/crates/openlogi-cli/Cargo.toml index 0a42f4a4..da1252e7 100644 --- a/crates/openlogi-cli/Cargo.toml +++ b/crates/openlogi-cli/Cargo.toml @@ -13,7 +13,9 @@ categories = ["command-line-utilities", "hardware-support"] [dependencies] openlogi-core = { path = "../openlogi-core", version = "0.6.19" } openlogi-hid = { path = "../openlogi-hid", version = "0.6.19" } +openlogi-camera = { path = "../openlogi-camera", version = "0.6.19" } openlogi-assets = { path = "../openlogi-assets", version = "0.6.19" } +png = "0.17" clap = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] } diff --git a/crates/openlogi-cli/src/cmd/camera.rs b/crates/openlogi-cli/src/cmd/camera.rs new file mode 100644 index 00000000..788ec801 --- /dev/null +++ b/crates/openlogi-cli/src/cmd/camera.rs @@ -0,0 +1,101 @@ +//! `openlogi camera` — read and write device-level UVC image controls. +//! +//! Exercises the `openlogi-camera` UVC path (the same primitive the GUI controls +//! panel uses). Changes land in the camera's own registers, so other apps +//! (Google Meet, Zoom, OBS) see them too. + +use anyhow::{Result, anyhow}; +use clap::{Args, Subcommand}; +use openlogi_camera::{AutoToggle, CameraControl}; + +#[derive(Debug, Args)] +pub struct CameraArgs { + #[command(subcommand)] + pub cmd: Option, + /// Operate on the camera with this unique id (default: first Logitech). + #[arg(long, global = true)] + pub camera: Option, +} + +#[derive(Debug, Subcommand)] +pub enum CameraCmd { + /// Show each control's min/max/default/current and each auto mode's state + /// (the default action). + Get, + /// Set a control to a value (or an auto toggle to 0/1); persists on the + /// device. + Set { + /// zoom | focus | exposure | brightness | contrast | saturation | + /// sharpness | white_balance | tint, or focus_auto | exposure_auto | + /// white_balance_auto + control: String, + value: i32, + }, +} + +pub fn run(args: CameraArgs) -> Result<()> { + let uid = match args.camera { + Some(id) => id, + None => openlogi_camera::enumerate_cameras() + .into_iter() + .next() + .map(|c| c.unique_id) + .ok_or_else(|| anyhow!("no Logitech camera found"))?, + }; + + match args.cmd.unwrap_or(CameraCmd::Get) { + CameraCmd::Get => { + println!("controls for {uid}:"); + match openlogi_camera::read_camera_state(&uid) { + Ok(state) if !state.controls.is_empty() => { + for (control, r) in &state.controls { + println!( + " {}: min={} max={} default={} current={}", + control.name(), + r.min, + r.max, + r.default, + r.current + ); + } + for (toggle, st) in &state.autos { + println!( + " {}: current={} default={}", + toggle.name(), + st.current, + st.default + ); + } + } + Ok(_) => println!(" (no adjustable controls, or camera not found)"), + Err(e) => println!(" {e}"), + } + } + CameraCmd::Set { control, value } => { + let raw = control.to_ascii_lowercase(); + if let Some(toggle) = AutoToggle::ALL.iter().find(|t| t.name() == raw) { + openlogi_camera::set_auto(&uid, *toggle, value != 0).map_err(|e| anyhow!("{e}"))?; + println!("set {} = {}", toggle.name(), value != 0); + } else { + let control = parse_control(&raw)?; + openlogi_camera::set_control(&uid, control, value).map_err(|e| anyhow!("{e}"))?; + println!("set {} = {value}", control.name()); + } + } + } + Ok(()) +} + +fn parse_control(raw: &str) -> Result { + CameraControl::ALL + .into_iter() + .find(|c| c.name() == raw) + .ok_or_else(|| { + let names: Vec<&str> = CameraControl::ALL + .iter() + .map(|c| c.name()) + .chain(AutoToggle::ALL.iter().map(|t| t.name())) + .collect(); + anyhow!("unknown control {raw:?} ({})", names.join("|")) + }) +} diff --git a/crates/openlogi-cli/src/cmd/list.rs b/crates/openlogi-cli/src/cmd/list.rs index 4f648052..00c845fd 100644 --- a/crates/openlogi-cli/src/cmd/list.rs +++ b/crates/openlogi-cli/src/cmd/list.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use clap::Args; +use openlogi_camera::Camera; use openlogi_core::device::{BatteryInfo, DeviceInventory, DeviceModelInfo, PairedDevice}; #[derive(Debug, Args)] @@ -9,9 +10,10 @@ pub async fn run(_args: ListArgs) -> Result<()> { let inventories = openlogi_hid::enumerate() .await .context("failed to enumerate HID++ devices")?; + let cameras = openlogi_camera::enumerate_cameras(); - if inventories.is_empty() { - println!("No Logitech HID++ devices found."); + if inventories.is_empty() && cameras.is_empty() { + println!("No Logitech HID++ devices or webcams found."); println!(); println!("Notes:"); println!(" - On macOS, quit Logi Options+ first — both apps fight over HID++ access."); @@ -33,9 +35,33 @@ pub async fn run(_args: ListArgs) -> Result<()> { print_inventory(inv); } + if !cameras.is_empty() { + if !inventories.is_empty() { + println!(); + } + print_cameras(&cameras); + } + Ok(()) } +fn print_cameras(cameras: &[Camera]) { + println!("Cameras ({} Logitech UVC)", cameras.len()); + let last = cameras.len() - 1; + for (i, cam) in cameras.iter().enumerate() { + let prefix = if i == last { " └─" } else { " ├─" }; + let caps = match (cam.max_resolution, cam.max_fps) { + (Some((w, h)), Some(fps)) => format!(", up to {w}x{h}@{fps}"), + (Some((w, h)), None) => format!(", up to {w}x{h}"), + _ => String::new(), + }; + println!( + "{prefix} ● {} (camera, vid={:04x} pid={:04x}{caps}, id={})", + cam.name, cam.vendor_id, cam.product_id, cam.unique_id + ); + } +} + fn print_inventory(inv: &DeviceInventory) { let uid = inv.receiver.unique_id.as_deref().unwrap_or("—"); println!( diff --git a/crates/openlogi-cli/src/cmd/mod.rs b/crates/openlogi-cli/src/cmd/mod.rs index 4cf0516d..2d3c0224 100644 --- a/crates/openlogi-cli/src/cmd/mod.rs +++ b/crates/openlogi-cli/src/cmd/mod.rs @@ -2,13 +2,19 @@ use anyhow::Result; use clap::Subcommand; pub mod assets; +pub mod camera; pub mod diag; pub mod list; +pub mod snapshot; #[derive(Debug, Subcommand)] pub enum Command { /// List connected Logitech HID++ devices. List(list::ListArgs), + /// Capture one frame from a Logitech webcam to a PNG. + Snapshot(snapshot::SnapshotArgs), + /// Read or write device-level UVC image controls on a webcam. + Camera(camera::CameraArgs), /// Manage assets fetched from assets.openlogi.org. #[command(subcommand)] Assets(assets::AssetsCmd), @@ -21,6 +27,10 @@ impl Command { pub async fn run(self) -> Result<()> { match self { Self::List(args) => list::run(args).await, + // Camera capture is blocking AVFoundation — no need for the async runtime. + Self::Snapshot(args) => snapshot::run(args), + // UVC control transfers are blocking IOKit — no async runtime needed. + Self::Camera(args) => camera::run(args), // `assets sync` is blocking HTTP — no need for the async runtime. Self::Assets(cmd) => cmd.run(), Self::Diag(cmd) => cmd.run().await, diff --git a/crates/openlogi-cli/src/cmd/snapshot.rs b/crates/openlogi-cli/src/cmd/snapshot.rs new file mode 100644 index 00000000..889b81c7 --- /dev/null +++ b/crates/openlogi-cli/src/cmd/snapshot.rs @@ -0,0 +1,57 @@ +//! `openlogi snapshot` — grab one frame from a Logitech webcam to a PNG. +//! +//! Exercises the `openlogi-camera` capture path (the same primitive the GUI +//! preview uses). Capturing needs Camera permission; from this unbundled CLI +//! macOS may deny access (no `NSCameraUsageDescription`), which is reported +//! rather than fatal. + +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use clap::Args; + +#[derive(Debug, Args)] +pub struct SnapshotArgs { + /// Output PNG path. + #[arg(default_value = "snapshot.png")] + pub path: String, + /// Capture from the camera with this unique id (default: first Logitech). + #[arg(long)] + pub camera: Option, +} + +pub fn run(args: SnapshotArgs) -> Result<()> { + let unique_id = match args.camera { + Some(id) => id, + None => openlogi_camera::enumerate_cameras() + .into_iter() + .next() + .map(|camera| camera.unique_id) + .ok_or_else(|| anyhow!("no Logitech camera found"))?, + }; + + println!("capturing one frame from {unique_id} …"); + let frame = openlogi_camera::capture_frame(&unique_id, Duration::from_secs(5)) + .map_err(|e| anyhow!("{e}"))?; + // Frames are stored BGRA (gpui's order); PNG wants RGBA, so swap R/B once. + let mut rgba = frame.bgra; + for px in rgba.chunks_exact_mut(4) { + px.swap(0, 2); + } + write_png(&args.path, frame.width, frame.height, &rgba) + .with_context(|| format!("writing {}", args.path))?; + println!("wrote {}x{} → {}", frame.width, frame.height, args.path); + Ok(()) +} + +fn write_png(path: &str, width: u32, height: u32, rgba: &[u8]) -> Result<()> { + let file = std::fs::File::create(path)?; + let writer = std::io::BufWriter::new(file); + let mut encoder = png::Encoder::new(writer, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header()? + .write_image_data(rgba) + .context("encoding PNG") +} diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index f5da39ba..eca39241 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -291,6 +291,17 @@ pub struct SmartShift { pub tunable_torque: u8, } +/// Per-webcam UVC controls, keyed by control name (`brightness`, `focus`, +/// `focus_auto`, …). Each value is the raw device unit (its scale comes from +/// the camera's own min/max); auto toggles store 0/1. Persisted so values +/// survive an unplug or reboot — the GUI re-applies them over USB when the +/// camera is next viewed, since the hardware only retains them until it loses +/// power. Serializes to the same TOML table the earlier fixed-field struct +/// wrote, so existing saved controls load unchanged. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CameraControls(pub BTreeMap); + /// Which control owns a device's single gesture role. /// /// Stored explicitly — rather than inferred from which button happens to carry a @@ -428,6 +439,17 @@ pub struct DeviceConfig { /// the same reason as [`Self::dpi`]. `None` until the user changes it. #[serde(default, skip_serializing_if = "Option::is_none")] pub smartshift: Option, + /// Per-webcam UVC image controls (brightness/contrast/…). `None` until the + /// user adjusts one, so it stays out of `config.toml` otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub camera_controls: Option, + /// User-saved camera profiles (name → control snapshot). Built-in profiles + /// (Default / Streaming / Video call) live in the GUI, not here. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub camera_profiles: BTreeMap, + /// The camera profile last applied from the GUI, highlighted on reopen. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub camera_profile: Option, /// Invert this device's scroll-wheel direction relative to the OS setting /// (issue #126): on, a wheel tick scrolls the opposite way, so a user who /// keeps macOS "natural scrolling" for the trackpad can have a traditional @@ -482,6 +504,12 @@ struct RawDeviceConfig { #[serde(default)] smartshift: Option, #[serde(default)] + camera_controls: Option, + #[serde(default)] + camera_profiles: BTreeMap, + #[serde(default)] + camera_profile: Option, + #[serde(default)] invert_scroll: bool, } @@ -525,6 +553,9 @@ impl From for DeviceConfig { dpi: raw.dpi, lighting: raw.lighting, smartshift: raw.smartshift, + camera_controls: raw.camera_controls, + camera_profiles: raw.camera_profiles, + camera_profile: raw.camera_profile, invert_scroll: raw.invert_scroll, } } @@ -952,6 +983,67 @@ impl Config { .smartshift = Some(smartshift); } + /// The saved UVC image controls for `device_key`, or `None` if never set. + #[must_use] + pub fn camera_controls(&self, device_key: &str) -> Option { + self.devices + .get(device_key) + .and_then(|d| d.camera_controls.clone()) + } + + /// Replace the saved UVC image controls for `device_key`. + pub fn set_camera_controls(&mut self, device_key: &str, controls: CameraControls) { + self.devices + .entry(device_key.to_string()) + .or_default() + .camera_controls = Some(controls); + } + + /// The saved custom camera profiles for `device_key` (name → snapshot). + #[must_use] + pub fn camera_profiles(&self, device_key: &str) -> BTreeMap { + self.devices + .get(device_key) + .map(|d| d.camera_profiles.clone()) + .unwrap_or_default() + } + + /// Save (or overwrite) a custom camera profile for `device_key`. + pub fn save_camera_profile(&mut self, device_key: &str, name: &str, snap: CameraControls) { + self.devices + .entry(device_key.to_string()) + .or_default() + .camera_profiles + .insert(name.to_string(), snap); + } + + /// Delete a custom camera profile, clearing the active selection if it + /// named it. Unknown names are a no-op. + pub fn delete_camera_profile(&mut self, device_key: &str, name: &str) { + if let Some(device) = self.devices.get_mut(device_key) { + device.camera_profiles.remove(name); + if device.camera_profile.as_deref() == Some(name) { + device.camera_profile = None; + } + } + } + + /// The last-applied camera profile name for `device_key`, if any. + #[must_use] + pub fn camera_active_profile(&self, device_key: &str) -> Option { + self.devices + .get(device_key) + .and_then(|d| d.camera_profile.clone()) + } + + /// Record which camera profile `device_key` last applied. + pub fn set_camera_active_profile(&mut self, device_key: &str, name: Option) { + self.devices + .entry(device_key.to_string()) + .or_default() + .camera_profile = name; + } + /// Whether `device_key`'s scroll wheel is inverted (issue #126). `false` /// (the native direction) for an unconfigured or absent device. #[must_use] diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs index 583257d1..a2df7dce 100644 --- a/crates/openlogi-core/src/device.rs +++ b/crates/openlogi-core/src/device.rs @@ -31,6 +31,7 @@ pub enum DeviceKind { Gamepad, Joystick, Headset, + Camera, Unknown, } @@ -55,6 +56,7 @@ impl DeviceKind { "gamepad" => Self::Gamepad, "joystick" => Self::Joystick, "headset" => Self::Headset, + "camera" => Self::Camera, _ => Self::Unknown, } } diff --git a/crates/openlogi-core/src/diagnostics.rs b/crates/openlogi-core/src/diagnostics.rs index fd54a09b..8c873267 100644 --- a/crates/openlogi-core/src/diagnostics.rs +++ b/crates/openlogi-core/src/diagnostics.rs @@ -362,6 +362,7 @@ fn kind_label(kind: DeviceKind) -> &'static str { DeviceKind::Gamepad => "gamepad", DeviceKind::Joystick => "joystick", DeviceKind::Headset => "headset", + DeviceKind::Camera => "camera", DeviceKind::Unknown => "unknown", } } diff --git a/crates/openlogi-gui/Cargo.toml b/crates/openlogi-gui/Cargo.toml index 0c6575f6..d64a7178 100644 --- a/crates/openlogi-gui/Cargo.toml +++ b/crates/openlogi-gui/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dependencies] openlogi-core = { path = "../openlogi-core" } openlogi-hid = { path = "../openlogi-hid" } +openlogi-camera = { path = "../openlogi-camera" } openlogi-hook = { path = "../openlogi-hook" } openlogi-assets = { path = "../openlogi-assets" } openlogi-agent-core = { path = "../openlogi-agent-core" } diff --git a/crates/openlogi-gui/bundle/gui-dev/Info.plist b/crates/openlogi-gui/bundle/gui-dev/Info.plist index 91256e34..9fc527fd 100644 --- a/crates/openlogi-gui/bundle/gui-dev/Info.plist +++ b/crates/openlogi-gui/bundle/gui-dev/Info.plist @@ -14,6 +14,7 @@ CFBundleVersion0.0.0 LSMinimumSystemVersion13.0 NSHighResolutionCapable + NSCameraUsageDescriptionOpenLogi previews your Logitech webcam locally. Video never leaves your Mac.