diff --git a/Cargo.lock b/Cargo.lock index 5cf8ba0..b76a599 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1408,6 +1408,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "screencapturekit" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b732b2db50b25cfbcb171e1b7abda06e77873aeef493e635eb209482fdd149" + [[package]] name = "semver" version = "1.0.28" @@ -1646,6 +1652,37 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -1708,6 +1745,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcli-capture" +version = "0.0.1" +dependencies = [ + "image", + "screencapturekit", + "tempfile", + "thiserror", + "tracing", + "vcli-core", +] + [[package]] name = "vcli-core" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 635405e..97750f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/vcli-core", + "crates/vcli-capture", "crates/vcli-dsl", "crates/vcli-ipc", "crates/vcli-perception", @@ -34,6 +35,12 @@ rusqlite = { version = "0.31", features = ["bundled"] } sha2 = "0.10" hex = "0.4" +tracing = "0.1" + +# Platform-specific, referenced from vcli-capture only. Declaring here so +# downstream crates that later depend on vcli-capture don't re-select versions. +screencapturekit = "1" + # Added by vcli-perception lane dashmap = "6" imageproc = "0.25" diff --git a/crates/vcli-capture/Cargo.toml b/crates/vcli-capture/Cargo.toml new file mode 100644 index 0000000..fd3fb6a --- /dev/null +++ b/crates/vcli-capture/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "vcli-capture" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Capture trait, macOS ScreenCaptureKit backend, mock, Windows stub, for vcli." + +[dependencies] +vcli-core = { path = "../vcli-core" } +thiserror = { workspace = true } +tracing = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +screencapturekit = { workspace = true } + +[dev-dependencies] +image = { workspace = true } +tempfile = { workspace = true } + +[[example]] +name = "capture_once" +path = "examples/capture_once.rs" diff --git a/crates/vcli-capture/build.rs b/crates/vcli-capture/build.rs new file mode 100644 index 0000000..748e185 --- /dev/null +++ b/crates/vcli-capture/build.rs @@ -0,0 +1,18 @@ +fn main() { + // Link CoreGraphics framework for CGPreflightScreenCaptureAccess and + // CGRequestScreenCaptureAccess used in permission.rs on macOS. + #[cfg(target_os = "macos")] + { + println!("cargo:rustc-link-lib=framework=CoreGraphics"); + + // The screencapturekit crate (v1.5.4) uses a Swift bridge that references + // libswift_Concurrency.dylib. On macOS 26+ this dylib is not a physical + // file but lives in the dyld shared cache. On older macOS it lives in + // the Swift toolchain (e.g., Xcode's swift-5.5 directory). Add the + // Xcode toolchain path to rpath so the loader finds it on both. + let xcode_swift55_path = + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain\ + /usr/lib/swift-5.5/macosx"; + println!("cargo:rustc-link-arg=-Wl,-rpath,{xcode_swift55_path}"); + } +} diff --git a/crates/vcli-capture/examples/capture_once.rs b/crates/vcli-capture/examples/capture_once.rs new file mode 100644 index 0000000..fe4c8ab --- /dev/null +++ b/crates/vcli-capture/examples/capture_once.rs @@ -0,0 +1,99 @@ +//! capture_once — grab one screen frame, print dimensions + permission status. +//! +//! Useful for manually verifying macOS Screen Recording permission and the +//! SCK wiring without running the whole daemon. +//! +//! Usage: cargo run -p vcli-capture --example capture_once +//! cargo run -p vcli-capture --example capture_once -- --save /tmp/out.png + +use std::process::ExitCode; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::from(2) + } + } +} + +#[cfg(target_os = "macos")] +fn run() -> Result<(), Box> { + use std::env; + use vcli_capture::{ + capture::Capture, + macos::MacCapture, + permission::{ + check_screen_recording_permission, request_screen_recording_permission, + PermissionStatus, + }, + }; + + let args: Vec = env::args().collect(); + let save_to = args + .iter() + .position(|a| a == "--save") + .and_then(|i| args.get(i + 1).cloned()); + + match check_screen_recording_permission()? { + PermissionStatus::Granted => { + println!("permission: granted"); + } + PermissionStatus::Denied => { + println!("permission: denied — prompting user"); + request_screen_recording_permission()?; + println!( + "re-run after granting in System Settings → Privacy & Security → Screen Recording" + ); + return Ok(()); + } + PermissionStatus::Unknown => { + println!("permission: unknown — attempting capture anyway"); + } + } + + let mut cap = MacCapture::new()?; + let frame = cap.grab_screen()?; + println!( + "captured {}x{} @ stride {} bytes, format {:?}", + frame.width(), + frame.height(), + frame.stride, + frame.format + ); + + if let Some(path) = save_to { + use image::{ImageBuffer, Rgba}; + let w = frame.width() as u32; + let h = frame.height() as u32; + let mut img: ImageBuffer, Vec> = ImageBuffer::new(w, h); + for (x, y, px) in img.enumerate_pixels_mut() { + let off = (y as usize) * frame.stride + (x as usize) * 4; + // RGBA → store as RGBA (frame.format == Rgba8) + let r = frame.pixels[off]; + let g = frame.pixels[off + 1]; + let b = frame.pixels[off + 2]; + let a = frame.pixels[off + 3]; + *px = Rgba([r, g, b, a]); + } + img.save(&path)?; + println!("saved: {path}"); + } + + let windows = cap.enumerate_windows()?; + println!("windows visible: {}", windows.len()); + for w in windows.iter().take(10) { + println!( + " id={} app={:?} title={:?} bounds={}x{}@({},{})", + w.id, w.app, w.title, w.bounds.w, w.bounds.h, w.bounds.x, w.bounds.y + ); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn run() -> Result<(), Box> { + eprintln!("capture_once is only implemented for macOS in v0"); + Ok(()) +} diff --git a/crates/vcli-capture/src/capture.rs b/crates/vcli-capture/src/capture.rs new file mode 100644 index 0000000..1bd9da4 --- /dev/null +++ b/crates/vcli-capture/src/capture.rs @@ -0,0 +1,106 @@ +//! `Capture` trait and associated descriptor types. + +use vcli_core::frame::{Frame, FrameFormat}; +use vcli_core::geom::Rect; +use vcli_core::region::WindowIndex; + +use crate::error::CaptureError; + +/// Opaque identifier for a display. v0 is single-display (primary) so this is +/// effectively `DisplayId::PRIMARY`, but the type is here from day 1 to keep +/// the trait stable when multi-display lands. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DisplayId(pub u32); + +impl DisplayId { + /// The primary display. Backends may enumerate others in the future. + pub const PRIMARY: Self = Self(0); +} + +/// A window known to the capture backend. Stable for the lifetime of the +/// window; ordering matches the native enumeration order (macOS: oldest → +/// newest per Decision F2). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowDescriptor { + /// Backend-assigned stable id. For macOS this is the AX window id (u32) + /// re-exposed. Opaque to callers. + pub id: u64, + /// App/owner name, e.g. "Safari". + pub app: String, + /// Window title string, possibly empty. + pub title: String, + /// Bounds in logical pixels, relative to the display origin. + pub bounds: Rect, + /// 0-based index within the (app, title-substring) group this window + /// belongs to in the current enumeration pass. The region resolver in + /// the perception lane uses this directly as `WindowIndex`. + pub window_index: WindowIndex, + /// Display the window is currently on. + pub display: DisplayId, +} + +/// Capture backend. One capture per tick; frame is shared across programs. +pub trait Capture: Send + Sync { + /// Pixel formats this backend can emit. First entry is the preferred one. + fn supported_formats(&self) -> &[FrameFormat]; + + /// Enumerate visible application windows on all displays. Excludes + /// off-screen and minimized windows. Stable ordering per backend. + /// + /// # Errors + /// + /// Returns [`CaptureError`] if the backend fails to enumerate windows. + fn enumerate_windows(&self) -> Result, CaptureError>; + + /// Grab a full-screen frame of the primary display. + /// + /// # Errors + /// + /// Returns [`CaptureError`] if the backend fails to capture the screen. + fn grab_screen(&mut self) -> Result; + + /// Grab a frame cropped to the given window's current bounds. Backend + /// may re-resolve the window by id; if the window has moved/resized + /// between enumeration and grab, the returned `Frame.bounds` reflects + /// the actual capture, not the stale descriptor bounds. + /// + /// # Errors + /// + /// Returns [`CaptureError`] if the window is not found or capture fails. + fn grab_window(&mut self, window: &WindowDescriptor) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_id_primary_is_zero() { + assert_eq!(DisplayId::PRIMARY, DisplayId(0)); + } + + #[test] + fn window_descriptor_is_clone_eq() { + let a = WindowDescriptor { + id: 42, + app: "Safari".into(), + title: "YouTube".into(), + bounds: Rect { + x: 0, + y: 0, + w: 800, + h: 600, + }, + window_index: WindowIndex(0), + display: DisplayId::PRIMARY, + }; + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn capture_trait_is_object_safe() { + // If this compiles, `dyn Capture` works — required for scheduler injection. + fn _takes_dyn(_c: &mut dyn Capture) {} + } +} diff --git a/crates/vcli-capture/src/error.rs b/crates/vcli-capture/src/error.rs new file mode 100644 index 0000000..5efee7f --- /dev/null +++ b/crates/vcli-capture/src/error.rs @@ -0,0 +1,99 @@ +//! Error types for the capture crate. +//! +//! The runtime maps `PermissionDenied` to event `capture.permission_missing` +//! (spec §Events) and `capture_failed` to the ipc error code of the same name. + +use thiserror::Error; + +/// All errors this crate produces. +#[derive(Debug, Error)] +pub enum CaptureError { + /// Screen Recording (TCC) permission is not granted. User must approve + /// in System Settings → Privacy & Security → Screen Recording. + #[error("screen recording permission not granted")] + PermissionDenied, + + /// The named window was not found on any display. + #[error("window not found: id={id}")] + WindowNotFound { + /// Opaque backend id from the lost descriptor. + id: u64, + }, + + /// Backend returned a frame the converter couldn't interpret (bad stride, + /// bad pixel format, zero-sized plane, …). + #[error("malformed frame from backend: {reason}")] + MalformedFrame { + /// Human-readable reason string. + reason: String, + }, + + /// Backend call timed out or failed mid-operation. Wraps the backend- + /// specific message so the runtime can surface it in `vcli health`. + #[error("backend failure: {message}")] + Backend { + /// Backend-provided message (SCK error description, HRESULT, etc.). + message: String, + }, + + /// The backend does not implement this method on the current platform. + #[error("capture operation unsupported on this backend: {what}")] + Unsupported { + /// Short description of what was attempted. + what: &'static str, + }, +} + +impl CaptureError { + /// Stable short string suitable for the ipc layer's error `code` field. + #[must_use] + pub fn code(&self) -> &'static str { + match self { + Self::PermissionDenied => "permission_denied", + Self::WindowNotFound { .. } => "unknown_window", + Self::MalformedFrame { .. } | Self::Backend { .. } => "capture_failed", + Self::Unsupported { .. } => "unsupported", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_messages_are_informative() { + let e = CaptureError::PermissionDenied; + assert_eq!(e.to_string(), "screen recording permission not granted"); + let e = CaptureError::WindowNotFound { id: 77 }; + assert_eq!(e.to_string(), "window not found: id=77"); + let e = CaptureError::MalformedFrame { + reason: "bad stride".into(), + }; + assert!(e.to_string().contains("bad stride")); + } + + #[test] + fn codes_are_stable() { + assert_eq!(CaptureError::PermissionDenied.code(), "permission_denied"); + assert_eq!( + CaptureError::WindowNotFound { id: 1 }.code(), + "unknown_window" + ); + assert_eq!( + CaptureError::Backend { + message: "x".into() + } + .code(), + "capture_failed" + ); + assert_eq!( + CaptureError::MalformedFrame { reason: "x".into() }.code(), + "capture_failed" + ); + assert_eq!( + CaptureError::Unsupported { what: "x" }.code(), + "unsupported" + ); + } +} diff --git a/crates/vcli-capture/src/lib.rs b/crates/vcli-capture/src/lib.rs new file mode 100644 index 0000000..67232e2 --- /dev/null +++ b/crates/vcli-capture/src/lib.rs @@ -0,0 +1,33 @@ +//! vcli-capture — Capture trait, macOS `ScreenCaptureKit` backend, mock impl, Windows stub. +//! +//! See spec §v0 scope and §Architecture → crate responsibilities. All outputs +//! use `vcli-core` types (`Frame`, `FrameFormat`, `Rect`, `WindowIndex`). +//! +//! # Coordinate model +//! +//! Per Decision F1/4.3 capture produces physical pixels internally and emits +//! a `Frame` at logical (1x) resolution. AX coords, input coords, and DSL +//! coords all live in logical space; the one physical → logical conversion +//! happens inside this crate. + +#![deny(unsafe_code)] +#![deny(rust_2018_idioms)] +#![warn(missing_docs)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] + +pub mod capture; +pub mod error; +pub mod mock; +pub mod permission; + +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(windows)] +pub mod windows; + +pub use capture::{Capture, DisplayId, WindowDescriptor}; +pub use error::CaptureError; +pub use mock::MockCapture; +pub use permission::{check_screen_recording_permission, PermissionStatus}; diff --git a/crates/vcli-capture/src/macos/convert.rs b/crates/vcli-capture/src/macos/convert.rs new file mode 100644 index 0000000..7bfa2da --- /dev/null +++ b/crates/vcli-capture/src/macos/convert.rs @@ -0,0 +1,227 @@ +//! Pure BGRA / physical → logical converters. No FFI. Unit-tested with +//! synthetic buffers so platform-free CI exercises the pixel math. + +use std::sync::Arc; + +use vcli_core::frame::{Frame, FrameFormat}; +use vcli_core::geom::Rect; + +use crate::error::CaptureError; + +/// Parameters for a raw BGRA8 buffer emitted by a backend. +#[derive(Debug, Clone)] +pub struct RawBgra { + /// Physical-pixel width. + pub width_px: u32, + /// Physical-pixel height. + pub height_px: u32, + /// Row stride in bytes. May exceed `width_px * 4` for alignment padding. + pub stride: usize, + /// Physical pixel bytes. Length must be ≥ `stride * height_px`. + pub pixels: Vec, + /// Monotonic ns timestamp. + pub captured_at_ns: u64, + /// Factor to downsample to logical 1x. 2.0 for Retina, 1.0 for non-HiDPI. + pub scale: f32, + /// Logical origin (top-left of the captured region in logical desktop coords). + pub logical_origin_x: i32, + /// Logical origin y. + pub logical_origin_y: i32, +} + +/// Convert a raw BGRA physical-pixel buffer into a logical-resolution `Frame`. +/// +/// For `scale == 1.0` the bytes are wrapped with no copying aside from the +/// `Arc`. For `scale == 2.0` (Retina) we perform a simple 2:1 box-filter +/// downsample so templates authored at logical resolution match. +/// +/// # Errors +/// +/// Returns [`CaptureError::MalformedFrame`] if: +/// - `pixels.len() < stride * height_px` +/// - `stride < width_px * 4` +/// - `scale` not in {1.0, 2.0} (v0 supported scales; fractional DPI deferred) +pub fn bgra_to_frame(raw: RawBgra) -> Result { + let need = raw + .stride + .checked_mul(raw.height_px as usize) + .ok_or_else(|| CaptureError::MalformedFrame { + reason: "stride * height overflow".into(), + })?; + if raw.pixels.len() < need { + return Err(CaptureError::MalformedFrame { + reason: format!("buffer len {} < stride*height {}", raw.pixels.len(), need), + }); + } + if raw.stride < (raw.width_px as usize) * 4 { + return Err(CaptureError::MalformedFrame { + reason: format!( + "stride {} less than width*4 {}", + raw.stride, + raw.width_px * 4 + ), + }); + } + + let scale = raw.scale; + let (out_w, out_h, out_stride, out_pixels) = if (scale - 1.0_f32).abs() < f32::EPSILON { + #[allow(clippy::cast_possible_wrap)] + ( + raw.width_px as i32, + raw.height_px as i32, + raw.stride, + raw.pixels, + ) + } else if (scale - 2.0_f32).abs() < f32::EPSILON { + downsample_2x(&raw) + } else { + return Err(CaptureError::MalformedFrame { + reason: format!("unsupported scale {scale}"), + }); + }; + + let bounds = Rect { + x: raw.logical_origin_x, + y: raw.logical_origin_y, + w: out_w, + h: out_h, + }; + + let bytes: Arc<[u8]> = out_pixels.into(); + Ok(Frame::new( + FrameFormat::Bgra8, + bounds, + out_stride, + bytes, + raw.captured_at_ns, + )) +} + +/// 2:1 box-filter downsample of a BGRA buffer. Returns +/// `(out_w, out_h, out_stride, out_pixels)`. Output stride = `out_w * 4`. +fn downsample_2x(raw: &RawBgra) -> (i32, i32, usize, Vec) { + let out_w = (raw.width_px / 2) as usize; + let out_h = (raw.height_px / 2) as usize; + let out_stride = out_w * 4; + let mut out = vec![0u8; out_stride * out_h]; + for y in 0..out_h { + let src_y0 = y * 2; + let src_y1 = src_y0 + 1; + let row0 = + &raw.pixels[src_y0 * raw.stride..src_y0 * raw.stride + raw.width_px as usize * 4]; + let row1 = + &raw.pixels[src_y1 * raw.stride..src_y1 * raw.stride + raw.width_px as usize * 4]; + for x in 0..out_w { + let sx = x * 2 * 4; + let idx = y * out_stride + x * 4; + // Box average 4 source pixels per channel. + for c in 0..4 { + let sum = u32::from(row0[sx + c]) + + u32::from(row0[sx + 4 + c]) + + u32::from(row1[sx + c]) + + u32::from(row1[sx + 4 + c]); + #[allow(clippy::cast_possible_truncation)] + { + out[idx + c] = (sum / 4) as u8; + } + } + } + } + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + (out_w as i32, out_h as i32, out_stride, out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw(width: u32, height: u32, stride: usize, fill: u8, scale: f32) -> RawBgra { + RawBgra { + width_px: width, + height_px: height, + stride, + pixels: vec![fill; stride * height as usize], + captured_at_ns: 1, + scale, + logical_origin_x: 0, + logical_origin_y: 0, + } + } + + #[test] + fn scale_1_passes_through_dims() { + let r = raw(8, 4, 32, 0xAB, 1.0); + let f = bgra_to_frame(r).unwrap(); + assert_eq!(f.width(), 8); + assert_eq!(f.height(), 4); + assert_eq!(f.stride, 32); + assert_eq!(f.format, FrameFormat::Bgra8); + assert_eq!(f.pixels[0], 0xAB); + } + + #[test] + fn scale_2_halves_dims_and_preserves_uniform_color() { + let r = raw(8, 4, 32, 0x7F, 2.0); + let f = bgra_to_frame(r).unwrap(); + assert_eq!(f.width(), 4); + assert_eq!(f.height(), 2); + assert_eq!(f.stride, 16); + // Box-filter of constant color is the same color. + for b in f.pixels.iter() { + assert_eq!(*b, 0x7F); + } + } + + #[test] + fn scale_2_averages_two_different_pixels() { + // 4x2 physical, 2x1 logical. Left column = 100, right column = 200. + let mut buf = vec![0u8; 4 * 2 * 4]; + for y in 0..2 { + for x in 0..4 { + let v = if x < 2 { 100 } else { 200 }; + for c in 0..4 { + buf[y * 16 + x * 4 + c] = v; + } + } + } + let r = RawBgra { + width_px: 4, + height_px: 2, + stride: 16, + pixels: buf, + captured_at_ns: 0, + scale: 2.0, + logical_origin_x: 0, + logical_origin_y: 0, + }; + let f = bgra_to_frame(r).unwrap(); + // Output is 2x1. Left output pixel averages 4 physical pixels of value 100. + // Right output pixel averages 4 physical pixels of value 200. + assert_eq!(f.width(), 2); + assert_eq!(f.height(), 1); + assert_eq!(f.pixels[0], 100); + assert_eq!(f.pixels[4], 200); + } + + #[test] + fn too_small_buffer_errors() { + let mut r = raw(100, 100, 400, 0, 1.0); + r.pixels.truncate(50); + let e = bgra_to_frame(r).unwrap_err(); + assert_eq!(e.code(), "capture_failed"); + } + + #[test] + fn stride_less_than_width_times_4_errors() { + let r = raw(10, 2, 20, 0, 1.0); // stride 20 < 10*4 = 40 + let e = bgra_to_frame(r).unwrap_err(); + assert_eq!(e.code(), "capture_failed"); + } + + #[test] + fn fractional_scale_unsupported() { + let r = raw(10, 10, 40, 0, 1.5); + let e = bgra_to_frame(r).unwrap_err(); + assert_eq!(e.code(), "capture_failed"); + } +} diff --git a/crates/vcli-capture/src/macos/mod.rs b/crates/vcli-capture/src/macos/mod.rs new file mode 100644 index 0000000..ece08ee --- /dev/null +++ b/crates/vcli-capture/src/macos/mod.rs @@ -0,0 +1,6 @@ +//! macOS capture backend. Enabled on `target_os = "macos"` only. + +pub mod convert; +pub mod sck; + +pub use sck::MacCapture; diff --git a/crates/vcli-capture/src/macos/sck.rs b/crates/vcli-capture/src/macos/sck.rs new file mode 100644 index 0000000..15f1394 --- /dev/null +++ b/crates/vcli-capture/src/macos/sck.rs @@ -0,0 +1,376 @@ +//! macOS `ScreenCaptureKit` backend. Synchronous facade over the +//! `screencapturekit` crate (v1.5.4 on crates.io; plan cited "0.3" which does +//! not exist). `SCShareableContent::get()` is a blocking synchronous call; no +//! tokio runtime is needed. +//! +//! ## API wiring notes (v1.5.4 vs plan assumptions) +//! +//! | Plan assumption | Actual v1.5.4 API | +//! |---|---| +//! | `screencapturekit::util::has_permission()` | Does not exist; see `permission.rs` for CG extern | +//! | `SCContentFilter::new_with_display_excluding_windows` | `SCContentFilter::create().with_display(d).with_excluding_windows(&[]).build()` | +//! | `SCContentFilter::new_with_desktop_independent_window` | `SCContentFilter::create().with_window(w).build()` | +//! | `SCStreamConfiguration::new()` + setters returning `Result` | Builder pattern (infallible) | +//! | `SCScreenshotManager::capture_image_with_filter` | `SCScreenshotManager::capture_image(&filter, &config)` | +//! | `image.bgra_bytes()` | `image.rgba_data()` → RGBA `Vec` (stored as `Rgba8` frame) | +//! | `image.bytes_per_row()` | Not available; computed as `width * 4` | +//! | `display.scale_factor()` | Not available; scale is always 1.0 (`SCK` gives logical px) | +//! | `CGRect.origin.x`, `.size.width` | `CGRect.x`, `.width` (flat struct) | +//! | `window.owning_application()?.application_name().ok()` | `.application_name()` returns `String` directly | +//! +//! Coordinate model (Decision F1, 4.3): +//! `SCK` reports `CGRect` in logical points. We treat logical points == logical +//! pixels for v0 (macOS). Physical pixel downsample happens in `convert.rs` +//! using the display's scale factor. + +use std::collections::HashMap; +use std::sync::Mutex; + +use screencapturekit::{ + screenshot_manager::SCScreenshotManager, + shareable_content::{SCDisplay, SCShareableContent, SCWindow}, + stream::{ + configuration::{PixelFormat, SCStreamConfiguration}, + content_filter::SCContentFilter, + }, +}; + +use vcli_core::frame::{Frame, FrameFormat}; +use vcli_core::geom::Rect; +use vcli_core::region::WindowIndex; + +use crate::capture::{Capture, DisplayId, WindowDescriptor}; +use crate::error::CaptureError; +use crate::macos::convert::{bgra_to_frame, RawBgra}; +use crate::permission::{check_screen_recording_permission, PermissionStatus}; + +/// macOS `SCK`-backed `Capture` implementation. +pub struct MacCapture { + /// Cached `SCShareableContent` refreshed per-call. Behind a `Mutex` + /// because SCK types are `!Sync` on some versions. + last_content: Mutex>, +} + +impl MacCapture { + /// Construct. Does NOT probe permission (permission check is explicit + /// via `check_screen_recording_permission`). + /// + /// # Errors + /// + /// Returns [`CaptureError::PermissionDenied`] if at construction time the + /// TCC probe reports `Denied`, so the daemon can emit the correct event + /// before ever attempting a grab. + pub fn new() -> Result { + match check_screen_recording_permission()? { + PermissionStatus::Granted => Ok(Self { + last_content: Mutex::new(None), + }), + PermissionStatus::Denied | PermissionStatus::Unknown => { + Err(CaptureError::PermissionDenied) + } + } + } + + /// Refresh cached `SCShareableContent`. + fn refresh_content(&self) -> Result { + let content = SCShareableContent::get().map_err(|e| CaptureError::Backend { + message: format!("SCShareableContent::get: {e}"), + })?; + *self.last_content.lock().unwrap() = Some(content.clone()); + Ok(content) + } + + /// Primary display — first in the enumerated list. + fn primary_display(content: &SCShareableContent) -> Result { + content + .displays() + .into_iter() + .next() + .ok_or_else(|| CaptureError::Backend { + message: "no displays reported by SCShareableContent".into(), + }) + } + + /// Locate a window by its stable AX id in a fresh enumeration. + fn locate_window(&self, id: u64) -> Result { + let content = self.refresh_content()?; + content + .windows() + .into_iter() + .find(|w| u64::from(w.window_id()) == id) + .ok_or(CaptureError::WindowNotFound { id }) + } +} + +/// Compute `window_index` by grouping windows by `(app, title)`. +/// Exported for unit testing of the pure indexing logic. +pub(crate) fn assign_window_indices( + raw: Vec<(u64, String, String, Rect)>, +) -> Vec { + let mut counters: HashMap<(String, String), u32> = HashMap::new(); + let mut out = Vec::with_capacity(raw.len()); + for (id, app, title, bounds) in raw { + let idx = counters + .entry((app.clone(), title.clone())) + .and_modify(|n| *n += 1) + .or_insert(0); + out.push(WindowDescriptor { + id, + app, + title, + bounds, + window_index: WindowIndex(*idx), + display: DisplayId::PRIMARY, + }); + } + out +} + +/// Build `WindowDescriptor` list from SCK windows using `assign_window_indices`. +fn build_descriptors(windows: Vec) -> Vec { + let raw: Vec<(u64, String, String, Rect)> = windows + .into_iter() + .map(|w| { + let app = w + .owning_application() + .map(|a| a.application_name()) + .unwrap_or_default(); + let title = w.title().unwrap_or_default(); + let rect = w.frame(); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let bounds = Rect { + x: rect.x as i32, + y: rect.y as i32, + w: rect.width as i32, + h: rect.height as i32, + }; + (u64::from(w.window_id()), app, title, bounds) + }) + .collect(); + assign_window_indices(raw) +} + +/// Return current monotonic-ish timestamp in nanoseconds. +fn monotonic_ns() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + // The cast from u128 to u64 is intentional: we only need nanoseconds since + // the Unix epoch modulo 2^64, which won't wrap for ~584 years from 1970. + #[allow(clippy::cast_possible_truncation)] + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos() as u64) +} + +/// Map a `screencapturekit` error to a `CaptureError`. +fn map_sck_error(e: &screencapturekit::error::SCError, context: &str) -> CaptureError { + use screencapturekit::error::SCError; + match e { + SCError::PermissionDenied(_) | SCError::NoShareableContent(_) => { + CaptureError::PermissionDenied + } + _ => CaptureError::Backend { + message: format!("{context}: {e}"), + }, + } +} + +impl Capture for MacCapture { + fn supported_formats(&self) -> &[FrameFormat] { + // `ScreenCaptureKit` v1.5.4 `capture_image` returns RGBA data. + // We store it as `Rgba8`. + const FORMATS: &[FrameFormat] = &[FrameFormat::Rgba8]; + FORMATS + } + + fn enumerate_windows(&self) -> Result, CaptureError> { + let content = self.refresh_content()?; + let mut windows = content.windows(); + // Stable ordering: sort by window_id ascending (AX order ≈ creation time). + windows.sort_by_key(SCWindow::window_id); + Ok(build_descriptors(windows)) + } + + fn grab_screen(&mut self) -> Result { + let content = self.refresh_content()?; + let display = Self::primary_display(&content)?; + + let cfg = SCStreamConfiguration::new() + .with_width(display.width()) + .with_height(display.height()) + .with_pixel_format(PixelFormat::BGRA); + + let filter = SCContentFilter::create() + .with_display(&display) + .with_excluding_windows(&[]) + .build(); + + let image = SCScreenshotManager::capture_image(&filter, &cfg) + .map_err(|e| map_sck_error(&e, "SCScreenshotManager::capture_image"))?; + + #[allow(clippy::cast_possible_truncation)] + let width_px = image.width() as u32; + #[allow(clippy::cast_possible_truncation)] + let height_px = image.height() as u32; + // `rgba_data()` returns raw RGBA bytes; stride = width * 4. + let pixels = image + .rgba_data() + .map_err(|e| CaptureError::MalformedFrame { + reason: format!("CGImage::rgba_data: {e}"), + })?; + let stride = (width_px as usize) * 4; + + // `SCDisplay.width()`/`height()` report logical pixels; scale = 1.0. + // `CGDisplayScaleFactor` was removed from macOS 26 SDK. + let raw = RawBgra { + width_px, + height_px, + stride, + pixels, + captured_at_ns: monotonic_ns(), + scale: 1.0, + logical_origin_x: 0, + logical_origin_y: 0, + }; + + // `bgra_to_frame` produces `FrameFormat::Bgra8`, but since `rgba_data` + // gives us RGBA bytes, we override the format to `Rgba8`. + let mut frame = bgra_to_frame(raw)?; + frame.format = FrameFormat::Rgba8; + Ok(frame) + } + + fn grab_window(&mut self, window: &WindowDescriptor) -> Result { + let sck_window = self.locate_window(window.id)?; + let frame_rect = sck_window.frame(); + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss + )] + let width_px = frame_rect.width as u32; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let height_px = frame_rect.height as u32; + + let cfg = SCStreamConfiguration::new() + .with_width(width_px) + .with_height(height_px) + .with_pixel_format(PixelFormat::BGRA); + + let filter = SCContentFilter::create().with_window(&sck_window).build(); + + let image = SCScreenshotManager::capture_image(&filter, &cfg) + .map_err(|e| map_sck_error(&e, "capture_image"))?; + + #[allow(clippy::cast_possible_truncation)] + let img_w = image.width() as u32; + #[allow(clippy::cast_possible_truncation)] + let img_h = image.height() as u32; + let pixels = image + .rgba_data() + .map_err(|e| CaptureError::MalformedFrame { + reason: format!("CGImage::rgba_data: {e}"), + })?; + let stride = (img_w as usize) * 4; + + // `display_scale` is always 1.0 for SCK (logical px). + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let raw = RawBgra { + width_px: img_w, + height_px: img_h, + stride, + pixels, + captured_at_ns: monotonic_ns(), + scale: 1.0, + logical_origin_x: frame_rect.x as i32, + logical_origin_y: frame_rect.y as i32, + }; + + let mut frame = bgra_to_frame(raw)?; + frame.format = FrameFormat::Rgba8; + Ok(frame) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_descriptors_assigns_indices_per_app_title() { + // We can't construct real `SCWindows` in unit tests; cover the pure + // index-assignment logic instead. + let a: Vec = Vec::new(); + assert!(a.is_empty()); + } + + #[test] + fn supported_formats_contains_rgba8() { + let m = MacCapture { + last_content: Mutex::new(None), + }; + assert_eq!(m.supported_formats(), &[FrameFormat::Rgba8]); + } + + #[test] + fn indices_are_sequential_per_app_title() { + let raw = vec![ + ( + 1, + "Safari".into(), + "YouTube".into(), + Rect { + x: 0, + y: 0, + w: 10, + h: 10, + }, + ), + ( + 2, + "Safari".into(), + "YouTube".into(), + Rect { + x: 10, + y: 0, + w: 10, + h: 10, + }, + ), + ( + 3, + "Safari".into(), + "Mail".into(), + Rect { + x: 20, + y: 0, + w: 10, + h: 10, + }, + ), + ( + 4, + "Finder".into(), + "YouTube".into(), + Rect { + x: 30, + y: 0, + w: 10, + h: 10, + }, + ), + ]; + let d = assign_window_indices(raw); + assert_eq!(d[0].window_index, WindowIndex(0)); + assert_eq!(d[1].window_index, WindowIndex(1)); + assert_eq!(d[2].window_index, WindowIndex(0)); + assert_eq!(d[3].window_index, WindowIndex(0)); + } + + #[test] + fn monotonic_ns_advances() { + let a = monotonic_ns(); + std::thread::sleep(std::time::Duration::from_millis(2)); + let b = monotonic_ns(); + assert!(b > a); + } +} diff --git a/crates/vcli-capture/src/mock.rs b/crates/vcli-capture/src/mock.rs new file mode 100644 index 0000000..f89e462 --- /dev/null +++ b/crates/vcli-capture/src/mock.rs @@ -0,0 +1,302 @@ +//! `MockCapture` — canned frames + windows for deterministic tests. +//! +//! Used by vcli-runtime scenario harness, vcli-perception evaluator tests, +//! and the vcli-daemon ipc handler in its test suite. + +use std::sync::{Arc, Mutex}; + +use vcli_core::frame::{Frame, FrameFormat}; + +use crate::capture::{Capture, WindowDescriptor}; +use crate::error::CaptureError; + +/// Canned-data capture backend. Cycles through `screen_frames` on each +/// `grab_screen()`. `window_frames` lookup by window id. `enumerate_windows` +/// returns a clone of the configured list. Thread-safe via `Mutex`. +#[derive(Debug)] +pub struct MockCapture { + inner: Arc>, +} + +#[derive(Debug)] +struct MockInner { + #[allow(dead_code)] // stored for future multi-format support + formats: Vec, + windows: Vec, + screen_frames: Vec, + screen_cursor: usize, + window_frame_map: Vec<(u64, Vec)>, + window_cursors: Vec, + next_error: Option, +} + +impl MockCapture { + /// Empty mock — all calls return default / empty. For the "no programs, + /// nothing to capture" path. + #[must_use] + pub fn empty() -> Self { + Self::new(vec![FrameFormat::Bgra8], vec![], vec![]) + } + + /// Full constructor. + #[must_use] + pub fn new( + formats: Vec, + windows: Vec, + screen_frames: Vec, + ) -> Self { + let window_cursors = vec![0; windows.len()]; + Self { + inner: Arc::new(Mutex::new(MockInner { + formats, + windows, + screen_frames, + screen_cursor: 0, + window_frame_map: Vec::new(), + window_cursors, + next_error: None, + })), + } + } + + /// Set canned frames for a particular window id. Subsequent calls to + /// `grab_window` with that descriptor cycle through these frames. + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned. + pub fn set_window_frames(&self, window_id: u64, frames: Vec) { + let mut g = self.inner.lock().unwrap(); + if let Some((_, slot)) = g + .window_frame_map + .iter_mut() + .find(|(id, _)| *id == window_id) + { + *slot = frames; + } else { + g.window_frame_map.push((window_id, frames)); + } + } + + /// Arm the next call to any grab/enumerate method to fail with `e`. Used + /// to exercise error paths in consumer tests. Consumed by one failing call. + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned. + pub fn arm_error(&self, e: CaptureError) { + self.inner.lock().unwrap().next_error = Some(e); + } + + fn take_armed_error(inner: &mut MockInner) -> Option { + inner.next_error.take() + } +} + +impl Capture for MockCapture { + fn supported_formats(&self) -> &[FrameFormat] { + // Safety-ish: we hand out a leaked static slice derived from the + // current config by cloning into a boxed leak only once. To keep this + // simple and allocation-free on the hot path, we return a constant + // slice with the default format. + const DEFAULT: &[FrameFormat] = &[FrameFormat::Bgra8]; + DEFAULT + } + + fn enumerate_windows(&self) -> Result, CaptureError> { + let mut g = self.inner.lock().unwrap(); + if let Some(e) = Self::take_armed_error(&mut g) { + return Err(e); + } + Ok(g.windows.clone()) + } + + fn grab_screen(&mut self) -> Result { + let mut g = self.inner.lock().unwrap(); + if let Some(e) = Self::take_armed_error(&mut g) { + return Err(e); + } + if g.screen_frames.is_empty() { + return Err(CaptureError::Backend { + message: "MockCapture has no screen frames configured".into(), + }); + } + let idx = g.screen_cursor % g.screen_frames.len(); + g.screen_cursor = g.screen_cursor.wrapping_add(1); + Ok(g.screen_frames[idx].clone()) + } + + fn grab_window(&mut self, window: &WindowDescriptor) -> Result { + let mut g = self.inner.lock().unwrap(); + if let Some(e) = Self::take_armed_error(&mut g) { + return Err(e); + } + let pos = g + .window_frame_map + .iter() + .position(|(id, _)| *id == window.id); + let Some(pos) = pos else { + return Err(CaptureError::WindowNotFound { id: window.id }); + }; + let frames_len = g.window_frame_map[pos].1.len(); + if frames_len == 0 { + return Err(CaptureError::Backend { + message: format!("no frames for window id={}", window.id), + }); + } + while g.window_cursors.len() <= pos { + g.window_cursors.push(0); + } + let cursor = g.window_cursors[pos]; + let idx = cursor % frames_len; + g.window_cursors[pos] = cursor.wrapping_add(1); + Ok(g.window_frame_map[pos].1[idx].clone()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use vcli_core::geom::Rect; + use vcli_core::region::WindowIndex; + + use crate::capture::DisplayId; + + use super::*; + + fn tiny(format: FrameFormat, value: u8) -> Frame { + let bytes: Arc<[u8]> = vec![value; 4 * 4 * 2].into(); + Frame::new( + format, + Rect { + x: 0, + y: 0, + w: 4, + h: 2, + }, + 4 * 4, + bytes, + 0, + ) + } + + #[test] + fn empty_mock_returns_empty_windows() { + let m = MockCapture::empty(); + assert!(m.enumerate_windows().unwrap().is_empty()); + } + + #[test] + fn empty_mock_screen_grab_errors() { + let mut m = MockCapture::empty(); + let e = m.grab_screen().unwrap_err(); + assert_eq!(e.code(), "capture_failed"); + } + + #[test] + fn screen_frames_cycle_in_order() { + let frames = vec![tiny(FrameFormat::Bgra8, 1), tiny(FrameFormat::Bgra8, 2)]; + let mut m = MockCapture::new(vec![FrameFormat::Bgra8], vec![], frames); + let a = m.grab_screen().unwrap(); + let b = m.grab_screen().unwrap(); + let c = m.grab_screen().unwrap(); + assert_eq!(a.pixels[0], 1); + assert_eq!(b.pixels[0], 2); + assert_eq!(c.pixels[0], 1); // wraps + } + + #[test] + fn enumerate_returns_configured_windows() { + let w = WindowDescriptor { + id: 9, + app: "Finder".into(), + title: "Downloads".into(), + bounds: Rect { + x: 0, + y: 0, + w: 1000, + h: 600, + }, + window_index: WindowIndex(0), + display: DisplayId::PRIMARY, + }; + let m = MockCapture::new(vec![FrameFormat::Bgra8], vec![w.clone()], vec![]); + let got = m.enumerate_windows().unwrap(); + assert_eq!(got, vec![w]); + } + + #[test] + fn grab_window_uses_configured_frames() { + let w = WindowDescriptor { + id: 7, + app: "Safari".into(), + title: "YT".into(), + bounds: Rect { + x: 0, + y: 0, + w: 4, + h: 2, + }, + window_index: WindowIndex(0), + display: DisplayId::PRIMARY, + }; + let m = MockCapture::new(vec![FrameFormat::Bgra8], vec![w.clone()], vec![]); + m.set_window_frames(7, vec![tiny(FrameFormat::Bgra8, 42)]); + let mut m2 = m; + let f = m2.grab_window(&w).unwrap(); + assert_eq!(f.pixels[0], 42); + } + + #[test] + fn grab_window_unknown_id_errors() { + let w = WindowDescriptor { + id: 100, + app: "X".into(), + title: "Y".into(), + bounds: Rect { + x: 0, + y: 0, + w: 4, + h: 2, + }, + window_index: WindowIndex(0), + display: DisplayId::PRIMARY, + }; + let mut m = MockCapture::new(vec![FrameFormat::Bgra8], vec![], vec![]); + let e = m.grab_window(&w).unwrap_err(); + assert_eq!(e.code(), "unknown_window"); + } + + #[test] + fn armed_error_is_returned_once() { + let mut m = MockCapture::new( + vec![FrameFormat::Bgra8], + vec![], + vec![tiny(FrameFormat::Bgra8, 0)], + ); + m.arm_error(CaptureError::PermissionDenied); + assert_eq!(m.grab_screen().unwrap_err().code(), "permission_denied"); + // Second call succeeds because error was consumed. + assert!(m.grab_screen().is_ok()); + } + + #[test] + fn mock_is_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + #[test] + fn mock_works_through_dyn_capture() { + use crate::capture::Capture; + let mut m = MockCapture::new( + vec![FrameFormat::Bgra8], + vec![], + vec![tiny(FrameFormat::Bgra8, 9)], + ); + let dyn_cap: &mut dyn Capture = &mut m; + let f = dyn_cap.grab_screen().unwrap(); + assert_eq!(f.pixels[0], 9); + } +} diff --git a/crates/vcli-capture/src/permission.rs b/crates/vcli-capture/src/permission.rs new file mode 100644 index 0000000..219ac88 --- /dev/null +++ b/crates/vcli-capture/src/permission.rs @@ -0,0 +1,128 @@ +//! Screen Recording (TCC) permission probe. +//! +//! On macOS this calls `CGPreflightScreenCaptureAccess` and +//! `CGRequestScreenCaptureAccess` from the Core Graphics framework directly +//! via `extern "C"` links. On other OSes the result is always `Granted` +//! because there is no equivalent gating. +//! +//! Note: `screencapturekit` v1.5.4 (the actual published version; plan cited +//! "0.3" which does not exist on crates.io) does not expose a +//! `util::has_permission` helper. We link the Core Graphics symbols directly +//! instead, which is the idiomatic macOS approach. +#![allow(unsafe_code)] // Required for CGPreflightScreenCaptureAccess / CGRequestScreenCaptureAccess extern "C" links on macOS. + +use crate::error::CaptureError; + +/// Result of probing the OS for screen-recording permission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionStatus { + /// Permission granted — capture will succeed. + Granted, + /// Not yet granted. On macOS the daemon should emit + /// `capture.permission_missing { backend: "macos" }` per spec §Events. + Denied, + /// Backend is unable to determine the status (e.g., running under `nohup` + /// with no window server). Treated as denied by the scheduler but + /// surfaces to the user as a more specific diagnostic. + Unknown, +} + +/// Synchronously probe the OS for screen-recording permission. +/// +/// - macOS: calls `CGPreflightScreenCaptureAccess()`. Does not prompt. +/// Use `request_screen_recording_permission()` to trigger the system prompt. +/// - Other OSes: always returns `Granted`. +/// +/// # Errors +/// +/// Returns [`CaptureError::Backend`] only if the underlying FFI call fails +/// in a way that is not "permission denied" (very rare — e.g., window server +/// unreachable). +pub fn check_screen_recording_permission() -> Result { + cfg_if_macos() +} + +#[cfg(target_os = "macos")] +#[allow(clippy::unnecessary_wraps)] +fn cfg_if_macos() -> Result { + extern "C" { + fn CGPreflightScreenCaptureAccess() -> bool; + } + // SAFETY: CGPreflightScreenCaptureAccess is a stable macOS framework + // symbol with no preconditions beyond the process having a window + // server connection. It is safe to call from any thread. + let granted = unsafe { CGPreflightScreenCaptureAccess() }; + Ok(if granted { + PermissionStatus::Granted + } else { + PermissionStatus::Denied + }) +} + +#[cfg(not(target_os = "macos"))] +#[allow(clippy::unnecessary_wraps)] +fn cfg_if_macos() -> Result { + Ok(PermissionStatus::Granted) +} + +/// Request the user grant screen-recording permission. Triggers the macOS +/// system prompt if currently Denied. No-op on other OSes. +/// +/// # Errors +/// +/// Returns [`CaptureError::Backend`] if the request call fails. +pub fn request_screen_recording_permission() -> Result<(), CaptureError> { + do_request() +} + +#[cfg(target_os = "macos")] +#[allow(clippy::unnecessary_wraps)] +fn do_request() -> Result<(), CaptureError> { + extern "C" { + fn CGRequestScreenCaptureAccess() -> bool; + } + // SAFETY: CGRequestScreenCaptureAccess is a stable macOS framework + // symbol. We ignore the return value — the prompt is async from the + // user's perspective; the caller should re-probe after user action. + let _granted = unsafe { CGRequestScreenCaptureAccess() }; + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +#[allow(clippy::unnecessary_wraps)] +fn do_request() -> Result<(), CaptureError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_status_is_copy_eq() { + let a = PermissionStatus::Granted; + let b = a; + assert_eq!(a, b); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn non_macos_always_granted() { + let s = check_screen_recording_permission().unwrap(); + assert_eq!(s, PermissionStatus::Granted); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn non_macos_request_is_no_op() { + request_screen_recording_permission().unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_probe_does_not_panic() { + // Doesn't assert Granted/Denied — that's environment-dependent. + // Just ensures the FFI call wiring works without unwind. + let _ = check_screen_recording_permission().unwrap(); + } +} diff --git a/crates/vcli-capture/src/windows/mod.rs b/crates/vcli-capture/src/windows/mod.rs new file mode 100644 index 0000000..f65ad2b --- /dev/null +++ b/crates/vcli-capture/src/windows/mod.rs @@ -0,0 +1,67 @@ +//! Windows capture backend — v0.4 stub. Compiles so the workspace builds on +//! Windows CI, but every operation returns `Unsupported` with a clear message. + +use vcli_core::frame::{Frame, FrameFormat}; + +use crate::capture::{Capture, WindowDescriptor}; +use crate::error::CaptureError; + +/// Windows capture backend stub. +#[derive(Debug, Default)] +pub struct WindowsCapture; + +impl WindowsCapture { + /// Construct. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl Capture for WindowsCapture { + fn supported_formats(&self) -> &[FrameFormat] { + const FORMATS: &[FrameFormat] = &[FrameFormat::Bgra8]; + FORMATS + } + + fn enumerate_windows(&self) -> Result, CaptureError> { + Err(CaptureError::Unsupported { + what: "WindowsCapture::enumerate_windows (v0.4)", + }) + } + + fn grab_screen(&mut self) -> Result { + Err(CaptureError::Unsupported { + what: "WindowsCapture::grab_screen (v0.4)", + }) + } + + fn grab_window(&mut self, _window: &WindowDescriptor) -> Result { + Err(CaptureError::Unsupported { + what: "WindowsCapture::grab_window (v0.4)", + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enumerate_reports_unsupported() { + let w = WindowsCapture::new(); + assert_eq!(w.enumerate_windows().unwrap_err().code(), "unsupported"); + } + + #[test] + fn grab_screen_reports_unsupported() { + let mut w = WindowsCapture::new(); + assert_eq!(w.grab_screen().unwrap_err().code(), "unsupported"); + } + + #[test] + fn formats_always_includes_bgra() { + let w = WindowsCapture::new(); + assert!(w.supported_formats().contains(&FrameFormat::Bgra8)); + } +} diff --git a/crates/vcli-capture/tests/macos_sck_smoke.rs b/crates/vcli-capture/tests/macos_sck_smoke.rs new file mode 100644 index 0000000..e0f40c9 --- /dev/null +++ b/crates/vcli-capture/tests/macos_sck_smoke.rs @@ -0,0 +1,46 @@ +//! macOS-only smoke test that actually touches ScreenCaptureKit. +//! +//! Requires Screen Recording permission for the running process. Gated +//! behind `#[ignore]` so `cargo test` does not run it by default. Enable +//! manually with: `cargo test -p vcli-capture --test macos_sck_smoke -- --ignored` + +#![cfg(target_os = "macos")] + +use vcli_capture::{ + capture::Capture, + macos::MacCapture, + permission::{check_screen_recording_permission, PermissionStatus}, +}; + +#[test] +#[ignore] +fn enumerates_at_least_one_window() { + assert_eq!( + check_screen_recording_permission().unwrap(), + PermissionStatus::Granted, + "grant Screen Recording permission to the binary before running" + ); + let c = MacCapture::new().expect("construct MacCapture"); + let windows = c.enumerate_windows().expect("enumerate"); + // A running GUI macOS session always has the Dock / menubar process, so + // at least one window is guaranteed. + assert!(!windows.is_empty(), "expected at least one window"); +} + +#[test] +#[ignore] +fn grabs_a_nonempty_screen_frame() { + assert_eq!( + check_screen_recording_permission().unwrap(), + PermissionStatus::Granted + ); + let mut c = MacCapture::new().expect("construct MacCapture"); + let frame = c.grab_screen().expect("grab_screen"); + assert!(frame.width() > 0); + assert!(frame.height() > 0); + assert!(!frame.pixels.is_empty()); + assert!( + frame.pixels.iter().any(|b| *b != 0), + "captured a fully-black frame — usually means TCC is silently denying" + ); +}