Skip to content
Open
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
30 changes: 30 additions & 0 deletions crates/recording/src/instant_recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,10 @@ async fn build_instant_recording_actor(
#[cfg(not(target_os = "macos"))]
let max_capture_size = None;

#[cfg(target_os = "macos")]
let capture_window =
macos_capture_window(&inputs.capture_target, inputs.camera_feed.is_some());

let screen_source = ScreenCaptureConfig::<ScreenCaptureMethod>::init(
display,
crop_bounds,
Expand All @@ -1345,6 +1349,8 @@ async fn build_instant_recording_actor(
.ok_or_else(|| anyhow::anyhow!("Missing shareable content"))?,
#[cfg(target_os = "macos")]
inputs.excluded_windows,
#[cfg(target_os = "macos")]
capture_window,
)
.await
.context("screen capture init")?;
Expand Down Expand Up @@ -1554,10 +1560,34 @@ fn clamp_size(input: (u32, u32), max: (u32, u32)) -> (u32, u32) {
}
}

// macOS Instant mode has no camera compositing: the camera preview only reaches the
// video because the display capture records it, so a window-only capture would drop it.
#[cfg(target_os = "macos")]
fn macos_capture_window(
target: &ScreenCaptureTarget,
camera_attached: bool,
) -> Option<scap_targets::WindowId> {
target.window().filter(|_| !camera_attached)
}

#[cfg(test)]
mod tests {
use super::*;

#[cfg(target_os = "macos")]
#[test]
fn macos_window_target_captures_window_unless_camera_attached() {
let id: scap_targets::WindowId = "247".parse().unwrap();
let window = ScreenCaptureTarget::Window { id: id.clone() };

assert_eq!(macos_capture_window(&window, false), Some(id));
assert_eq!(macos_capture_window(&window, true), None);
assert_eq!(
macos_capture_window(&ScreenCaptureTarget::CameraOnly, false),
None
);
}

#[cfg(target_os = "linux")]
#[test]
fn linux_presentation_builder_preserves_explicit_config_and_cli_default() {
Expand Down
169 changes: 152 additions & 17 deletions crates/recording/src/sources/screen_capture/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,50 @@ use tracing::{debug, error, info, warn};
const MAX_CAPTURE_RESTARTS: u32 = 3;
const RESTART_DELAY: Duration = Duration::from_secs(2);

// The display filter is what hides excluded windows, so an excluded selection must stay on
// the display-crop path instead of being captured on its own.
fn capturable_window<'a>(
config: &'a Config,
excluded_windows: &[WindowId],
capture_phase: &'static str,
) -> Option<&'a WindowId> {
let window_id = config.window.as_ref()?;

if excluded_windows.contains(window_id) {
warn!(
%window_id,
capture_phase,
"Capture window is excluded from recordings, using display crop with exclusions"
);
return None;
}

Some(window_id)
}

fn window_content_filter(
window_id: &WindowId,
content: &sc::ShareableContent,
capture_phase: &'static str,
) -> Option<arc::R<sc::ContentFilter>> {
let Ok(native_id) = window_id.to_string().parse::<u32>() else {
warn!(%window_id, capture_phase, "Capture window has an invalid native id");
return None;
};

let windows = content.windows();
let Some(window) = windows.iter().find(|window| window.id() == native_id) else {
warn!(
%window_id,
capture_phase,
"Capture window missing from ScreenCaptureKit shareable content, falling back to display crop"
);
return None;
};

Some(sc::ContentFilter::with_desktop_independent_window(window))
}

fn excluded_shareable_windows(
excluded_windows: &[WindowId],
content: &sc::ShareableContent,
Expand Down Expand Up @@ -219,16 +263,28 @@ impl ScreenCaptureConfig<CMSampleBufferCapture> {
let display = Display::from_id(&self.config.display)
.ok_or_else(|| SourceError::NoDisplay(self.config.display.clone()))?;

let excluded_sc_windows =
excluded_shareable_windows(&self.excluded_windows, &self.shareable_content, "initial");
let window_filter = capturable_window(&self.config, &self.excluded_windows, "initial")
.and_then(|id| window_content_filter(id, &self.shareable_content, "initial"));
let captures_window = window_filter.is_some();

let content_filter = match window_filter {
Some(filter) => filter,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Excluded windows get recorded

The new window filter ignores excluded_windows. A user can select an excluded app from the window dropdown because that list does not apply saved exclusions. Studio, or Instant without a camera, then records that window despite the “Hide windows from recordings” setting. Sharing the recording can expose its contents.

Check the selected ID against excluded_windows before creating the window filter. Apply the same check in rebuild_capturer, which also bypasses the exclusions.

How this was verified: Saved exclusions reach the capturer, but both window-filter branches use the selected window without checking them.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/sources/screen_capture/macos.rs
Line: 253

Comment:
**Excluded windows get recorded**

The new window filter ignores `excluded_windows`. A user can select an excluded app from the window dropdown because that list does not apply saved exclusions. Studio, or Instant without a camera, then records that window despite the “Hide windows from recordings” setting. Sharing the recording can expose its contents.

Check the selected ID against `excluded_windows` before creating the window filter. Apply the same check in `rebuild_capturer`, which also bypasses the exclusions.

**How this was verified:** Saved exclusions reach the capturer, but both window-filter branches use the selected window without checking them.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

None => {
let excluded_sc_windows = excluded_shareable_windows(
&self.excluded_windows,
&self.shareable_content,
"initial",
);

let content_filter = display
.raw_handle()
.as_content_filter_excluding_windows(
self.shareable_content.clone(),
excluded_sc_windows,
)
.ok_or(SourceError::AsContentFilter)?;
display
.raw_handle()
.as_content_filter_excluding_windows(
self.shareable_content.clone(),
excluded_sc_windows,
)
.ok_or(SourceError::AsContentFilter)?
}
};

debug!("SCK content filter: {:?}", content_filter);

Expand Down Expand Up @@ -257,7 +313,7 @@ impl ScreenCaptureConfig<CMSampleBufferCapture> {
settings.set_pixel_format(cv::PixelFormat::_420V);
settings.set_color_space_name(cg::color_space::names::srgb());

if let Some(crop_bounds) = self.config.crop_bounds {
if !captures_window && let Some(crop_bounds) = self.config.crop_bounds {
debug!("crop bounds: {:?}", crop_bounds);
settings.set_src_rect(cg::Rect::new(
crop_bounds.position().x(),
Expand Down Expand Up @@ -948,13 +1004,22 @@ async fn rebuild_capturer(params: &CapturerRebuildParams) -> anyhow::Result<Capt
let display = Display::from_id(&params.display_id)
.ok_or_else(|| anyhow!("Display not found during restart: {:?}", params.display_id))?;

let excluded_sc_windows =
excluded_shareable_windows(&params.excluded_windows, &shareable_content, "restart");
let window_filter = capturable_window(&params.config, &params.excluded_windows, "restart")
.and_then(|id| window_content_filter(id, &shareable_content, "restart"));
let captures_window = window_filter.is_some();

let content_filter = match window_filter {
Some(filter) => filter,
None => {
let excluded_sc_windows =
excluded_shareable_windows(&params.excluded_windows, &shareable_content, "restart");

let content_filter = display
.raw_handle()
.as_content_filter_excluding_windows(shareable_content, excluded_sc_windows)
.ok_or_else(|| anyhow!("Failed to create content filter during restart"))?;
display
.raw_handle()
.as_content_filter_excluding_windows(shareable_content, excluded_sc_windows)
.ok_or_else(|| anyhow!("Failed to create content filter during restart"))?
}
};

let size = PhysicalSize::new(
params.video_info.width as f64,
Expand All @@ -977,7 +1042,7 @@ async fn rebuild_capturer(params: &CapturerRebuildParams) -> anyhow::Result<Capt
settings.set_pixel_format(cv::PixelFormat::_420V);
settings.set_color_space_name(cg::color_space::names::srgb());

if let Some(crop_bounds) = params.config.crop_bounds {
if !captures_window && let Some(crop_bounds) = params.config.crop_bounds {
settings.set_src_rect(cg::Rect::new(
crop_bounds.position().x(),
crop_bounds.position().y(),
Expand Down Expand Up @@ -1324,3 +1389,73 @@ impl output_pipeline::AudioSource for SystemAudioSource {
self.inner.audio_info()
}
}

#[cfg(test)]
mod tests {
use super::*;

fn window_id(id: &str) -> WindowId {
id.parse().unwrap()
}

fn window_config(window: Option<WindowId>) -> Config {
Config {
display: "1".parse().unwrap(),
crop_bounds: None,
fps: 60,
show_cursor: true,
window,
}
}

#[test]
fn initial_capture_uses_window_filter_for_non_excluded_window() {
let config = window_config(Some(window_id("247")));
let excluded = vec![window_id("12"), window_id("99")];

assert_eq!(
capturable_window(&config, &excluded, "initial"),
Some(&window_id("247"))
);
}

#[test]
fn initial_capture_keeps_excluded_window_on_display_path() {
let config = window_config(Some(window_id("247")));
let excluded = vec![window_id("12"), window_id("247")];

assert_eq!(capturable_window(&config, &excluded, "initial"), None);
}

#[test]
fn restart_capture_keeps_excluded_window_on_display_path() {
let config = window_config(Some(window_id("247")));
let excluded = vec![window_id("247")];
let (restart_config, restart_excluded) = (config.clone(), excluded.clone());

assert_eq!(
capturable_window(&restart_config, &restart_excluded, "restart"),
None
);
}

#[test]
fn restart_capture_uses_window_filter_for_non_excluded_window() {
let config = window_config(Some(window_id("247")));
let excluded = vec![window_id("12")];
let (restart_config, restart_excluded) = (config.clone(), excluded.clone());

assert_eq!(
capturable_window(&restart_config, &restart_excluded, "restart"),
Some(&window_id("247"))
);
}

#[test]
fn non_window_targets_never_use_window_filter() {
let config = window_config(None);

assert_eq!(capturable_window(&config, &[], "initial"), None);
assert_eq!(capturable_window(&config, &[], "restart"), None);
}
}
5 changes: 5 additions & 0 deletions crates/recording/src/sources/screen_capture/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ pub struct Config {
show_cursor: bool,
#[cfg(target_os = "linux")]
linux_source: LinuxCaptureSource,
#[cfg(target_os = "macos")]
window: Option<WindowId>,
}

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -473,6 +475,7 @@ impl<TCaptureFormat: ScreenCaptureFormat> ScreenCaptureConfig<TCaptureFormat> {
#[cfg(windows)] d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device,
#[cfg(target_os = "macos")] shareable_content: SendableShareableContent,
#[cfg(target_os = "macos")] excluded_windows: Vec<WindowId>,
#[cfg(target_os = "macos")] window: Option<WindowId>,
) -> Result<Self, ScreenCaptureInitError> {
cap_fail::fail!("ScreenCaptureSource::init");

Expand Down Expand Up @@ -531,6 +534,8 @@ impl<TCaptureFormat: ScreenCaptureFormat> ScreenCaptureConfig<TCaptureFormat> {
show_cursor,
#[cfg(target_os = "linux")]
linux_source,
#[cfg(target_os = "macos")]
window,
},
video_info: VideoInfo::from_raw_ffmpeg(
TCaptureFormat::pixel_format(),
Expand Down
2 changes: 2 additions & 0 deletions crates/recording/src/studio_recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3292,6 +3292,8 @@ async fn create_segment_pipeline(
.ok_or_else(|| anyhow!("Missing shareable content"))?,
#[cfg(target_os = "macos")]
base_inputs.excluded_windows.clone(),
#[cfg(target_os = "macos")]
capture_target.window(),
)
.await
.context("screen capture init")?;
Expand Down
Loading