Capture screenshots in the compositor instead of grim
This commit is contained in:
1 parent
4b70cb40e0
commit
eda453dabd
15 files changed
+2770
-171
No files matched your search
@@ -3,6 +3,8 @@ mod compositor;
|
||||
mod input_method;
|
||||
mod layer_shell;
|
||||
pub(crate) mod screencopy;
|
||||
pub(crate) mod screenshot;
|
||||
pub(crate) mod screenshot_worker;
|
||||
mod xdg_shell;
|
||||
|
||||
use smithay::{
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::screenshot::{CaptureBuffer, PartOutcome, PartSender};
|
||||
use smithay::{
|
||||
backend::{allocator::Fourcc, renderer::ExportMem},
|
||||
backend::{
|
||||
allocator::Fourcc,
|
||||
renderer::{ExportMem, TextureMapping},
|
||||
},
|
||||
output::Output,
|
||||
reexports::{
|
||||
wayland_protocols_wlr::screencopy::v1::server::{
|
||||
@@ -31,18 +36,120 @@ pub(crate) struct FrameData {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PendingScreencopy {
|
||||
pub(crate) frame: ZwlrScreencopyFrameV1,
|
||||
buffer: WlBuffer,
|
||||
sink: CaptureSink,
|
||||
pub(crate) output: Output,
|
||||
region: Rectangle<i32, Buffer>,
|
||||
overlay_cursor: bool,
|
||||
with_damage: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||
let enabled = std::env::var_os("FERESE_ENABLE_SCREENCOPY").is_some_and(|value| value == "1");
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum CaptureSink {
|
||||
Shm {
|
||||
frame: ZwlrScreencopyFrameV1,
|
||||
buffer: WlBuffer,
|
||||
},
|
||||
Owned {
|
||||
request: u64,
|
||||
part: usize,
|
||||
complete: PartSender,
|
||||
published: AtomicBool,
|
||||
},
|
||||
}
|
||||
|
||||
if enabled {
|
||||
impl CaptureSink {
|
||||
pub(crate) fn owned(request: u64, part: usize, complete: PartSender) -> Self {
|
||||
Self::Owned {
|
||||
request,
|
||||
part,
|
||||
complete,
|
||||
published: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
// Every part reaches exactly one terminal state: pixels or an error. A
|
||||
// cancelled part is still answered, otherwise its request would wait
|
||||
// forever for a part that can no longer arrive.
|
||||
pub(crate) fn fail(&self) {
|
||||
match self {
|
||||
CaptureSink::Shm { frame, .. } => frame.failed(),
|
||||
CaptureSink::Owned {
|
||||
request,
|
||||
part,
|
||||
complete,
|
||||
published,
|
||||
} => {
|
||||
if !published.swap(true, Ordering::AcqRel) {
|
||||
let _ = complete.send(PartOutcome {
|
||||
request: *request,
|
||||
part: *part,
|
||||
result: Err("Screenshot part failed".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot: a part publishes at most once, so a second completion can
|
||||
// never overwrite pixels that were already handed off.
|
||||
pub(crate) fn publish(&self, buffer: CaptureBuffer) -> bool {
|
||||
let CaptureSink::Owned {
|
||||
request,
|
||||
part,
|
||||
complete,
|
||||
published,
|
||||
} = self
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if published.swap(true, Ordering::AcqRel) {
|
||||
return false;
|
||||
}
|
||||
complete
|
||||
.send(PartOutcome {
|
||||
request: *request,
|
||||
part: *part,
|
||||
result: Ok(buffer),
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingScreencopy {
|
||||
pub(crate) fn owned(
|
||||
request: u64,
|
||||
part: usize,
|
||||
complete: PartSender,
|
||||
output: Output,
|
||||
region: Rectangle<i32, Buffer>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sink: CaptureSink::owned(request, part, complete),
|
||||
output,
|
||||
region,
|
||||
// The compositor-owned path deliberately omits the cursor.
|
||||
overlay_cursor: false,
|
||||
with_damage: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fail(&self) {
|
||||
self.sink.fail();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn capture_allowed() -> bool {
|
||||
static ALLOWED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ALLOWED
|
||||
.get_or_init(|| capture_opted_in(std::env::var_os("FERESE_ENABLE_SCREENCOPY").as_deref()))
|
||||
}
|
||||
|
||||
fn capture_opted_in(value: Option<&std::ffi::OsStr>) -> bool {
|
||||
value.is_some_and(|value| value == "1")
|
||||
}
|
||||
|
||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||
if capture_allowed() {
|
||||
display.create_global::<Ferese, ZwlrScreencopyManagerV1, ()>(3, ());
|
||||
tracing::info!("authorized screencopy is enabled for this session");
|
||||
}
|
||||
@@ -161,8 +268,10 @@ impl Ferese {
|
||||
};
|
||||
|
||||
self.pending_screencopies.push(PendingScreencopy {
|
||||
frame: frame.clone(),
|
||||
buffer,
|
||||
sink: CaptureSink::Shm {
|
||||
frame: frame.clone(),
|
||||
buffer,
|
||||
},
|
||||
output,
|
||||
region: data.region,
|
||||
overlay_cursor: data.overlay_cursor,
|
||||
@@ -192,14 +301,16 @@ impl Ferese {
|
||||
remaining.push(capture);
|
||||
continue;
|
||||
}
|
||||
if !capture.frame.is_alive() || !capture.buffer.is_alive() {
|
||||
if let CaptureSink::Shm { frame, buffer } = &capture.sink
|
||||
&& (!frame.is_alive() || !buffer.is_alive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
framebuffer_binding_changed = true;
|
||||
if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err()
|
||||
{
|
||||
capture.frame.failed();
|
||||
capture.fail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,17 +394,25 @@ fn capture_region(
|
||||
capture_region_for_geometry(mode.size, scale, transform, requested)
|
||||
}
|
||||
|
||||
fn capture_region_for_geometry(
|
||||
pub(crate) fn output_logical_size(
|
||||
mode_size: Size<i32, smithay::utils::Physical>,
|
||||
scale: f64,
|
||||
transform: smithay::utils::Transform,
|
||||
) -> Size<i32, Logical> {
|
||||
transform
|
||||
.transform_size(mode_size)
|
||||
.to_f64()
|
||||
.to_logical(scale)
|
||||
.to_i32_round()
|
||||
}
|
||||
|
||||
pub(crate) fn capture_region_for_geometry(
|
||||
mode_size: Size<i32, smithay::utils::Physical>,
|
||||
scale: f64,
|
||||
transform: smithay::utils::Transform,
|
||||
requested: Option<Rectangle<i32, Logical>>,
|
||||
) -> Option<Rectangle<i32, Buffer>> {
|
||||
let logical_size = transform
|
||||
.transform_size(mode_size)
|
||||
.to_f64()
|
||||
.to_logical(scale)
|
||||
.to_i32_round();
|
||||
let logical_size = output_logical_size(mode_size, scale, transform);
|
||||
let output_region = Rectangle::from_size(logical_size);
|
||||
let logical_region = requested
|
||||
.unwrap_or(output_region)
|
||||
@@ -342,50 +461,79 @@ where
|
||||
.map_err(|error| {
|
||||
tracing::warn!(?error, "failed to copy output framebuffer");
|
||||
})?;
|
||||
if mapping.format() != Fourcc::Argb8888 {
|
||||
return Err(());
|
||||
}
|
||||
let source = renderer.map_texture(&mapping).map_err(|error| {
|
||||
tracing::warn!(?error, "failed to map output framebuffer copy");
|
||||
})?;
|
||||
let expected = capture.region.size;
|
||||
let copied = with_buffer_contents_mut(&capture.buffer, |destination, length, data| {
|
||||
let bytes = expected.w as usize * expected.h as usize * BYTES_PER_PIXEL;
|
||||
if source.len() < bytes || data.offset < 0 || data.offset as usize + bytes > length {
|
||||
return false;
|
||||
}
|
||||
let stride = (expected.w as usize)
|
||||
.checked_mul(BYTES_PER_PIXEL)
|
||||
.ok_or(())?;
|
||||
let bytes = stride.checked_mul(expected.h as usize).ok_or(())?;
|
||||
if source.len() < bytes {
|
||||
return Err(());
|
||||
}
|
||||
// Smithay's output projection already accounts for OpenGL's Y axis, so these
|
||||
// rows must not be flipped vertically again. They are in output-buffer order
|
||||
// including the output transform, which the conversion stage normalizes.
|
||||
let copied = match &capture.sink {
|
||||
CaptureSink::Shm { buffer, .. } => {
|
||||
with_buffer_contents_mut(buffer, |destination, length, data| {
|
||||
let Ok(offset) = usize::try_from(data.offset) else {
|
||||
return false;
|
||||
};
|
||||
if offset > length || bytes > length - offset {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Smithay's output projection already accounts for OpenGL's Y axis.
|
||||
// These rows are in output-buffer order, including its output transform.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
source.as_ptr(),
|
||||
destination.add(data.offset as usize),
|
||||
bytes,
|
||||
);
|
||||
// SAFETY: the mapped slice is at least `bytes` long, and the
|
||||
// checks above establish `bytes` writable bytes at `offset`
|
||||
// within the shm buffer. The regions cannot overlap: one is
|
||||
// renderer-owned and the other client-owned.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(source.as_ptr(), destination.add(offset), bytes);
|
||||
}
|
||||
true
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
true
|
||||
})
|
||||
.map_err(|_| ())?;
|
||||
CaptureSink::Owned { .. } => {
|
||||
let mut pixels = Vec::new();
|
||||
if pixels.try_reserve_exact(bytes).is_err() {
|
||||
false
|
||||
} else {
|
||||
pixels.extend_from_slice(&source[..bytes]);
|
||||
capture.sink.publish(CaptureBuffer {
|
||||
width: expected.w,
|
||||
height: expected.h,
|
||||
stride,
|
||||
pixels,
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !copied {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
if capture.with_damage {
|
||||
capture
|
||||
.frame
|
||||
.damage(0, 0, expected.w as u32, expected.h as u32);
|
||||
}
|
||||
// TextureMapping::flipped describes importing the mapping as a texture;
|
||||
// using it here would make clients flip the already-correct output rows.
|
||||
capture
|
||||
.frame
|
||||
.flags(zwlr_screencopy_frame_v1::Flags::empty());
|
||||
if let CaptureSink::Shm { frame, .. } = &capture.sink {
|
||||
if capture.with_damage {
|
||||
frame.damage(0, 0, expected.w as u32, expected.h as u32);
|
||||
}
|
||||
// TextureMapping::flipped describes importing the mapping as a texture;
|
||||
// using it here would make clients flip the already-correct output rows.
|
||||
frame.flags(zwlr_screencopy_frame_v1::Flags::empty());
|
||||
|
||||
let seconds = timestamp.as_secs();
|
||||
capture.frame.ready(
|
||||
(seconds >> 32) as u32,
|
||||
seconds as u32,
|
||||
timestamp.subsec_nanos(),
|
||||
);
|
||||
let seconds = timestamp.as_secs();
|
||||
frame.ready(
|
||||
(seconds >> 32) as u32,
|
||||
seconds as u32,
|
||||
timestamp.subsec_nanos(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -394,6 +542,19 @@ mod tests {
|
||||
use super::*;
|
||||
use smithay::utils::{Physical, Transform};
|
||||
|
||||
#[test]
|
||||
fn capture_is_off_unless_the_opt_in_is_exactly_one() {
|
||||
use std::ffi::OsStr;
|
||||
|
||||
assert!(!capture_opted_in(None));
|
||||
assert!(!capture_opted_in(Some(OsStr::new("0"))));
|
||||
assert!(!capture_opted_in(Some(OsStr::new(""))));
|
||||
assert!(!capture_opted_in(Some(OsStr::new("true"))));
|
||||
assert!(!capture_opted_in(Some(OsStr::new("01"))));
|
||||
assert!(!capture_opted_in(Some(OsStr::new(" 1"))));
|
||||
assert!(capture_opted_in(Some(OsStr::new("1"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_region_clips_then_scales_to_buffer_coordinates() {
|
||||
let mode = Size::<i32, Physical>::from((1_920, 1_080));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,292 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use smithay::reexports::calloop::channel;
|
||||
|
||||
use super::screenshot::{Canvas, OutputFrame, compose};
|
||||
|
||||
const QUEUE_CAPACITY: usize = 4;
|
||||
/// A client normally opens and unlinks its screenshot immediately. If it dies
|
||||
/// first, the file is left behind, so staged files are swept well after any
|
||||
/// realistic request could still be reading one.
|
||||
const STAGE_TTL: Duration = Duration::from_secs(60 * 60);
|
||||
pub(crate) const SWEEP_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const DIR_MODE: u32 = 0o700;
|
||||
const FILE_MODE: u32 = 0o600;
|
||||
|
||||
pub(crate) struct Job {
|
||||
pub(crate) request: u64,
|
||||
pub(crate) frames: Vec<OutputFrame>,
|
||||
}
|
||||
|
||||
pub(crate) struct Encoded {
|
||||
pub(crate) request: u64,
|
||||
pub(crate) result: Result<PathBuf, String>,
|
||||
}
|
||||
|
||||
pub(crate) struct Worker {
|
||||
queue: SyncSender<Job>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub(crate) fn spawn(results: channel::SyncSender<Encoded>) -> Option<Self> {
|
||||
let (queue, receiver) = sync_channel::<Job>(QUEUE_CAPACITY);
|
||||
thread::Builder::new()
|
||||
.name("ferese-screenshot".to_owned())
|
||||
.spawn(move || run(receiver, results))
|
||||
.ok()
|
||||
.map(|_| Self { queue })
|
||||
}
|
||||
|
||||
// The compositor thread must never block, so a full queue is reported back
|
||||
// to the caller instead of waiting.
|
||||
pub(crate) fn submit(&self, job: Job) -> Result<(), Job> {
|
||||
match self.queue.try_send(job) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(TrySendError::Full(job)) | Err(TrySendError::Disconnected(job)) => Err(job),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(receiver: Receiver<Job>, results: channel::SyncSender<Encoded>) {
|
||||
while let Ok(job) = receiver.recv() {
|
||||
let result = encode(&job);
|
||||
// The loop drains this channel, so a full queue only delays the worker.
|
||||
if results
|
||||
.send(Encoded {
|
||||
request: job.request,
|
||||
result,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removes staged screenshots whose client never collected them. Only files
|
||||
// older than the TTL are touched, so an in-flight request is never disturbed.
|
||||
pub(crate) fn sweep_stale_files() {
|
||||
let Ok(directory) = private_directory() else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = fs::read_dir(&directory) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|extension| extension != "png") {
|
||||
continue;
|
||||
}
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
// A file dated in the future is treated as fresh rather than swept.
|
||||
let stale = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
|
||||
.is_some_and(|age| age > STAGE_TTL);
|
||||
if stale && fs::remove_file(&path).is_ok() {
|
||||
tracing::debug!(path = %path.display(), "removed an uncollected screenshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(job: &Job) -> Result<PathBuf, String> {
|
||||
let canvas = compose(&job.frames)?;
|
||||
let png = encode_png(&canvas)?;
|
||||
write_private_file(&png)
|
||||
}
|
||||
|
||||
fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
|
||||
let (width, height) = (canvas.width, canvas.height);
|
||||
// A final sanity check on the composed size before handing it to the
|
||||
// encoder, which would otherwise panic on a zero dimension.
|
||||
if width == 0 || height == 0 {
|
||||
return Err("Screenshot is empty".into());
|
||||
}
|
||||
let image = image::RgbaImage::from_raw(width, height, canvas.pixels.clone())
|
||||
.ok_or("Screenshot buffer does not match its dimensions")?;
|
||||
let mut out = Vec::new();
|
||||
let encoder = image::codecs::png::PngEncoder::new_with_quality(
|
||||
std::io::Cursor::new(&mut out),
|
||||
image::codecs::png::CompressionType::Fast,
|
||||
image::codecs::png::FilterType::Adaptive,
|
||||
);
|
||||
image::ImageEncoder::write_image(
|
||||
encoder,
|
||||
image.as_raw(),
|
||||
width,
|
||||
height,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)
|
||||
.map_err(|error| format!("Could not encode screenshot: {error}"))?;
|
||||
tracing::debug!(encoded = out.len(), "encoded screenshot");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// The client is told to open this path and unlink it immediately, so the
|
||||
// directory is ours, private, and files are created with the final mode.
|
||||
fn write_private_file(png: &[u8]) -> Result<PathBuf, String> {
|
||||
let directory = private_directory()?;
|
||||
for attempt in 0..64u32 {
|
||||
let path = directory.join(format!("shot-{}-{attempt}.png", std::process::id()));
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true).mode(FILE_MODE);
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
use io::Write;
|
||||
file.write_all(png)
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|error| format!("Could not write screenshot: {error}"))?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(format!("Could not write screenshot: {error}")),
|
||||
}
|
||||
}
|
||||
Err("Could not find an unused screenshot filename".into())
|
||||
}
|
||||
|
||||
fn private_directory() -> Result<PathBuf, String> {
|
||||
let runtime = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.ok_or("XDG_RUNTIME_DIR is not set, so screenshots cannot be staged")?;
|
||||
let root = PathBuf::from(runtime);
|
||||
let directory = root.join("ferese-screenshots");
|
||||
match fs::symlink_metadata(&directory) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err("Refusing to stage screenshots in a symlinked directory".into());
|
||||
}
|
||||
Ok(metadata) => {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } {
|
||||
return Err("Refusing to stage screenshots in an untrusted directory".into());
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
fs::create_dir(&directory)
|
||||
.and_then(|()| {
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(DIR_MODE))
|
||||
})
|
||||
.map_err(|error| format!("Could not create screenshot directory: {error}"))?;
|
||||
}
|
||||
Err(error) => return Err(format!("Could not inspect screenshot directory: {error}")),
|
||||
}
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use smithay::utils::Transform;
|
||||
|
||||
fn frame(width: i32, height: i32) -> OutputFrame {
|
||||
let stride = width as usize * 4;
|
||||
let mut pixels = vec![0u8; stride * height as usize];
|
||||
let (quads, _) = pixels.as_chunks_mut::<4>();
|
||||
for pixel in quads {
|
||||
pixel[3] = 255;
|
||||
}
|
||||
OutputFrame {
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
pixels,
|
||||
transform: Transform::Normal,
|
||||
location: (0, 0),
|
||||
scale: 1.0,
|
||||
logical_width: width,
|
||||
logical_height: height,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_produces_a_decodable_png() {
|
||||
let job = Job {
|
||||
request: 1,
|
||||
frames: vec![frame(4, 3)],
|
||||
};
|
||||
let canvas = compose(&job.frames).unwrap();
|
||||
let png = encode_png(&canvas).expect("png");
|
||||
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "png signature");
|
||||
|
||||
let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
|
||||
.expect("decodes back")
|
||||
.to_rgba8();
|
||||
assert_eq!(decoded.dimensions(), (4, 3));
|
||||
// The opaque alpha we force is preserved through the round trip.
|
||||
assert_eq!(decoded.get_pixel(0, 0)[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_files_are_owner_only() {
|
||||
let png = b"\x89PNG\r\n\x1a\nnot really a png".to_vec();
|
||||
let path = write_private_file(&png).expect("staged");
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(
|
||||
mode, FILE_MODE,
|
||||
"staged screenshots are not group/world readable"
|
||||
);
|
||||
fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sweep_keeps_fresh_files_and_ignores_other_types() {
|
||||
let png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let fresh = write_private_file(&png).expect("staged");
|
||||
|
||||
let directory = private_directory().unwrap();
|
||||
let other = directory.join("keep-me.txt");
|
||||
fs::write(&other, b"not a screenshot").unwrap();
|
||||
|
||||
sweep_stale_files();
|
||||
assert!(
|
||||
fresh.exists(),
|
||||
"a freshly staged screenshot is still in use"
|
||||
);
|
||||
assert!(other.exists(), "unrelated files are left alone");
|
||||
|
||||
fs::remove_file(&fresh).unwrap();
|
||||
fs::remove_file(&other).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sweep_removes_files_past_the_ttl() {
|
||||
let png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let path = write_private_file(&png).expect("staged");
|
||||
// Backdate it past the TTL without waiting an hour.
|
||||
let aged = SystemTime::now() - STAGE_TTL - Duration::from_secs(60);
|
||||
fs::File::options()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap()
|
||||
.set_modified(aged)
|
||||
.expect("backdate");
|
||||
|
||||
sweep_stale_files();
|
||||
assert!(!path.exists(), "an uncollected screenshot is reclaimed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_runtime_dir_is_reported_rather_than_ignored() {
|
||||
// private_directory must never fall back to /tmp or the cwd.
|
||||
let result = private_directory();
|
||||
match result {
|
||||
Ok(path) => assert!(
|
||||
path.ends_with("ferese-screenshots"),
|
||||
"screenshots are staged in the runtime dir, not a fallback location"
|
||||
),
|
||||
Err(error) => assert!(error.contains("XDG_RUNTIME_DIR") || error.contains("untrusted")),
|
||||
}
|
||||
}
|
||||
}
|
||||
+224
-5
@@ -11,18 +11,27 @@ use std::sync::{
|
||||
mpsc::{SyncSender, sync_channel},
|
||||
};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use ferese_core::LayoutMode;
|
||||
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
||||
use ferese_layout::Direction;
|
||||
use serde_json::{Value, json};
|
||||
use smithay::reexports::calloop::{EventLoop, LoopSignal, channel};
|
||||
use smithay::reexports::calloop::{EventLoop, LoopSignal, channel, timer};
|
||||
use smithay::utils::Transform;
|
||||
|
||||
use crate::handlers::screencopy;
|
||||
use crate::handlers::screenshot::{
|
||||
Action, Geometry, OutputLayout, PartOutcome, PartSender, parse_geometry, plan,
|
||||
};
|
||||
use crate::handlers::screenshot_worker::{self, Encoded, Job, Worker};
|
||||
use crate::{Ferese, config::OutputTransform};
|
||||
use smithay::output::Output;
|
||||
|
||||
const REQUEST_QUEUE_CAPACITY: usize = 128;
|
||||
const MAX_CONNECTIONS: usize = 64;
|
||||
const RESULT_QUEUE_CAPACITY: usize = 8;
|
||||
const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IpcCall {
|
||||
@@ -61,7 +70,7 @@ impl Drop for IpcSocketGuard {
|
||||
|
||||
pub(crate) fn init(
|
||||
event_loop: &mut EventLoop<'static, Ferese>,
|
||||
) -> Result<IpcSocketGuard, Box<dyn std::error::Error>> {
|
||||
) -> Result<ScreenshotInit, Box<dyn std::error::Error>> {
|
||||
let path = socket_path()?;
|
||||
prepare_parent(&path)?;
|
||||
let listener = bind_listener(&path)?;
|
||||
@@ -79,18 +88,70 @@ pub(crate) fn init(
|
||||
.handle()
|
||||
.insert_source(receiver, |event, _, state| {
|
||||
if let channel::Event::Msg(call) = event {
|
||||
let response = state.handle_ipc_request(call.request);
|
||||
let _ = call.response.send(response);
|
||||
if call.request.command == "screenshot" {
|
||||
// Deferred: this path answers the caller itself, exactly
|
||||
// once, whenever the request finishes or is terminated.
|
||||
state.start_screenshot(call.request, call.response);
|
||||
} else {
|
||||
let response = state.handle_ipc_request(call.request);
|
||||
let _ = call.response.send(response);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
// Screenshot readbacks publish parts here, so the loop wakes as soon as a
|
||||
// readback lands, whichever backend produced it.
|
||||
let (parts, part_events) = channel::channel::<PartOutcome>();
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_source(part_events, |event, _, state| {
|
||||
if let channel::Event::Msg(outcome) = event {
|
||||
state.on_screenshot_part(outcome);
|
||||
}
|
||||
})?;
|
||||
|
||||
let (encoded, encoded_events) = channel::sync_channel::<Encoded>(RESULT_QUEUE_CAPACITY);
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_source(encoded_events, |event, _, state| {
|
||||
if let channel::Event::Msg(result) = event {
|
||||
state.on_screenshot_encoded(result);
|
||||
}
|
||||
})?;
|
||||
let worker = Worker::spawn(encoded);
|
||||
|
||||
let signal = event_loop.get_signal();
|
||||
thread::Builder::new()
|
||||
.name("ferese-ipc-listener".to_owned())
|
||||
.spawn(move || accept_connections(listener, sender, signal))?;
|
||||
|
||||
// A client that dies between receiving a screenshot path and unlinking it
|
||||
// leaves the file behind, so staged files are reclaimed at startup and then
|
||||
// periodically. The TTL keeps this from disturbing a live request.
|
||||
screenshot_worker::sweep_stale_files();
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_source(timer::Timer::from_duration(SWEEP_INTERVAL), |_, _, _| {
|
||||
screenshot_worker::sweep_stale_files();
|
||||
timer::TimeoutAction::ToDuration(SWEEP_INTERVAL)
|
||||
})?;
|
||||
|
||||
tracing::info!(path = %path.display(), "Ferese IPC is accepting connections");
|
||||
Ok(guard)
|
||||
|
||||
let Some(worker) = worker else {
|
||||
return Err("Could not start the screenshot worker".into());
|
||||
};
|
||||
Ok(ScreenshotInit {
|
||||
parts,
|
||||
worker,
|
||||
_guard: guard,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct ScreenshotInit {
|
||||
pub(crate) parts: PartSender,
|
||||
pub(crate) worker: Worker,
|
||||
pub(crate) _guard: IpcSocketGuard,
|
||||
}
|
||||
|
||||
fn accept_connections(
|
||||
@@ -261,6 +322,152 @@ impl Ferese {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
// Screenshot replies are deferred: this either answers the caller now, or
|
||||
// hands the reply to the coordinator, which answers exactly once.
|
||||
pub(crate) fn start_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
|
||||
// A macro rather than a closure: the message may be a borrowed str or an
|
||||
// owned String, and Response::error already accepts either.
|
||||
macro_rules! reject {
|
||||
($code:expr, $message:expr) => {
|
||||
let _ = response.try_send(Response::error(request.id, $code, $message));
|
||||
};
|
||||
}
|
||||
if self.session_lock.active {
|
||||
reject!("session_locked", "IPC unavailable while session is locked");
|
||||
return;
|
||||
}
|
||||
if !screencopy::capture_allowed() {
|
||||
reject!(
|
||||
"capture_disabled",
|
||||
"Screen capture is disabled for this session; start it with \
|
||||
FERESE_ENABLE_SCREENCOPY=1 to enable screenshots"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = validate_request(&request) {
|
||||
reject!(error.code, error.message);
|
||||
return;
|
||||
}
|
||||
let geometry = match geometry_arg(&request.args) {
|
||||
Ok(geometry) => geometry,
|
||||
Err(message) => {
|
||||
reject!("invalid_argument", &message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if self.screenshot_parts.is_none() || self.screenshot_worker.is_none() {
|
||||
reject!("unavailable", "Screenshot capture is not available");
|
||||
return;
|
||||
}
|
||||
let Some(parts) = self.screenshot_parts.clone() else {
|
||||
reject!("unavailable", "Screenshot capture is not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// Snapshot the layout, so a later move, rescale, or transform cannot
|
||||
// change what this request captures.
|
||||
let targets: Vec<(Output, OutputLayout)> = self
|
||||
.space
|
||||
.outputs()
|
||||
.filter_map(|output| {
|
||||
let mode = output.current_mode()?;
|
||||
let geometry = self.space.output_geometry(output)?;
|
||||
Some((
|
||||
output.clone(),
|
||||
OutputLayout {
|
||||
mode_size: mode.size,
|
||||
scale: output.current_scale().fractional_scale(),
|
||||
transform: output.current_transform(),
|
||||
location: geometry.loc,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let layouts: Vec<OutputLayout> = targets.iter().map(|(_, layout)| layout.clone()).collect();
|
||||
let planned = match plan(&geometry, &layouts) {
|
||||
Ok(planned) => planned,
|
||||
Err(message) => {
|
||||
reject!("invalid_request", &message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let specs = planned
|
||||
.iter()
|
||||
.map(|part| part.spec(&layouts[part.index]))
|
||||
.collect();
|
||||
|
||||
// admit stores the reply only on success, so a clone survives the
|
||||
// rejection path and every caller is answered exactly once.
|
||||
let fallback = response.clone();
|
||||
let id = match self.screenshot.admit(request.id, response, specs) {
|
||||
Ok(id) => id,
|
||||
Err(message) => {
|
||||
let _ =
|
||||
fallback.try_send(Response::error(request.id, "screenshot_rejected", message));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for (position, part) in planned.iter().enumerate() {
|
||||
let (output, _) = &targets[part.index];
|
||||
self.pending_screencopies
|
||||
.push(crate::handlers::screencopy::PendingScreencopy::owned(
|
||||
id,
|
||||
position,
|
||||
parts.clone(),
|
||||
output.clone(),
|
||||
part.buffer,
|
||||
));
|
||||
}
|
||||
|
||||
// The nested backend redraws on its refresh timer; this drives the
|
||||
// direct backend immediately and is a no-op otherwise.
|
||||
crate::backends::direct::render_all(self);
|
||||
}
|
||||
|
||||
pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) {
|
||||
let action = self
|
||||
.screenshot
|
||||
.on_part(outcome.request, outcome.part, outcome.result);
|
||||
self.apply_screenshot_action(action);
|
||||
}
|
||||
|
||||
pub(crate) fn on_screenshot_encoded(&mut self, result: Encoded) {
|
||||
let action = self.screenshot.on_encoded(result.request, result.result);
|
||||
self.apply_screenshot_action(action);
|
||||
}
|
||||
|
||||
fn apply_screenshot_action(&mut self, action: Action) {
|
||||
match action {
|
||||
Action::None => {}
|
||||
Action::Encode { request, frames } => {
|
||||
let submitted = self
|
||||
.screenshot_worker
|
||||
.as_ref()
|
||||
.is_some_and(|worker| worker.submit(Job { request, frames }).is_ok());
|
||||
if !submitted {
|
||||
self.screenshot
|
||||
.reject(request, "Screenshot encoding queue is full");
|
||||
}
|
||||
}
|
||||
Action::Delivered(path) => {
|
||||
// The caller opens and unlinks it; the path was already sent.
|
||||
tracing::debug!(path = %path.display(), "delivered a screenshot");
|
||||
}
|
||||
Action::DiscardFile(path) => {
|
||||
if let Err(error) = fs::remove_file(&path)
|
||||
&& error.kind() != io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::debug!(
|
||||
%error,
|
||||
path = %path.display(),
|
||||
"failed to remove a staged screenshot"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn focused_window_json(&self) -> Value {
|
||||
self.focused_window
|
||||
.map(|window| json!({ "id": window.0 }))
|
||||
@@ -413,6 +620,18 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry_arg(args: &Value) -> Result<Geometry, String> {
|
||||
match args.get("geometry") {
|
||||
None | Some(Value::Null) => Ok(Geometry::All),
|
||||
Some(value) => {
|
||||
let text = value
|
||||
.as_str()
|
||||
.ok_or("geometry must be a string like \"x,y WxH\"")?;
|
||||
parse_geometry(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_request(request: &Request) -> Result<(), CommandError> {
|
||||
if request.version != VERSION {
|
||||
return Err(CommandError::new(
|
||||
|
||||
@@ -125,7 +125,16 @@ impl SessionLockHandler for Ferese {
|
||||
self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
|
||||
self.session_lock.confirmation = Some(confirmation);
|
||||
for capture in self.pending_screencopies.drain(..) {
|
||||
capture.frame.failed();
|
||||
capture.fail();
|
||||
}
|
||||
// Draining only covers requests that had not read back yet. One that is
|
||||
// already encoding has no pending capture left, so it is terminated
|
||||
// here and its staged file is discarded when the worker reports back.
|
||||
for request in self.screenshot.terminate_all() {
|
||||
tracing::debug!(
|
||||
request,
|
||||
"cancelled an in-flight screenshot for the session lock"
|
||||
);
|
||||
}
|
||||
self.set_overview_active(false);
|
||||
let serial = SERIAL_COUNTER.next_serial();
|
||||
|
||||
+14
-2
@@ -174,6 +174,12 @@ pub struct Ferese {
|
||||
_ipc_socket: Option<crate::ipc::IpcSocketGuard>,
|
||||
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>,
|
||||
pub(crate) pending_screencopies: Vec<crate::handlers::screencopy::PendingScreencopy>,
|
||||
// Screenshot requests outlive the readback that filled them: the
|
||||
// coordinator owns them until encoding finishes and the caller is
|
||||
// answered, or the request is terminated.
|
||||
pub(crate) screenshot: crate::handlers::screenshot::Coordinator,
|
||||
pub(crate) screenshot_parts: Option<crate::handlers::screenshot::PartSender>,
|
||||
pub(crate) screenshot_worker: Option<crate::handlers::screenshot_worker::Worker>,
|
||||
pub(crate) shell_resources: Vec<
|
||||
smithay::reexports::wayland_server::Weak<
|
||||
ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1,
|
||||
@@ -385,6 +391,9 @@ impl Ferese {
|
||||
_ipc_socket: None,
|
||||
pending_dmabuf_imports: Vec::new(),
|
||||
pending_screencopies: Vec::new(),
|
||||
screenshot: crate::handlers::screenshot::Coordinator::new(),
|
||||
screenshot_parts: None,
|
||||
screenshot_worker: None,
|
||||
shell_resources: Vec::new(),
|
||||
shell_snapshot_serial: 0,
|
||||
last_shell_snapshot: None,
|
||||
@@ -422,7 +431,10 @@ impl Ferese {
|
||||
xdg_shell_state,
|
||||
xdg_toplevel_icon_manager,
|
||||
};
|
||||
state._ipc_socket = Some(crate::ipc::init(event_loop)?);
|
||||
let screenshot = crate::ipc::init(event_loop)?;
|
||||
state.screenshot_parts = Some(screenshot.parts);
|
||||
state.screenshot_worker = Some(screenshot.worker);
|
||||
state._ipc_socket = Some(screenshot._guard);
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
@@ -572,7 +584,7 @@ impl Ferese {
|
||||
self.overview_scrims.remove(&output_id);
|
||||
self.pending_screencopies.retain(|capture| {
|
||||
if capture.output == *output {
|
||||
capture.frame.failed();
|
||||
capture.fail();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
|
||||
@@ -73,7 +73,7 @@ pub fn resolve(
|
||||
let mut result = WindowRuleResult::default();
|
||||
if matches!(
|
||||
app_id.as_deref(),
|
||||
Some("dev.ferese.authentication" | "dev.ferese.screenshare")
|
||||
Some("dev.ferese.authentication" | "dev.ferese.screenshare" | "dev.ferese.screenshot")
|
||||
) {
|
||||
result.floating = Some(true);
|
||||
}
|
||||
@@ -220,6 +220,7 @@ mod tests {
|
||||
"dev.ferese.ScreenShare",
|
||||
"dev.ferese.ScreenShare.desktop",
|
||||
"dev.ferese.Authentication",
|
||||
"dev.ferese.Screenshot",
|
||||
] {
|
||||
assert_eq!(resolve(&[], Some(app_id), None, false).floating, Some(true));
|
||||
let rules = validate(&[WindowRuleConfig {
|
||||
|
||||
Reference in new issue
Block a user