Protect selected windows from screen capture
Add capture privacy rules for windows, transient children, overview previews and client cursors, with separate capture rendering and deferred-frame invalidation. Keep detached close snapshots out of captures after clients leave the registry. Validated with release workspace tests, private compositor/EGL regressions, and an isolated release PipeWire capture test.
This commit is contained in:
1 parent
bffd538dd4
commit
453ec16583
24 files changed
+1132
-41
No files matched your search
@@ -5,7 +5,7 @@ use std::os::fd::AsFd;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod capture;
|
||||
pub(crate) mod capture;
|
||||
mod lid;
|
||||
mod mirror;
|
||||
mod output_power;
|
||||
@@ -251,6 +251,7 @@ struct DirectOutput {
|
||||
identity: String,
|
||||
mirror_source: Option<String>,
|
||||
mirror_texture: Option<GlesTexture>,
|
||||
mirror_capture_texture: Option<GlesTexture>,
|
||||
mirror_canvas: Option<Output>,
|
||||
mirror_id: smithay::backend::renderer::element::Id,
|
||||
mirror_commit: CommitCounter,
|
||||
@@ -1267,13 +1268,17 @@ fn render_output(
|
||||
primary.sync.wait()?;
|
||||
}
|
||||
|
||||
capture::capture_output(
|
||||
state,
|
||||
&mut device.renderer,
|
||||
&mut output.capture_texture,
|
||||
&output.output,
|
||||
&frame,
|
||||
)?;
|
||||
if output.mirror_source.is_some() {
|
||||
capture::capture_mirror(state, &mut device.renderer, &scene_output, &frame, &mut output)?;
|
||||
} else {
|
||||
capture::capture_output(
|
||||
state,
|
||||
&mut device.renderer,
|
||||
&mut output.capture_texture,
|
||||
&output.output,
|
||||
&frame,
|
||||
)?;
|
||||
}
|
||||
|
||||
if result.is_empty {
|
||||
output
|
||||
@@ -1511,6 +1516,7 @@ fn remove_device(state: &mut Ferese, node: DrmNode) {
|
||||
let context = device.renderer.context_id().erased();
|
||||
state.wallpaper.forget_context(&context);
|
||||
state.render.forget_context(&context);
|
||||
state.capture_render.forget_context(&context);
|
||||
for (crtc, mut output) in device.outputs {
|
||||
cancel_output_timers(&state.loop_handle, &mut output);
|
||||
if let Some(global) = output.global.take() {
|
||||
@@ -2027,6 +2033,7 @@ fn create_direct_output(
|
||||
identity,
|
||||
mirror_source,
|
||||
mirror_texture: None,
|
||||
mirror_capture_texture: None,
|
||||
mirror_canvas: None,
|
||||
mirror_id: smithay::backend::renderer::element::Id::new(),
|
||||
mirror_commit: CommitCounter::default(),
|
||||
|
||||
@@ -5,10 +5,10 @@ use smithay::backend::renderer::{Bind, Offscreen, Texture};
|
||||
use smithay::output::Output;
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::render::{redraw_output, sampled_output_elements};
|
||||
use crate::render::{capture_output_elements, redraw_output};
|
||||
use crate::state::FrameScene;
|
||||
|
||||
pub(super) fn capture_output(
|
||||
pub(crate) fn capture_output(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &mut Option<GlesTexture>,
|
||||
@@ -22,7 +22,7 @@ pub(super) fn capture_output(
|
||||
|
||||
// Sample the same scene into an independent target: the displayed
|
||||
// primary buffer may omit both a scanned-out client and hardware cursor.
|
||||
let elements = sampled_output_elements(state, renderer, output, include_cursor, scene);
|
||||
let elements = capture_output_elements(state, renderer, output, include_cursor, scene);
|
||||
let target = render_capture(renderer, texture, output, &elements)?;
|
||||
state.process_screencopies(renderer, &target, output, include_cursor);
|
||||
}
|
||||
@@ -30,6 +30,33 @@ pub(super) fn capture_output(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn capture_mirror(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
source: &Output,
|
||||
scene: &FrameScene,
|
||||
output: &mut super::DirectOutput,
|
||||
) -> Result<(), GlesError> {
|
||||
for include_cursor in [true, false] {
|
||||
if !state.has_pending_screencopy(&output.output, include_cursor) {
|
||||
continue;
|
||||
}
|
||||
let elements = capture_output_elements(state, renderer, source, include_cursor, scene);
|
||||
// Never sample mirror_texture: it contains the unfiltered display.
|
||||
let canvas = output.mirror_canvas.as_ref().expect("mirror display composed");
|
||||
super::mirror::compose(renderer, &mut output.mirror_capture_texture, canvas, source, &elements)?;
|
||||
let element = super::mirror::fitted_texture(
|
||||
output.mirror_capture_texture.as_ref().unwrap().clone(),
|
||||
&output.output,
|
||||
smithay::backend::renderer::element::Id::new(),
|
||||
Default::default(),
|
||||
);
|
||||
let target = render_capture(renderer, &mut output.capture_texture, &output.output, &[element])?;
|
||||
state.process_screencopies(renderer, &target, &output.output, include_cursor);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &'a mut Option<GlesTexture>,
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(super) fn elements(
|
||||
])
|
||||
}
|
||||
|
||||
fn compose(
|
||||
pub(super) fn compose(
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &mut Option<GlesTexture>,
|
||||
canvas: &Output,
|
||||
@@ -69,7 +69,7 @@ fn compose(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fitted_texture(
|
||||
pub(super) fn fitted_texture(
|
||||
texture: GlesTexture,
|
||||
target: &Output,
|
||||
id: smithay::backend::renderer::element::Id,
|
||||
|
||||
@@ -1016,7 +1016,7 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_and_validates_window_rules() {
|
||||
let config = parse(
|
||||
"window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 fullscreen=#false\n",
|
||||
"window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 fullscreen=#false block-out-from-screencasts=#true\n",
|
||||
);
|
||||
let rules = config.window_rules().unwrap();
|
||||
let result = window_rules::resolve(&rules, Some("org.example.editor.desktop"), Some("Document"), false);
|
||||
@@ -1026,6 +1026,7 @@ mod tests {
|
||||
assert_eq!(result.width, Some(900.0));
|
||||
assert_eq!(result.height, Some(600.0));
|
||||
assert_eq!(result.fullscreen, Some(false));
|
||||
assert_eq!(result.block_out_from_screencasts, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -52,6 +52,7 @@ impl SeatHandler for Ferese {
|
||||
.or_insert_with(|| crate::cursor::load_named_cursor(&self.cursor_theme, *icon));
|
||||
}
|
||||
self.cursor_status = image;
|
||||
self.update_capture_cursor_privacy();
|
||||
// Pointer focus transitions invoke this with the pointer lock held.
|
||||
// render_all -> cursor_elements -> current_location would try to
|
||||
// acquire that same lock, freezing the entire compositor thread.
|
||||
|
||||
@@ -560,9 +560,13 @@ impl Coordinator {
|
||||
}
|
||||
|
||||
pub(crate) fn terminate_all(&mut self) -> Vec<u64> {
|
||||
self.terminate_all_with_reason("Screenshot cancelled: the session was locked")
|
||||
}
|
||||
|
||||
pub(crate) fn terminate_all_with_reason(&mut self, reason: &str) -> Vec<u64> {
|
||||
let ids: Vec<u64> = self.requests.keys().copied().collect();
|
||||
for id in &ids {
|
||||
self.reject_inner(*id, "Screenshot cancelled: the session was locked");
|
||||
self.reject_inner(*id, reason);
|
||||
}
|
||||
ids
|
||||
}
|
||||
@@ -1029,6 +1033,26 @@ mod completion {
|
||||
assert!(harness.response().is_none(), "no late success is delivered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_change_cancels_encoding_and_discards_late_results() {
|
||||
let mut harness = Harness::new(1);
|
||||
let id = harness.coordinator.next_id;
|
||||
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
|
||||
assert!(harness.coordinator.is_encoding(id));
|
||||
assert_eq!(
|
||||
harness.coordinator.terminate_all_with_reason("Capture privacy changed"),
|
||||
vec![id]
|
||||
);
|
||||
assert!(harness.response().unwrap().error.is_some());
|
||||
assert!(matches!(
|
||||
harness
|
||||
.coordinator
|
||||
.on_encoded(id, Ok(PathBuf::from("/tmp/private.png"))),
|
||||
Action::DiscardFile(_)
|
||||
));
|
||||
assert!(harness.response().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_result_is_delivered_once() {
|
||||
let mut harness = Harness::new(1);
|
||||
|
||||
@@ -57,6 +57,7 @@ impl Drop for Permit {
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Snapshot {
|
||||
id: WindowId,
|
||||
epoch: u64,
|
||||
logical_size: (u32, u32),
|
||||
pixels: CaptureBuffer,
|
||||
_permit: Permit,
|
||||
@@ -159,7 +160,7 @@ fn finish_conversion(state: &Ferese, result: Conversion) {
|
||||
}
|
||||
if state.session_lock.active()
|
||||
|| !super::screencopy::capture_allowed()
|
||||
|| !state.windows.ids().values().any(|id| *id == snapshot.id)
|
||||
|| !state.capture_window_allowed(snapshot.id, snapshot.epoch)
|
||||
{
|
||||
*data.used.lock().unwrap() = true;
|
||||
frame.failed();
|
||||
@@ -273,6 +274,9 @@ impl Ferese {
|
||||
.find(|(_, candidate)| **candidate == id)?
|
||||
.0
|
||||
.clone();
|
||||
if self.capture_protected(&window) {
|
||||
return None;
|
||||
}
|
||||
let workspace = self.workspaces.workspace_for_window(id)?;
|
||||
let owner = self.output_workspaces.output_for_workspace(workspace)?;
|
||||
let output = self
|
||||
@@ -295,7 +299,7 @@ impl Ferese {
|
||||
*busy = true;
|
||||
return None;
|
||||
};
|
||||
let cursor = if overlay_cursor && !self.overview.is_presenting() {
|
||||
let cursor = if overlay_cursor && !self.overview.is_presenting() && !self.capture_cursor_protected() {
|
||||
self.seat.get_pointer().and_then(|pointer| {
|
||||
let location = pointer.current_location();
|
||||
if self.window_under_visual(location).as_ref() != Some(&window) {
|
||||
@@ -345,6 +349,7 @@ impl Ferese {
|
||||
}
|
||||
Some(Snapshot {
|
||||
id,
|
||||
epoch: self.capture_epoch,
|
||||
logical_size: (geometry.size.w as u32, geometry.size.h as u32),
|
||||
pixels,
|
||||
_permit: permit,
|
||||
@@ -365,7 +370,7 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
|
||||
};
|
||||
if state.session_lock.active()
|
||||
|| !super::screencopy::capture_allowed()
|
||||
|| !state.windows.ids().values().any(|id| *id == snapshot.id)
|
||||
|| !state.capture_window_allowed(snapshot.id, snapshot.epoch)
|
||||
{
|
||||
resource.failed();
|
||||
return;
|
||||
@@ -407,9 +412,96 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Exercise both asynchronous publication boundaries using real resources.
|
||||
pub(crate) fn check_deferred_snapshot(
|
||||
state: &Ferese,
|
||||
window: &smithay::desktop::Window,
|
||||
epoch: u64,
|
||||
allowed: bool,
|
||||
) {
|
||||
let client = window.toplevel().unwrap().wl_surface().client().unwrap();
|
||||
let id = state.windows.ids()[window];
|
||||
let budget = Arc::default();
|
||||
let snapshot = || Snapshot {
|
||||
id,
|
||||
epoch,
|
||||
logical_size: (64, 48),
|
||||
pixels: CaptureBuffer {
|
||||
width: 64,
|
||||
height: 48,
|
||||
stride: 256,
|
||||
pixels: vec![0; 64 * 48 * 4],
|
||||
},
|
||||
_permit: Permit::reserve(&budget, 64 * 48 * 4).unwrap(),
|
||||
};
|
||||
let frame_data = || {
|
||||
Arc::new(FrameData {
|
||||
output: None,
|
||||
region: Rectangle::from_size((64, 48).into()),
|
||||
overlay_cursor: false,
|
||||
used: Mutex::new(false),
|
||||
snapshot: Mutex::new(None),
|
||||
})
|
||||
};
|
||||
let (worker, _requests) = std::sync::mpsc::sync_channel(1);
|
||||
let manager = client
|
||||
.create_resource::<FereseWindowCaptureManagerV1, Global, Ferese>(
|
||||
&state.display_handle,
|
||||
1,
|
||||
Global {
|
||||
budget: budget.clone(),
|
||||
worker,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let data = frame_data();
|
||||
let frame = client
|
||||
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(&state.display_handle, 3, data.clone())
|
||||
.unwrap();
|
||||
finish_conversion(
|
||||
state,
|
||||
Conversion {
|
||||
snapshot: snapshot(),
|
||||
frame,
|
||||
manager,
|
||||
},
|
||||
);
|
||||
assert_eq!(data.snapshot.lock().unwrap().is_some(), allowed);
|
||||
assert_eq!(*data.used.lock().unwrap(), !allowed);
|
||||
|
||||
let data = frame_data();
|
||||
*data.snapshot.lock().unwrap() = Some(snapshot());
|
||||
let frame = client
|
||||
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(&state.display_handle, 3, data.clone())
|
||||
.unwrap();
|
||||
let buffer = client
|
||||
.object_from_protocol_id::<WlBuffer>(&state.display_handle, 6)
|
||||
.unwrap();
|
||||
let before = with_buffer_contents_mut(&buffer, |ptr, _, layout| {
|
||||
// SAFETY: the fixture allocates and validates this 64x48 SHM buffer.
|
||||
unsafe { std::slice::from_raw_parts(ptr.add(layout.offset as usize), 64 * 48 * 4).to_vec() }
|
||||
})
|
||||
.unwrap();
|
||||
copy_snapshot(state, &frame, &data, &buffer);
|
||||
with_buffer_contents_mut(&buffer, |ptr, _, layout| {
|
||||
// SAFETY: same fixture-owned buffer as above.
|
||||
let after = unsafe { std::slice::from_raw_parts(ptr.add(layout.offset as usize), 64 * 48 * 4) };
|
||||
if allowed {
|
||||
assert!(after.iter().all(|byte| *byte == 0));
|
||||
} else {
|
||||
assert_eq!(after, before, "rejected snapshot wrote private pixels");
|
||||
}
|
||||
// Restore the client's colored buffer for subsequent renderer checks.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(before.as_ptr(), ptr.add(layout.offset as usize), before.len());
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outstanding_snapshots_release_both_limits_when_dropped() {
|
||||
let budget = Arc::default();
|
||||
|
||||
@@ -34,6 +34,7 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
|
||||
fn title_changed(&mut self, surface: ToplevelSurface) {
|
||||
self.refresh_capture_privacy();
|
||||
if self.windows.refresh_metadata(surface.wl_surface()) {
|
||||
self.send_shell_snapshots();
|
||||
for output in self.surface_outputs(surface.wl_surface()) {
|
||||
@@ -43,6 +44,7 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
|
||||
fn app_id_changed(&mut self, surface: ToplevelSurface) {
|
||||
self.refresh_capture_privacy();
|
||||
if self.windows.refresh_metadata(surface.wl_surface()) {
|
||||
self.send_shell_snapshots();
|
||||
for output in self.surface_outputs(surface.wl_surface()) {
|
||||
@@ -52,6 +54,7 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
|
||||
fn parent_changed(&mut self, surface: ToplevelSurface) {
|
||||
self.refresh_capture_privacy();
|
||||
let Some(parent) = surface.parent() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -898,6 +898,10 @@ impl Ferese {
|
||||
reject("Window no longer exists".into());
|
||||
return;
|
||||
};
|
||||
if self.capture_protected(&window) {
|
||||
reject("Window is protected from capture".into());
|
||||
return;
|
||||
}
|
||||
let Some(output) = self
|
||||
.space
|
||||
.outputs()
|
||||
|
||||
@@ -1046,7 +1046,6 @@ impl OverviewMotion {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect {
|
||||
|
||||
self.presentations.get(&id).map_or(normal, |p| p.current)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ use overview::*;
|
||||
pub(crate) use resources::RenderResources;
|
||||
use scene::cursor_elements;
|
||||
pub(crate) use scene::{
|
||||
animated_window_elements, frame_effect_metrics, layer_surfaces, output_elements, sampled_output_elements,
|
||||
animated_window_elements, capture_output_elements, frame_effect_metrics, layer_surfaces, sampled_output_elements,
|
||||
};
|
||||
pub(crate) use shader::SharedPixelShaderElement;
|
||||
use smithay::backend::allocator::Fourcc;
|
||||
|
||||
@@ -91,6 +91,7 @@ pub(super) fn overview_strip_elements(
|
||||
output_geometry: Rectangle<i32, Logical>,
|
||||
scale: f64,
|
||||
frame: &crate::state::FrameScene,
|
||||
capture: bool,
|
||||
) -> Vec<AnimatedWindowRenderElement> {
|
||||
let alpha = frame.overview.opacity();
|
||||
if alpha <= 0.001 {
|
||||
@@ -139,7 +140,13 @@ pub(super) fn overview_strip_elements(
|
||||
// has a fresh seed; iterating it changes z indices and damages still captions.
|
||||
let caption_ids = state.windows.ordered_ids().rev().collect::<Vec<_>>();
|
||||
for id in caption_ids {
|
||||
if !state.window_belongs_to_output(id, output) {
|
||||
if (capture
|
||||
&& state
|
||||
.windows
|
||||
.window(id)
|
||||
.is_none_or(|window| state.capture_protected(window)))
|
||||
|| !state.window_belongs_to_output(id, output)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(rect) = frame.windows.get(&id).map(|sample| sample.presentation.bounds.current) else {
|
||||
@@ -222,6 +229,9 @@ pub(super) fn overview_strip_elements(
|
||||
}
|
||||
for (id, rect) in &card.windows {
|
||||
if let Some(window) = state.windows.window(*id) {
|
||||
if capture && state.capture_protected(window) {
|
||||
continue;
|
||||
}
|
||||
let shape = window_corner_shape(window);
|
||||
|
||||
if let Some(programs) = corner_program(&mut state.render, renderer, shape) {
|
||||
|
||||
@@ -31,6 +31,32 @@ pub(crate) fn sampled_output_elements(
|
||||
output: &Output,
|
||||
include_cursor: bool,
|
||||
frame: &crate::state::FrameScene,
|
||||
) -> Vec<AnimatedWindowRenderElement> {
|
||||
scene_elements(state, renderer, output, include_cursor, frame, false)
|
||||
}
|
||||
|
||||
pub(crate) fn capture_output_elements(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
output: &Output,
|
||||
include_cursor: bool,
|
||||
frame: &crate::state::FrameScene,
|
||||
) -> Vec<AnimatedWindowRenderElement> {
|
||||
// Blur textures and resize handoff snapshots must never be shared with
|
||||
// display rendering. Only the capture scene can populate these caches.
|
||||
std::mem::swap(&mut state.render, &mut state.capture_render);
|
||||
let elements = scene_elements(state, renderer, output, include_cursor, frame, true);
|
||||
std::mem::swap(&mut state.render, &mut state.capture_render);
|
||||
elements
|
||||
}
|
||||
|
||||
fn scene_elements(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
output: &Output,
|
||||
include_cursor: bool,
|
||||
frame: &crate::state::FrameScene,
|
||||
capture: bool,
|
||||
) -> Vec<AnimatedWindowRenderElement> {
|
||||
if state.session_lock.active() {
|
||||
let Some(geometry) = state.space.output_geometry(output) else {
|
||||
@@ -89,7 +115,8 @@ pub(crate) fn sampled_output_elements(
|
||||
};
|
||||
let scale = output.current_scale().fractional_scale();
|
||||
|
||||
let mut elements = if include_cursor {
|
||||
let protected_cursor = capture && state.capture_cursor_protected();
|
||||
let mut elements = if include_cursor && !protected_cursor {
|
||||
cursor_elements(state, renderer, output_geometry, scale)
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -108,12 +135,13 @@ pub(crate) fn sampled_output_elements(
|
||||
output_geometry,
|
||||
scale,
|
||||
frame,
|
||||
capture,
|
||||
));
|
||||
}
|
||||
let prepare_window = |window: &Window| {
|
||||
// Configure immediately, but present the frame/shadow only after
|
||||
// the first buffer commit has settled the actual client geometry.
|
||||
if !state.window_content_ready(window) {
|
||||
if (capture && state.capture_protected(window)) || !state.window_content_ready(window) {
|
||||
return None;
|
||||
}
|
||||
let id = *state.windows.ids().get(window)?;
|
||||
|
||||
@@ -333,6 +333,7 @@ impl SessionLockHandler for Ferese {
|
||||
// Replacing an orphan never passes through Unlocked. Existing protected
|
||||
// presentation facts remain valid, but old surfaces must not own input.
|
||||
self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation);
|
||||
self.capture_epoch = self.capture_epoch.wrapping_add(1);
|
||||
self.session_lock.surfaces.clear();
|
||||
self.cancel_logout_confirmation();
|
||||
self.refresh_idle_inhibition();
|
||||
@@ -473,6 +474,7 @@ mod tests {
|
||||
let mut event_loop = EventLoop::try_new().unwrap();
|
||||
let config = crate::config::Config::default().runtime_config().unwrap();
|
||||
let mut state = Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap();
|
||||
let capture_epoch = state.capture_epoch;
|
||||
|
||||
let acquire = |state: &mut Ferese| {
|
||||
let (server, mut wire) = UnixStream::pair().unwrap();
|
||||
@@ -496,6 +498,10 @@ mod tests {
|
||||
|
||||
let mut first = acquire(&mut state);
|
||||
dispatch(&mut event_loop, &mut state);
|
||||
assert_ne!(
|
||||
state.capture_epoch, capture_epoch,
|
||||
"lock revokes deferred window snapshots"
|
||||
);
|
||||
assert!(
|
||||
matches!(state.session_lock.lifecycle, Lifecycle::Locked(_)),
|
||||
"no outputs must confirm immediately"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod animation;
|
||||
mod capture_privacy;
|
||||
mod floating;
|
||||
mod focus;
|
||||
mod hit_testing;
|
||||
@@ -409,6 +410,10 @@ pub struct Ferese {
|
||||
output_identity_ids: HashMap<String, OutputId>,
|
||||
pub(crate) windows: WindowRegistry<Window>,
|
||||
pub(crate) render: crate::render::RenderResources,
|
||||
pub(crate) capture_render: crate::render::RenderResources,
|
||||
pub(crate) capture_epoch: u64,
|
||||
capture_protected_windows: HashSet<WindowId>,
|
||||
capture_protected_cursor: Option<WlSurface>,
|
||||
pub(crate) daemons: Option<std::rc::Rc<std::cell::RefCell<crate::daemon::Runner>>>,
|
||||
pub(crate) display_presentation: crate::display_presentation::DisplayPresentation,
|
||||
pub(crate) nested_backend: Option<NestedBackend>,
|
||||
@@ -642,6 +647,10 @@ impl Ferese {
|
||||
output_identity_ids: HashMap::new(),
|
||||
windows: WindowRegistry::default(),
|
||||
render: Default::default(),
|
||||
capture_render: Default::default(),
|
||||
capture_epoch: 0,
|
||||
capture_protected_windows: HashSet::new(),
|
||||
capture_protected_cursor: None,
|
||||
display_presentation: Default::default(),
|
||||
daemons: None,
|
||||
nested_backend: None,
|
||||
@@ -872,6 +881,7 @@ impl Ferese {
|
||||
}
|
||||
|
||||
if old_rules != self.window_rules {
|
||||
self.refresh_capture_privacy();
|
||||
self.reapply_window_rules(&old_rules);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
use smithay::desktop::Window;
|
||||
use smithay::wayland::compositor::with_states;
|
||||
use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn has_capture_exclusions(&self) -> bool {
|
||||
// Detached close snapshots outlive the registry's privacy metadata.
|
||||
// Keep them on the display and use the filtered scene for captures.
|
||||
!self.capture_protected_windows.is_empty()
|
||||
|| self.capture_protected_cursor.is_some()
|
||||
|| !self.render.closing.is_empty()
|
||||
}
|
||||
|
||||
/// Resolve at capture time: metadata changes must not wait for placement rules.
|
||||
/// Transient children inherit protection, including children on other outputs.
|
||||
pub(crate) fn capture_protected(&self, window: &Window) -> bool {
|
||||
let Some(toplevel) = window.toplevel() else {
|
||||
return true;
|
||||
};
|
||||
let mut surface = Some(toplevel.wl_surface().clone());
|
||||
let mut visited = Vec::new();
|
||||
while let Some(current) = surface {
|
||||
if visited.contains(¤t) {
|
||||
return true;
|
||||
}
|
||||
visited.push(current.clone());
|
||||
let (blocked, parent) = with_states(¤t, |states| {
|
||||
let Some(data) = states.data_map.get::<XdgToplevelSurfaceData>() else {
|
||||
return (true, None);
|
||||
};
|
||||
let data = data.lock().unwrap();
|
||||
let rule = crate::window_rules::resolve(
|
||||
&self.window_rules,
|
||||
data.app_id.as_deref(),
|
||||
data.title.as_deref(),
|
||||
data.parent.is_some(),
|
||||
);
|
||||
(rule.block_out_from_screencasts == Some(true), data.parent.clone())
|
||||
});
|
||||
if blocked {
|
||||
return true;
|
||||
}
|
||||
surface = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Called from cursor_image while the pointer mutex is held: do not query
|
||||
// pointer focus here. A cursor surface can outlive its protected window.
|
||||
pub(crate) fn update_capture_cursor_privacy(&mut self) {
|
||||
use smithay::input::pointer::CursorImageStatus;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
let CursorImageStatus::Surface(cursor) = &self.cursor_status else {
|
||||
self.capture_protected_cursor = None;
|
||||
return;
|
||||
};
|
||||
if self.capture_protected_cursor.as_ref() == Some(cursor) {
|
||||
return;
|
||||
}
|
||||
let protected = self.windows.ids().iter().any(|(window, id)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|top| top.wl_surface().id().same_client_as(&cursor.id()))
|
||||
&& (self.capture_protected_windows.contains(id) || self.capture_protected(window))
|
||||
});
|
||||
self.capture_protected_cursor = protected.then(|| cursor.clone());
|
||||
}
|
||||
|
||||
pub(crate) fn capture_cursor_protected(&self) -> bool {
|
||||
use smithay::input::pointer::CursorImageStatus;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::wayland::compositor::get_parent;
|
||||
// A cursor image is supplied by a client and can outlive pointer focus.
|
||||
// Conservatively conceal that client's custom cursor while any of its
|
||||
// windows is protected, including popup grabs outside the parent rect.
|
||||
if let CursorImageStatus::Surface(cursor) = &self.cursor_status {
|
||||
return self.capture_protected_cursor.as_ref() == Some(cursor)
|
||||
|| self.windows.ids().keys().any(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|top| top.wl_surface().id().same_client_as(&cursor.id()))
|
||||
&& self.capture_protected(window)
|
||||
});
|
||||
}
|
||||
let Some(mut focus) = self.seat.get_pointer().and_then(|pointer| pointer.current_focus()) else {
|
||||
return false;
|
||||
};
|
||||
while let Some(parent) = get_parent(&focus) {
|
||||
focus = parent;
|
||||
}
|
||||
if let Some(popup) = self.popups.find_popup(&focus)
|
||||
&& let Ok(root) = smithay::desktop::find_popup_root_surface(&popup)
|
||||
{
|
||||
focus = root;
|
||||
}
|
||||
self.windows
|
||||
.id_for_surface(&focus)
|
||||
.and_then(|id| self.windows.window(id))
|
||||
.is_some_and(|window| self.capture_protected(window))
|
||||
}
|
||||
|
||||
pub(crate) fn capture_window_allowed(&self, id: ferese_layout::WindowId, epoch: u64) -> bool {
|
||||
epoch == self.capture_epoch
|
||||
&& self
|
||||
.windows
|
||||
.window(id)
|
||||
.is_some_and(|window| !self.capture_protected(window))
|
||||
}
|
||||
|
||||
/// Deferred readbacks and encoders must not publish across a policy change,
|
||||
/// even if a subsequent change restores the original policy.
|
||||
pub(crate) fn refresh_capture_privacy(&mut self) {
|
||||
self.update_capture_cursor_privacy();
|
||||
let protected = self
|
||||
.windows
|
||||
.ids()
|
||||
.iter()
|
||||
.filter(|(window, _)| self.capture_protected(window))
|
||||
.map(|(_, id)| *id)
|
||||
.collect();
|
||||
if self.capture_protected_windows == protected {
|
||||
return;
|
||||
}
|
||||
self.capture_protected_windows = protected;
|
||||
self.update_capture_cursor_privacy();
|
||||
self.capture_epoch = self.capture_epoch.wrapping_add(1);
|
||||
self.screenshot
|
||||
.terminate_all_with_reason("Screenshot cancelled: capture privacy changed");
|
||||
// Drop cached blur pixels as well as their damage history.
|
||||
self.capture_render = Default::default();
|
||||
for output in self.space.outputs().cloned().collect::<Vec<_>>() {
|
||||
self.defer_output_redraw(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "capture_privacy_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,595 @@
|
||||
//! Private protocol clients and offscreen EGL; no host desktop or DRM required.
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::{AsRawFd, RawFd};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use smithay::backend::allocator::Fourcc;
|
||||
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
|
||||
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
|
||||
use smithay::backend::renderer::{Bind, ExportMem, Offscreen};
|
||||
use smithay::desktop::Window;
|
||||
use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel};
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase;
|
||||
use smithay::reexports::wayland_server::protocol::{wl_compositor::WlCompositor, wl_shm::WlShm};
|
||||
use smithay::reexports::wayland_server::{Display, Resource};
|
||||
use smithay::utils::{Rectangle, Transform};
|
||||
use smithay::wayland::compositor::with_states;
|
||||
use smithay::wayland::shell::xdg::{XdgShellHandler, XdgToplevelSurfaceData, XdgWmBaseUserData};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::state::{ClientState, DesktopOutput};
|
||||
|
||||
fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32], fd: Option<RawFd>) {
|
||||
let bytes = [object, (((args.len() + 2) * 4) as u32) << 16 | opcode]
|
||||
.into_iter()
|
||||
.chain(args.iter().copied())
|
||||
.flat_map(u32::to_ne_bytes)
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(fd) = fd {
|
||||
let mut iov = libc::iovec {
|
||||
iov_base: bytes.as_ptr().cast_mut().cast(),
|
||||
iov_len: bytes.len(),
|
||||
};
|
||||
let mut control = [0usize; 8];
|
||||
// SAFETY: aligned control storage and iovec remain live for sendmsg;
|
||||
// the one SCM_RIGHTS entry contains an owned, open tempfile descriptor.
|
||||
unsafe {
|
||||
let mut msg: libc::msghdr = std::mem::zeroed();
|
||||
msg.msg_iov = &mut iov;
|
||||
msg.msg_iovlen = 1;
|
||||
msg.msg_control = control.as_mut_ptr().cast();
|
||||
msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::<RawFd>() as u32) as usize;
|
||||
let header = libc::CMSG_FIRSTHDR(&msg);
|
||||
(*header).cmsg_level = libc::SOL_SOCKET;
|
||||
(*header).cmsg_type = libc::SCM_RIGHTS;
|
||||
(*header).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as usize;
|
||||
libc::CMSG_DATA(header).cast::<RawFd>().write(fd);
|
||||
assert_eq!(libc::sendmsg(wire.as_raw_fd(), &msg, 0), bytes.len() as isize);
|
||||
}
|
||||
} else {
|
||||
wire.write_all(&bytes).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(events: &mut EventLoop<'static, Ferese>, state: &mut Ferese) {
|
||||
events.dispatch(Duration::from_millis(1), state).unwrap();
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
}
|
||||
|
||||
fn ack_configure(wire: &mut UnixStream, xdg: u32) {
|
||||
wire.set_nonblocking(true).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let _ = wire.read_to_end(&mut bytes);
|
||||
wire.set_nonblocking(false).unwrap();
|
||||
let mut serial = None;
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
|
||||
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
|
||||
if object == xdg && header & 0xffff == 0 {
|
||||
serial = Some(u32::from_ne_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()));
|
||||
}
|
||||
offset += (header >> 16) as usize;
|
||||
}
|
||||
assert!(serial.is_some(), "missing configure for {xdg}: {bytes:?}");
|
||||
request(wire, xdg, 4, &[serial.unwrap()], None);
|
||||
}
|
||||
|
||||
fn window(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, color: u32) -> (Window, UnixStream) {
|
||||
let (server, mut wire) = UnixStream::pair().unwrap();
|
||||
let client = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(ClientState::default()))
|
||||
.unwrap();
|
||||
let compositor = client
|
||||
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
|
||||
.unwrap();
|
||||
let shell = client
|
||||
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(&state.display_handle, 6, Default::default())
|
||||
.unwrap();
|
||||
let shm = client
|
||||
.create_resource::<WlShm, (), Ferese>(&state.display_handle, 1, ())
|
||||
.unwrap();
|
||||
request(&mut wire, compositor.id().protocol_id(), 0, &[2], None);
|
||||
request(&mut wire, shell.id().protocol_id(), 2, &[3, 2], None);
|
||||
request(&mut wire, 3, 1, &[4], None);
|
||||
request(&mut wire, 2, 6, &[], None);
|
||||
dispatch(events, state);
|
||||
ack_configure(&mut wire, 3);
|
||||
let mut file = tempfile::tempfile().unwrap();
|
||||
file.write_all(&color.to_ne_bytes().repeat(64 * 48)).unwrap();
|
||||
request(
|
||||
&mut wire,
|
||||
shm.id().protocol_id(),
|
||||
0,
|
||||
&[5, 64 * 48 * 4],
|
||||
Some(file.as_raw_fd()),
|
||||
);
|
||||
request(&mut wire, 5, 0, &[6, 0, 64, 48, 64 * 4, 0], None);
|
||||
request(&mut wire, 3, 3, &[0, 0, 64, 48], None);
|
||||
request(&mut wire, 2, 1, &[6, 0, 0], None);
|
||||
request(&mut wire, 2, 2, &[0, 0, 64, 48], None);
|
||||
request(&mut wire, 2, 6, &[], None);
|
||||
dispatch(events, state);
|
||||
let window = state
|
||||
.windows
|
||||
.ids()
|
||||
.keys()
|
||||
.find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.unwrap()
|
||||
.wl_surface()
|
||||
.client()
|
||||
.is_some_and(|owner| owner.id() == client.id())
|
||||
})
|
||||
.expect("mapped window")
|
||||
.clone();
|
||||
(window, wire)
|
||||
}
|
||||
|
||||
fn attach_children(
|
||||
state: &mut Ferese,
|
||||
events: &mut EventLoop<'static, Ferese>,
|
||||
window: &Window,
|
||||
wire: &mut UnixStream,
|
||||
) {
|
||||
use smithay::reexports::wayland_server::protocol::wl_subcompositor::WlSubcompositor;
|
||||
let client = window.toplevel().unwrap().wl_surface().client().unwrap();
|
||||
let compositor = client
|
||||
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
|
||||
.unwrap();
|
||||
let subcompositor = client
|
||||
.create_resource::<WlSubcompositor, (), Ferese>(&state.display_handle, 1, ())
|
||||
.unwrap();
|
||||
request(wire, compositor.id().protocol_id(), 0, &[7], None);
|
||||
request(wire, subcompositor.id().protocol_id(), 1, &[8, 7, 2], None);
|
||||
request(wire, 8, 1, &[11, 11], None);
|
||||
request(wire, 7, 1, &[6, 0, 0], None);
|
||||
request(wire, 7, 6, &[], None);
|
||||
request(wire, 2, 6, &[], None);
|
||||
let shell = client
|
||||
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(&state.display_handle, 6, Default::default())
|
||||
.unwrap();
|
||||
request(wire, compositor.id().protocol_id(), 0, &[9], None);
|
||||
request(wire, shell.id().protocol_id(), 2, &[10, 9], None);
|
||||
request(wire, shell.id().protocol_id(), 1, &[11], None);
|
||||
request(wire, 11, 1, &[32, 16], None);
|
||||
request(wire, 11, 2, &[32, 16, 1, 1], None);
|
||||
request(wire, 10, 2, &[12, 3, 11], None);
|
||||
request(wire, 9, 6, &[], None);
|
||||
dispatch(events, state);
|
||||
ack_configure(wire, 10);
|
||||
request(wire, 10, 3, &[0, 0, 32, 16], None);
|
||||
request(wire, 9, 1, &[6, 0, 0], None);
|
||||
request(wire, 9, 6, &[], None);
|
||||
dispatch(events, state);
|
||||
assert_eq!(
|
||||
smithay::desktop::PopupManager::popups_for_surface(window.toplevel().unwrap().wl_surface()).count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
fn app_id(state: &mut Ferese, window: &Window, app: &str) {
|
||||
let top = window.toplevel().unwrap();
|
||||
with_states(top.wl_surface(), |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<XdgToplevelSurfaceData>()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.app_id = Some(app.into());
|
||||
});
|
||||
state.app_id_changed(top.clone());
|
||||
}
|
||||
|
||||
fn pixels(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
output: &Output,
|
||||
capture: bool,
|
||||
texture: &mut GlesTexture,
|
||||
) -> Vec<u8> {
|
||||
let scene = state.sample_frame(output, Duration::ZERO);
|
||||
if capture {
|
||||
let (sender, receiver) = smithay::reexports::calloop::channel::channel();
|
||||
state
|
||||
.pending_screencopies
|
||||
.push(crate::handlers::screencopy::PendingScreencopy::owned(
|
||||
1,
|
||||
0,
|
||||
sender,
|
||||
output.clone(),
|
||||
Rectangle::from_size((320, 240).into()),
|
||||
));
|
||||
let mut target = Some(texture.clone());
|
||||
crate::backends::direct::capture::capture_output(state, renderer, &mut target, output, &scene).unwrap();
|
||||
let mut result = receiver.try_recv().unwrap().result.unwrap().pixels;
|
||||
for pixel in result.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let elements = crate::render::sampled_output_elements(state, renderer, output, false, &scene);
|
||||
let mut target = renderer.bind(texture).unwrap();
|
||||
crate::render::redraw_output(renderer, &mut target, output, &elements).unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(&target, Rectangle::from_size((320, 240).into()), Fourcc::Abgr8888)
|
||||
.unwrap();
|
||||
renderer.map_texture(&mapping).unwrap().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an EGL device; uses a disposable runtime and private protocol clients"]
|
||||
fn capture_privacy_pixels_and_policy_transitions() {
|
||||
if std::env::var_os("FERESE_PRIVACY_TEST_CHILD").is_none() {
|
||||
let runtime = tempfile::tempdir().unwrap();
|
||||
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"state::capture_privacy::tests::capture_privacy_pixels_and_policy_transitions",
|
||||
"--ignored",
|
||||
"--nocapture",
|
||||
])
|
||||
.env("FERESE_PRIVACY_TEST_CHILD", "1")
|
||||
.env("FERESE_ENABLE_SCREENCOPY", "1")
|
||||
.env("XDG_RUNTIME_DIR", runtime.path())
|
||||
.env("XDG_CONFIG_HOME", runtime.path())
|
||||
.env("XDG_STATE_HOME", runtime.path())
|
||||
.env_remove("FERESE_SOCKET")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let config = crate::config::Config::parse_source(
|
||||
"animations { reduced-motion #true; }\nwindow-rule transient=#false floating=#true\n",
|
||||
)
|
||||
.unwrap()
|
||||
.runtime_config()
|
||||
.unwrap();
|
||||
let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap();
|
||||
let output = Output::new(
|
||||
"privacy".into(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
let desktop = DesktopOutput {
|
||||
output: output.clone(),
|
||||
identity: "privacy".into(),
|
||||
mode: Mode {
|
||||
size: (320, 240).into(),
|
||||
refresh: 60_000,
|
||||
},
|
||||
transform: Transform::Normal,
|
||||
scale: Scale::Fractional(1.0),
|
||||
position: (0, 0).into(),
|
||||
};
|
||||
state.begin_desktop_transition();
|
||||
let changes = state.publish_desktop(vec![desktop]).unwrap();
|
||||
state.finish_desktop_transition(changes);
|
||||
let (secret, mut secret_wire) = window(&mut state, &mut events, 0xffff0000);
|
||||
attach_children(&mut state, &mut events, &secret, &mut secret_wire);
|
||||
let id = state.windows.ids()[&secret];
|
||||
assert!(state.window_content_ready(&secret));
|
||||
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
|
||||
let display = unsafe { EGLDisplay::new(device).unwrap() };
|
||||
let context = EGLContext::new(&display).unwrap();
|
||||
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
|
||||
let mut display_texture =
|
||||
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap();
|
||||
let mut capture_texture =
|
||||
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap();
|
||||
let has_red = |bytes: &[u8]| bytes.chunks_exact(4).any(|pixel| pixel == [255, 0, 0, 255]);
|
||||
assert!(has_red(&pixels(
|
||||
&mut state,
|
||||
&mut renderer,
|
||||
&output,
|
||||
false,
|
||||
&mut display_texture
|
||||
)));
|
||||
assert!(has_red(&pixels(
|
||||
&mut state,
|
||||
&mut renderer,
|
||||
&output,
|
||||
true,
|
||||
&mut capture_texture
|
||||
)));
|
||||
let epoch = state.capture_epoch;
|
||||
assert!(!state.has_capture_exclusions());
|
||||
crate::handlers::window_capture::tests::check_deferred_snapshot(&state, &secret, epoch, true);
|
||||
app_id(&mut state, &secret, "ordinary");
|
||||
assert_eq!(
|
||||
state.capture_epoch, epoch,
|
||||
"unmatched metadata must not interrupt capture"
|
||||
);
|
||||
app_id(&mut state, &secret, "dev.ferese.Authentication");
|
||||
assert!(state.capture_protected(&secret));
|
||||
assert!(state.has_capture_exclusions());
|
||||
assert!(!state.capture_window_allowed(id, epoch));
|
||||
crate::handlers::window_capture::tests::check_deferred_snapshot(&state, &secret, epoch, false);
|
||||
assert!(
|
||||
!has_red(&pixels(&mut state, &mut renderer, &output, true, &mut capture_texture)),
|
||||
"reused capture target leaked previous pixels"
|
||||
);
|
||||
assert!(
|
||||
has_red(&pixels(&mut state, &mut renderer, &output, false, &mut display_texture)),
|
||||
"display lost protected content"
|
||||
);
|
||||
app_id(&mut state, &secret, "ordinary");
|
||||
assert!(
|
||||
!state.capture_window_allowed(id, epoch),
|
||||
"protect then unprotect must invalidate deferred frames"
|
||||
);
|
||||
assert!(state.capture_window_allowed(id, state.capture_epoch));
|
||||
crate::handlers::window_capture::tests::check_deferred_snapshot(&state, &secret, epoch, false);
|
||||
assert!(has_red(&pixels(
|
||||
&mut state,
|
||||
&mut renderer,
|
||||
&output,
|
||||
true,
|
||||
&mut capture_texture
|
||||
)));
|
||||
let rules =
|
||||
crate::config::Config::parse_source("window-rule app-id=\"ordinary\" block-out-from-screencasts=#true\n")
|
||||
.unwrap()
|
||||
.window_rules()
|
||||
.unwrap();
|
||||
state.window_rules = rules;
|
||||
state.refresh_capture_privacy();
|
||||
assert!(state.capture_protected(&secret));
|
||||
assert!(!has_red(&pixels(
|
||||
&mut state,
|
||||
&mut renderer,
|
||||
&output,
|
||||
true,
|
||||
&mut capture_texture
|
||||
)));
|
||||
for transform in [
|
||||
Transform::Normal,
|
||||
Transform::_90,
|
||||
Transform::_180,
|
||||
Transform::_270,
|
||||
Transform::Flipped,
|
||||
Transform::Flipped90,
|
||||
Transform::Flipped180,
|
||||
Transform::Flipped270,
|
||||
] {
|
||||
for scale in [1.0, 1.25] {
|
||||
output.change_current_state(None, Some(transform), Some(Scale::Fractional(scale)), None);
|
||||
assert!(
|
||||
has_red(&pixels(&mut state, &mut renderer, &output, false, &mut display_texture)),
|
||||
"display {transform:?}/{scale}"
|
||||
);
|
||||
assert!(
|
||||
!has_red(&pixels(&mut state, &mut renderer, &output, true, &mut capture_texture)),
|
||||
"capture {transform:?}/{scale}"
|
||||
);
|
||||
}
|
||||
}
|
||||
output.change_current_state(None, Some(Transform::Normal), Some(Scale::Fractional(1.0)), None);
|
||||
// An otherwise unprotected child still inherits its parent's protection.
|
||||
let (child, _child_wire) = window(&mut state, &mut events, 0xff00ff00);
|
||||
with_states(child.toplevel().unwrap().wl_surface(), |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<XdgToplevelSurfaceData>()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.parent = Some(secret.toplevel().unwrap().wl_surface().clone());
|
||||
});
|
||||
state.parent_changed(child.toplevel().unwrap().clone());
|
||||
assert!(state.capture_protected(&child));
|
||||
let captured = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
|
||||
assert!(!has_red(&captured));
|
||||
assert!(!captured.chunks_exact(4).any(|pixel| pixel == [0, 255, 0, 255]));
|
||||
|
||||
state.set_overview_active(true);
|
||||
let overview = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
|
||||
assert!(!has_red(&overview));
|
||||
assert!(!overview.chunks_exact(4).any(|pixel| pixel == [0, 255, 0, 255]));
|
||||
state.set_overview_active(false);
|
||||
|
||||
// Region requests go through the same filtered output readback.
|
||||
let (sender, receiver) = smithay::reexports::calloop::channel::channel();
|
||||
state
|
||||
.pending_screencopies
|
||||
.push(crate::handlers::screencopy::PendingScreencopy::owned(
|
||||
2,
|
||||
0,
|
||||
sender,
|
||||
output.clone(),
|
||||
Rectangle::new((32, 24).into(), (160, 120).into()),
|
||||
));
|
||||
let scene = state.sample_frame(&output, Duration::ZERO);
|
||||
crate::backends::direct::capture::capture_output(
|
||||
&mut state,
|
||||
&mut renderer,
|
||||
&mut Some(capture_texture.clone()),
|
||||
&output,
|
||||
&scene,
|
||||
)
|
||||
.unwrap();
|
||||
let region = receiver.try_recv().unwrap().result.unwrap();
|
||||
assert_eq!((region.width, region.height), (160, 120));
|
||||
assert!(
|
||||
!region
|
||||
.pixels
|
||||
.chunks_exact(4)
|
||||
.any(|pixel| pixel == [0, 0, 255, 255] || pixel == [0, 255, 0, 255])
|
||||
);
|
||||
|
||||
// A transient remains protected when moved to a different monitor.
|
||||
let second = Output::new(
|
||||
"second".into(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
let mut desktop = state.current_desktop_outputs();
|
||||
desktop.push(DesktopOutput {
|
||||
output: second.clone(),
|
||||
identity: "second".into(),
|
||||
mode: Mode {
|
||||
size: (320, 240).into(),
|
||||
refresh: 60_000,
|
||||
},
|
||||
transform: Transform::Normal,
|
||||
scale: Scale::Fractional(1.0),
|
||||
position: (400, 0).into(),
|
||||
});
|
||||
state.begin_desktop_transition();
|
||||
let changes = state.publish_desktop(desktop).unwrap();
|
||||
state.finish_desktop_transition(changes);
|
||||
let destination = state
|
||||
.output_workspaces
|
||||
.active_workspace(state.output_ids[&second])
|
||||
.unwrap();
|
||||
let child_id = state.windows.ids()[&child];
|
||||
state
|
||||
.workspaces
|
||||
.move_window_to_workspace(child_id, destination, ferese_layout::Axis::Horizontal, 0.5)
|
||||
.unwrap();
|
||||
state.relayout();
|
||||
let rect = ferese_layout::Rect::new(440.0, 40.0, 64.0, 48.0);
|
||||
state.workspaces.set_floating_rect(child_id, rect).unwrap();
|
||||
let mut geometry = ferese_animation::WindowGeometry::new(rect, None);
|
||||
geometry.client.committed_size = Some(ferese_animation::ClientSize::from_rect(rect));
|
||||
state.windows.set_geometry(child_id, geometry);
|
||||
state.space.map_element(child.clone(), (440, 40), false);
|
||||
assert!(
|
||||
pixels(&mut state, &mut renderer, &second, false, &mut display_texture)
|
||||
.chunks_exact(4)
|
||||
.any(|pixel| pixel == [0, 255, 0, 255])
|
||||
);
|
||||
assert!(
|
||||
!pixels(&mut state, &mut renderer, &second, true, &mut capture_texture)
|
||||
.chunks_exact(4)
|
||||
.any(|pixel| pixel == [0, 255, 0, 255])
|
||||
);
|
||||
|
||||
// Unprotected content still captures, including translucent pixels.
|
||||
let (public, mut public_wire) = window(&mut state, &mut events, 0x80000080);
|
||||
let captured = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
|
||||
assert!(captured.chunks_exact(4).any(|pixel| pixel[2] > 100));
|
||||
assert!(!has_red(&captured));
|
||||
|
||||
// A translucent material over the secret must never reuse display blur.
|
||||
use ferese_protocols::material::v1::server::ferese_material_manager_v1::FereseMaterialManagerV1;
|
||||
let client = public.toplevel().unwrap().wl_surface().client().unwrap();
|
||||
let manager = client
|
||||
.create_resource::<FereseMaterialManagerV1, (), Ferese>(&state.display_handle, 1, ())
|
||||
.unwrap();
|
||||
request(&mut public_wire, manager.id().protocol_id(), 1, &[7, 2], None);
|
||||
dispatch(&mut events, &mut state);
|
||||
state.theme_settings.material_style = crate::config::MaterialStyle::Translucent;
|
||||
state.theme_settings.backdrop_blur = 12.0;
|
||||
// Place the glass over the secret, preserving the protocol-owned buffer.
|
||||
let public_id = state.windows.ids()[&public];
|
||||
let secret_geometry = *state.windows.geometry(&id).unwrap();
|
||||
state.windows.set_geometry(public_id, secret_geometry);
|
||||
state
|
||||
.space
|
||||
.map_element(public.clone(), state.space.element_location(&secret).unwrap(), true);
|
||||
let before = pixels(&mut state, &mut renderer, &output, false, &mut display_texture);
|
||||
let protected = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
|
||||
state.space.unmap_elem(&secret);
|
||||
state.space.unmap_elem(&child);
|
||||
let absent = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
|
||||
assert_eq!(protected, absent, "protected content leaked through a cached material");
|
||||
let without = pixels(&mut state, &mut renderer, &output, false, &mut display_texture);
|
||||
assert_ne!(before, without, "fixture must place protected content behind glass");
|
||||
|
||||
// A stationary client's cursor is a separate surface and can outlive its
|
||||
// protected toplevel. Never reveal that retained image after an opt-out or close.
|
||||
use smithay::input::{SeatHandler, pointer::CursorImageStatus};
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
let client = secret.toplevel().unwrap().wl_surface().client().unwrap();
|
||||
let compositor = client
|
||||
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
|
||||
.unwrap();
|
||||
request(&mut secret_wire, compositor.id().protocol_id(), 0, &[13], None);
|
||||
request(&mut secret_wire, 13, 1, &[6, 0, 0], None);
|
||||
request(&mut secret_wire, 13, 6, &[], None);
|
||||
dispatch(&mut events, &mut state);
|
||||
let cursor = client
|
||||
.object_from_protocol_id::<WlSurface>(&state.display_handle, 13)
|
||||
.unwrap();
|
||||
let seat = state.seat.clone();
|
||||
state.cursor_image(&seat, CursorImageStatus::Surface(cursor.clone()));
|
||||
assert!(state.capture_cursor_protected());
|
||||
app_id(&mut state, &secret, "public-again");
|
||||
state.cursor_image(&seat, CursorImageStatus::Surface(cursor.clone()));
|
||||
assert!(state.capture_cursor_protected());
|
||||
let mut presentation = state.current_window_presentation(id).unwrap();
|
||||
presentation.close();
|
||||
let snapshot = crate::render::capture_resize_snapshot(
|
||||
&mut renderer,
|
||||
&secret,
|
||||
secret.geometry(),
|
||||
1.0,
|
||||
crate::presentation::SNAPSHOT_BUDGET,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
state.remove_tiled_window(&secret);
|
||||
assert!(state.capture_cursor_protected());
|
||||
assert!(matches!(&state.cursor_status, CursorImageStatus::Surface(current) if current == &cursor));
|
||||
state.cursor_image(&seat, CursorImageStatus::Hidden);
|
||||
assert!(!state.capture_cursor_protected());
|
||||
assert!(!state.has_capture_exclusions());
|
||||
|
||||
// A close snapshot remains visible after the protected client leaves the
|
||||
// registry. The nested readback shortcut must still use the filtered scene.
|
||||
state.space.unmap_elem(&public);
|
||||
state.space.unmap_elem(&child);
|
||||
state.render.closing.push(crate::render::ClosedWindow {
|
||||
presentation,
|
||||
output: state.output_ids[&output],
|
||||
below: None,
|
||||
snapshot,
|
||||
handoff: None,
|
||||
radius: 0.0,
|
||||
shape: crate::presentation::CornerShape::Continuous,
|
||||
decorations: 0.0,
|
||||
dim: 0.0,
|
||||
fill: None,
|
||||
material: None,
|
||||
});
|
||||
assert!(
|
||||
state.has_capture_exclusions(),
|
||||
"close snapshot bypassed capture filtering"
|
||||
);
|
||||
assert!(has_red(&pixels(
|
||||
&mut state,
|
||||
&mut renderer,
|
||||
&output,
|
||||
false,
|
||||
&mut display_texture
|
||||
)));
|
||||
assert!(!has_red(&pixels(
|
||||
&mut state,
|
||||
&mut renderer,
|
||||
&output,
|
||||
true,
|
||||
&mut capture_texture
|
||||
)));
|
||||
}
|
||||
@@ -101,6 +101,7 @@ impl Ferese {
|
||||
self.output_redraw_pending.retain(|pending| pending != output);
|
||||
if let Some(id) = self.output_ids.get(output) {
|
||||
self.render.remove_output(*id);
|
||||
self.capture_render.remove_output(*id);
|
||||
}
|
||||
self.pending_screencopies.retain(|capture| {
|
||||
if capture.output == *output {
|
||||
|
||||
@@ -116,6 +116,7 @@ impl Ferese {
|
||||
}
|
||||
|
||||
self.windows.register_window(window.clone(), id);
|
||||
self.refresh_capture_privacy();
|
||||
self.windows.update(id, |w| w.placement_anchor = Some(anchor));
|
||||
|
||||
if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() && remembered_work.is_none()
|
||||
@@ -157,6 +158,7 @@ impl Ferese {
|
||||
}
|
||||
|
||||
self.windows.register_window(window.clone(), id);
|
||||
self.refresh_capture_privacy();
|
||||
self.window_stack.insert(id);
|
||||
if focus_new_window {
|
||||
self.focused_window = Some(id);
|
||||
@@ -197,6 +199,7 @@ impl Ferese {
|
||||
}
|
||||
|
||||
self.windows.register_window(window.clone(), id);
|
||||
self.refresh_capture_privacy();
|
||||
self.window_stack.insert(id);
|
||||
if client_size(&window).is_none() {
|
||||
self.windows.update(id, |w| w.natural_floating_pending = true);
|
||||
@@ -417,6 +420,7 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn remove_tiled_window(&mut self, window: &Window) {
|
||||
self.update_capture_cursor_privacy();
|
||||
self.retain_closed_window(window);
|
||||
// Uncommitted toplevels have not entered a workspace yet.
|
||||
self.space.unmap_elem(window);
|
||||
@@ -424,11 +428,13 @@ impl Ferese {
|
||||
return;
|
||||
};
|
||||
|
||||
self.refresh_capture_privacy();
|
||||
self.focus_history.remove(id);
|
||||
#[cfg(feature = "resize-metrics")]
|
||||
self.resize_metrics
|
||||
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled);
|
||||
self.render.remove_window(id);
|
||||
self.capture_render.remove_window(id);
|
||||
self.window_stack.remove(id);
|
||||
self.floating_above_fullscreen.remove(&id);
|
||||
if let Err(error) = self.workspaces.remove_window(id) {
|
||||
|
||||
@@ -10,6 +10,7 @@ pub(crate) struct WindowRuleConfig {
|
||||
pub width: Option<f64>,
|
||||
pub height: Option<f64>,
|
||||
pub fullscreen: Option<bool>,
|
||||
pub block_out_from_screencasts: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -22,6 +23,7 @@ pub struct WindowRule {
|
||||
width: Option<f64>,
|
||||
height: Option<f64>,
|
||||
fullscreen: Option<bool>,
|
||||
block_out_from_screencasts: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
@@ -31,14 +33,18 @@ pub struct WindowRuleResult {
|
||||
pub width: Option<f64>,
|
||||
pub height: Option<f64>,
|
||||
pub fullscreen: Option<bool>,
|
||||
pub block_out_from_screencasts: Option<bool>,
|
||||
}
|
||||
|
||||
/// Only changed matches should override an existing window's manual state.
|
||||
pub(crate) fn live_result(
|
||||
old: WindowRuleResult,
|
||||
mut old: WindowRuleResult,
|
||||
mut new: WindowRuleResult,
|
||||
transient: bool,
|
||||
) -> Option<WindowRuleResult> {
|
||||
// Capture policy is resolved independently of placement at capture time.
|
||||
old.block_out_from_screencasts = None;
|
||||
new.block_out_from_screencasts = None;
|
||||
if old == new {
|
||||
return None;
|
||||
}
|
||||
@@ -70,6 +76,10 @@ pub fn resolve(rules: &[WindowRule], app_id: Option<&str>, title: Option<&str>,
|
||||
result.floating = Some(true);
|
||||
}
|
||||
|
||||
if app_id.as_deref() == Some("dev.ferese.authentication") {
|
||||
result.block_out_from_screencasts = Some(true);
|
||||
}
|
||||
|
||||
for rule in rules {
|
||||
let app_id_matches = rule
|
||||
.app_id
|
||||
@@ -86,6 +96,7 @@ pub fn resolve(rules: &[WindowRule], app_id: Option<&str>, title: Option<&str>,
|
||||
result.width = rule.width.or(result.width);
|
||||
result.height = rule.height.or(result.height);
|
||||
result.fullscreen = rule.fullscreen.or(result.fullscreen);
|
||||
result.block_out_from_screencasts = rule.block_out_from_screencasts.or(result.block_out_from_screencasts);
|
||||
}
|
||||
|
||||
result
|
||||
@@ -125,6 +136,7 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result<WindowRule, St
|
||||
width,
|
||||
height,
|
||||
fullscreen: rule.fullscreen,
|
||||
block_out_from_screencasts: rule.block_out_from_screencasts,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,9 +214,63 @@ mod tests {
|
||||
width: None,
|
||||
height: None,
|
||||
fullscreen: None,
|
||||
block_out_from_screencasts: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_privacy_defaults_overrides_and_placement_are_independent() {
|
||||
for app_id in ["dev.ferese.Authentication", "dev.ferese.Authentication.desktop"] {
|
||||
assert_eq!(
|
||||
resolve(&[], Some(app_id), None, false).block_out_from_screencasts,
|
||||
Some(true)
|
||||
);
|
||||
let rules = validate(&[WindowRuleConfig {
|
||||
block_out_from_screencasts: Some(false),
|
||||
..config(app_id)
|
||||
}])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolve(&rules, Some(app_id), None, false).block_out_from_screencasts,
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve(&[], Some("editor"), None, false).block_out_from_screencasts,
|
||||
None
|
||||
);
|
||||
let rules = validate(&[
|
||||
WindowRuleConfig {
|
||||
block_out_from_screencasts: Some(true),
|
||||
..config("editor")
|
||||
},
|
||||
WindowRuleConfig {
|
||||
title: Some("Public".into()),
|
||||
block_out_from_screencasts: Some(false),
|
||||
..config("editor")
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolve(&rules, Some("editor"), Some("Secret"), false).block_out_from_screencasts,
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&rules, Some("editor"), Some("Public"), false).block_out_from_screencasts,
|
||||
Some(false)
|
||||
);
|
||||
let old = WindowRuleResult {
|
||||
width: Some(800.),
|
||||
floating: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let new = WindowRuleResult {
|
||||
block_out_from_screencasts: Some(true),
|
||||
..old
|
||||
};
|
||||
assert_eq!(live_result(old, new, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_dialogs_float_by_default_and_allow_user_overrides() {
|
||||
for app_id in [
|
||||
@@ -279,6 +345,7 @@ mod tests {
|
||||
width: None,
|
||||
height: None,
|
||||
fullscreen: None,
|
||||
block_out_from_screencasts: None,
|
||||
};
|
||||
let invalid_size = WindowRuleConfig {
|
||||
width: Some(f64::NAN),
|
||||
|
||||
+21
-11
@@ -18,7 +18,7 @@ use smithay::wayland::presentation::Refresh;
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::metrics::{FrameEffectMetrics, RenderMetrics};
|
||||
use crate::render::{animated_window_elements, frame_effect_metrics, layer_surfaces, output_elements, redraw_output};
|
||||
use crate::render::{animated_window_elements, frame_effect_metrics, layer_surfaces};
|
||||
|
||||
pub(crate) type NestedBackend = Rc<RefCell<winit::WinitGraphicsBackend<GlesRenderer>>>;
|
||||
type DamageRenderResult = Result<
|
||||
@@ -89,6 +89,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
TimeoutAction::ToDuration(redraw_refresh.get())
|
||||
})?;
|
||||
|
||||
let mut capture_texture = None;
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_source(event_source, move |event, _, state| {
|
||||
@@ -140,16 +141,25 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
&elements,
|
||||
[0.035, 0.04, 0.055, 1.0],
|
||||
)?;
|
||||
let cursorless_capture = state.has_pending_screencopy(&output, false);
|
||||
let captured_with_cursor =
|
||||
state.process_screencopies(renderer, &framebuffer, &output, true);
|
||||
|
||||
if cursorless_capture {
|
||||
let cursorless_elements = output_elements(state, renderer, &output, false);
|
||||
redraw_output(renderer, &mut framebuffer, &output, &cursorless_elements)?;
|
||||
state.process_screencopies(renderer, &framebuffer, &output, false);
|
||||
redraw_output(renderer, &mut framebuffer, &output, &elements)?;
|
||||
} else if captured_with_cursor {
|
||||
// Keep the existing readback-only path when the
|
||||
// displayed scene has no privacy exclusions.
|
||||
let mut capture_changed_binding = !state.has_capture_exclusions()
|
||||
&& state.process_screencopies(renderer, &framebuffer, &output, true);
|
||||
if state.has_pending_screencopy(&output, false)
|
||||
|| state.has_pending_screencopy(&output, true)
|
||||
{
|
||||
let scene = state.sample_frame(&output, Duration::ZERO);
|
||||
crate::backends::direct::capture::capture_output(
|
||||
state,
|
||||
renderer,
|
||||
&mut capture_texture,
|
||||
&output,
|
||||
&scene,
|
||||
)?;
|
||||
capture_changed_binding = true;
|
||||
}
|
||||
if capture_changed_binding {
|
||||
// Restore the display framebuffer binding after offscreen readback.
|
||||
let _ = renderer
|
||||
.render(
|
||||
&mut framebuffer,
|
||||
|
||||
Reference in new issue
Block a user