Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
resolver = "2"
members = [
"crates/vcli-core",
"crates/vcli-capture",
"crates/vcli-dsl",
"crates/vcli-ipc",
"crates/vcli-perception",
Expand Down Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions crates/vcli-capture/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions crates/vcli-capture/build.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
99 changes: 99 additions & 0 deletions crates/vcli-capture/examples/capture_once.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
use std::env;
use vcli_capture::{
capture::Capture,
macos::MacCapture,
permission::{
check_screen_recording_permission, request_screen_recording_permission,
PermissionStatus,
},
};

let args: Vec<String> = 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<Rgba<u8>, Vec<u8>> = 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<dyn std::error::Error>> {
eprintln!("capture_once is only implemented for macOS in v0");
Ok(())
}
106 changes: 106 additions & 0 deletions crates/vcli-capture/src/capture.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<WindowDescriptor>, 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<Frame, CaptureError>;

/// 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<Frame, CaptureError>;
}

#[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) {}
}
}
Loading
Loading