Capture screenshots in the compositor instead of grim

This commit is contained in:
Samuel Onoja committed 2026-09-29 15:51:56 +01:00
1 parent 4b70cb40e0
commit eda453dabd
15 files changed
+2727 -128

No files matched your search

+2
View File
@@ -3,6 +3,8 @@ mod compositor;
mod input_method; mod input_method;
mod layer_shell; mod layer_shell;
pub(crate) mod screencopy; pub(crate) mod screencopy;
pub(crate) mod screenshot;
pub(crate) mod screenshot_worker;
mod xdg_shell; mod xdg_shell;
use smithay::{ use smithay::{
+193 -32
View File
@@ -1,7 +1,12 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use super::screenshot::{CaptureBuffer, PartOutcome, PartSender};
use smithay::{ use smithay::{
backend::{allocator::Fourcc, renderer::ExportMem}, backend::{
allocator::Fourcc,
renderer::{ExportMem, TextureMapping},
},
output::Output, output::Output,
reexports::{ reexports::{
wayland_protocols_wlr::screencopy::v1::server::{ wayland_protocols_wlr::screencopy::v1::server::{
@@ -31,18 +36,120 @@ pub(crate) struct FrameData {
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct PendingScreencopy { pub(crate) struct PendingScreencopy {
pub(crate) frame: ZwlrScreencopyFrameV1, sink: CaptureSink,
buffer: WlBuffer,
pub(crate) output: Output, pub(crate) output: Output,
region: Rectangle<i32, Buffer>, region: Rectangle<i32, Buffer>,
overlay_cursor: bool, overlay_cursor: bool,
with_damage: bool, with_damage: bool,
} }
pub(crate) fn init_global(display: &DisplayHandle) { #[derive(Debug)]
let enabled = std::env::var_os("FERESE_ENABLE_SCREENCOPY").is_some_and(|value| value == "1"); 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, ()); display.create_global::<Ferese, ZwlrScreencopyManagerV1, ()>(3, ());
tracing::info!("authorized screencopy is enabled for this session"); tracing::info!("authorized screencopy is enabled for this session");
} }
@@ -161,8 +268,10 @@ impl Ferese {
}; };
self.pending_screencopies.push(PendingScreencopy { self.pending_screencopies.push(PendingScreencopy {
sink: CaptureSink::Shm {
frame: frame.clone(), frame: frame.clone(),
buffer, buffer,
},
output, output,
region: data.region, region: data.region,
overlay_cursor: data.overlay_cursor, overlay_cursor: data.overlay_cursor,
@@ -192,14 +301,16 @@ impl Ferese {
remaining.push(capture); remaining.push(capture);
continue; 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; continue;
} }
framebuffer_binding_changed = true; framebuffer_binding_changed = true;
if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err() 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) 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>, mode_size: Size<i32, smithay::utils::Physical>,
scale: f64, scale: f64,
transform: smithay::utils::Transform, transform: smithay::utils::Transform,
requested: Option<Rectangle<i32, Logical>>, requested: Option<Rectangle<i32, Logical>>,
) -> Option<Rectangle<i32, Buffer>> { ) -> Option<Rectangle<i32, Buffer>> {
let logical_size = transform let logical_size = output_logical_size(mode_size, scale, transform);
.transform_size(mode_size)
.to_f64()
.to_logical(scale)
.to_i32_round();
let output_region = Rectangle::from_size(logical_size); let output_region = Rectangle::from_size(logical_size);
let logical_region = requested let logical_region = requested
.unwrap_or(output_region) .unwrap_or(output_region)
@@ -342,50 +461,79 @@ where
.map_err(|error| { .map_err(|error| {
tracing::warn!(?error, "failed to copy output framebuffer"); tracing::warn!(?error, "failed to copy output framebuffer");
})?; })?;
if mapping.format() != Fourcc::Argb8888 {
return Err(());
}
let source = renderer.map_texture(&mapping).map_err(|error| { let source = renderer.map_texture(&mapping).map_err(|error| {
tracing::warn!(?error, "failed to map output framebuffer copy"); tracing::warn!(?error, "failed to map output framebuffer copy");
})?; })?;
let expected = capture.region.size; let expected = capture.region.size;
let copied = with_buffer_contents_mut(&capture.buffer, |destination, length, data| { let stride = (expected.w as usize)
let bytes = expected.w as usize * expected.h as usize * BYTES_PER_PIXEL; .checked_mul(BYTES_PER_PIXEL)
if source.len() < bytes || data.offset < 0 || data.offset as usize + bytes > length { .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; return false;
} }
// Smithay's output projection already accounts for OpenGL's Y axis. // SAFETY: the mapped slice is at least `bytes` long, and the
// These rows are in output-buffer order, including its output transform. // 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 { unsafe {
std::ptr::copy_nonoverlapping( std::ptr::copy_nonoverlapping(source.as_ptr(), destination.add(offset), bytes);
source.as_ptr(),
destination.add(data.offset as usize),
bytes,
);
} }
true true
}) })
.map_err(|_| ())?; .unwrap_or(false)
}
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 { if !copied {
return Err(()); return Err(());
} }
if let CaptureSink::Shm { frame, .. } = &capture.sink {
if capture.with_damage { if capture.with_damage {
capture frame.damage(0, 0, expected.w as u32, expected.h as u32);
.frame
.damage(0, 0, expected.w as u32, expected.h as u32);
} }
// TextureMapping::flipped describes importing the mapping as a texture; // TextureMapping::flipped describes importing the mapping as a texture;
// using it here would make clients flip the already-correct output rows. // using it here would make clients flip the already-correct output rows.
capture frame.flags(zwlr_screencopy_frame_v1::Flags::empty());
.frame
.flags(zwlr_screencopy_frame_v1::Flags::empty());
let seconds = timestamp.as_secs(); let seconds = timestamp.as_secs();
capture.frame.ready( frame.ready(
(seconds >> 32) as u32, (seconds >> 32) as u32,
seconds as u32, seconds as u32,
timestamp.subsec_nanos(), timestamp.subsec_nanos(),
); );
}
Ok(()) Ok(())
} }
@@ -394,6 +542,19 @@ mod tests {
use super::*; use super::*;
use smithay::utils::{Physical, Transform}; 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] #[test]
fn capture_region_clips_then_scales_to_buffer_coordinates() { fn capture_region_clips_then_scales_to_buffer_coordinates() {
let mode = Size::<i32, Physical>::from((1_920, 1_080)); 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")),
}
}
}
+222 -3
View File
@@ -11,18 +11,27 @@ use std::sync::{
mpsc::{SyncSender, sync_channel}, mpsc::{SyncSender, sync_channel},
}; };
use std::thread; use std::thread;
use std::time::Duration;
use ferese_core::LayoutMode; use ferese_core::LayoutMode;
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
use ferese_layout::Direction; use ferese_layout::Direction;
use serde_json::{Value, json}; 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 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 crate::{Ferese, config::OutputTransform};
use smithay::output::Output;
const REQUEST_QUEUE_CAPACITY: usize = 128; const REQUEST_QUEUE_CAPACITY: usize = 128;
const MAX_CONNECTIONS: usize = 64; const MAX_CONNECTIONS: usize = 64;
const RESULT_QUEUE_CAPACITY: usize = 8;
const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL;
#[derive(Debug)] #[derive(Debug)]
struct IpcCall { struct IpcCall {
@@ -61,7 +70,7 @@ impl Drop for IpcSocketGuard {
pub(crate) fn init( pub(crate) fn init(
event_loop: &mut EventLoop<'static, Ferese>, event_loop: &mut EventLoop<'static, Ferese>,
) -> Result<IpcSocketGuard, Box<dyn std::error::Error>> { ) -> Result<ScreenshotInit, Box<dyn std::error::Error>> {
let path = socket_path()?; let path = socket_path()?;
prepare_parent(&path)?; prepare_parent(&path)?;
let listener = bind_listener(&path)?; let listener = bind_listener(&path)?;
@@ -79,18 +88,70 @@ pub(crate) fn init(
.handle() .handle()
.insert_source(receiver, |event, _, state| { .insert_source(receiver, |event, _, state| {
if let channel::Event::Msg(call) = event { if let channel::Event::Msg(call) = event {
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 response = state.handle_ipc_request(call.request);
let _ = call.response.send(response); 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(); let signal = event_loop.get_signal();
thread::Builder::new() thread::Builder::new()
.name("ferese-ipc-listener".to_owned()) .name("ferese-ipc-listener".to_owned())
.spawn(move || accept_connections(listener, sender, signal))?; .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"); 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( fn accept_connections(
@@ -261,6 +322,152 @@ impl Ferese {
Ok(json!({})) 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 { fn focused_window_json(&self) -> Value {
self.focused_window self.focused_window
.map(|window| json!({ "id": window.0 })) .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> { fn validate_request(request: &Request) -> Result<(), CommandError> {
if request.version != VERSION { if request.version != VERSION {
return Err(CommandError::new( return Err(CommandError::new(
+10 -1
View File
@@ -125,7 +125,16 @@ impl SessionLockHandler for Ferese {
self.session_lock.owner = Some(confirmation.ext_session_lock().clone()); self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
self.session_lock.confirmation = Some(confirmation); self.session_lock.confirmation = Some(confirmation);
for capture in self.pending_screencopies.drain(..) { 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); self.set_overview_active(false);
let serial = SERIAL_COUNTER.next_serial(); let serial = SERIAL_COUNTER.next_serial();
+14 -2
View File
@@ -174,6 +174,12 @@ pub struct Ferese {
_ipc_socket: Option<crate::ipc::IpcSocketGuard>, _ipc_socket: Option<crate::ipc::IpcSocketGuard>,
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>, pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>,
pub(crate) pending_screencopies: Vec<crate::handlers::screencopy::PendingScreencopy>, 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< pub(crate) shell_resources: Vec<
smithay::reexports::wayland_server::Weak< smithay::reexports::wayland_server::Weak<
ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1, ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1,
@@ -385,6 +391,9 @@ impl Ferese {
_ipc_socket: None, _ipc_socket: None,
pending_dmabuf_imports: Vec::new(), pending_dmabuf_imports: Vec::new(),
pending_screencopies: Vec::new(), pending_screencopies: Vec::new(),
screenshot: crate::handlers::screenshot::Coordinator::new(),
screenshot_parts: None,
screenshot_worker: None,
shell_resources: Vec::new(), shell_resources: Vec::new(),
shell_snapshot_serial: 0, shell_snapshot_serial: 0,
last_shell_snapshot: None, last_shell_snapshot: None,
@@ -422,7 +431,10 @@ impl Ferese {
xdg_shell_state, xdg_shell_state,
xdg_toplevel_icon_manager, 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) Ok(state)
} }
@@ -572,7 +584,7 @@ impl Ferese {
self.overview_scrims.remove(&output_id); self.overview_scrims.remove(&output_id);
self.pending_screencopies.retain(|capture| { self.pending_screencopies.retain(|capture| {
if capture.output == *output { if capture.output == *output {
capture.frame.failed(); capture.fail();
false false
} else { } else {
true true
+2 -1
View File
@@ -73,7 +73,7 @@ pub fn resolve(
let mut result = WindowRuleResult::default(); let mut result = WindowRuleResult::default();
if matches!( if matches!(
app_id.as_deref(), 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); result.floating = Some(true);
} }
@@ -220,6 +220,7 @@ mod tests {
"dev.ferese.ScreenShare", "dev.ferese.ScreenShare",
"dev.ferese.ScreenShare.desktop", "dev.ferese.ScreenShare.desktop",
"dev.ferese.Authentication", "dev.ferese.Authentication",
"dev.ferese.Screenshot",
] { ] {
assert_eq!(resolve(&[], Some(app_id), None, false).floating, Some(true)); assert_eq!(resolve(&[], Some(app_id), None, false).floating, Some(true));
let rules = validate(&[WindowRuleConfig { let rules = validate(&[WindowRuleConfig {
+22 -8
View File
@@ -294,16 +294,26 @@ Super+M layout; Super+Shift+Space floating; Super+Tab overview;
Super+Shift+S area screenshot; Print Screen whole-screen screenshot; Super+Shift+S area screenshot; Print Screen whole-screen screenshot;
Super+Shift+E immediate logout. Super+Shift+E immediate logout.
Hold Super and drag a floating window with the left mouse button to move it.
Hold Super and drag with the right mouse button to resize it from the nearest
corner. These work even when an app has no title bar or resize border.
The screenshot shortcut runs `ferese-screenshot`: drag to select an area, or The screenshot shortcut runs `ferese-screenshot`: drag to select an area, or
press Escape to cancel. Captures open in Satty for annotation. Press Enter to press Escape to cancel. Captures open in Satty for annotation. Press Enter to
save the edited PNG under your Pictures directory in `Screenshots` and copy it save the edited PNG under your Pictures directory in `Screenshots` and copy it
to the clipboard; Escape discards the capture. It requires `grim`, `slurp`, to the clipboard; Escape discards the capture. It requires `slurp`, `satty`, and
`satty`, and `wl-copy` (from `wl-clipboard`). Override the `screenshot` command `wl-copy` (from `wl-clipboard`); the capture itself is done by the compositor
to use another screenshot tool. through `feresectl screenshot`. Override the `screenshot` command to use another
Print Screen runs `ferese-screenshot --full` and captures all enabled outputs screenshot tool.
without a selector. Fn+PrtSc works when the keyboard emits the Print Screen key; Print Screen runs `ferese-screenshot --full` and captures the active monitor
Fn is handled by the keyboard firmware. Override `screenshot-full` to customize without a selector, falling back to the sole enabled output when focus is
this command. Both modes open Satty with the same save and copy workflow. unavailable. It never silently captures every output. Fn+PrtSc works when the
keyboard emits the Print Screen key; Fn is handled by the keyboard firmware.
Override `screenshot-full` to customize this command. Both modes open Satty with
the same save and copy workflow.
`ferese-screenshot --all` captures every enabled output as one image.
## Window rules ## Window rules
@@ -406,7 +416,11 @@ These are launch-time environment switches, not KDL keys. Enable only for
trusted clients: `FERESE_ENABLE_INPUT_METHOD=1`, trusted clients: `FERESE_ENABLE_INPUT_METHOD=1`,
`FERESE_ENABLE_SHORTCUT_INHIBIT=1`, `FERESE_ENABLE_SCREENCOPY=1`. `FERESE_ENABLE_SHORTCUT_INHIBIT=1`, `FERESE_ENABLE_SCREENCOPY=1`.
The installed session launcher enables screencopy for screenshots unless The installed session launcher enables screencopy for screenshots unless
`FERESE_ENABLE_SCREENCOPY=0` is explicitly set in its environment. `FERESE_ENABLE_SCREENCOPY=0` is explicitly set in its environment. Setting it
to `0` disables every path that can read the screen, including the built-in
screenshot command, not just the Wayland protocol. A session launched by hand
rather than through `ferese-session` needs `FERESE_ENABLE_SCREENCOPY=1` in its
environment for screenshots to work.
Text input is available by default; Ctrl+Alt+Escape releases an active shortcut Text input is available by default; Ctrl+Alt+Escape releases an active shortcut
inhibitor. Use `feresectl --help` for runtime control commands. inhibitor. Use `feresectl --help` for runtime control commands.
+11 -4
View File
@@ -203,7 +203,7 @@ Install these through your distribution when you want the corresponding features
| Feature | Tools or services | | Feature | Tools or services |
| --- | --- | | --- | --- |
| Wallpaper file picker | `zenity` | | Wallpaper file picker | `zenity` |
| Screenshots and editing | `grim`, `slurp`, `satty`, `wl-clipboard`, Python 3 | | Screenshots and editing | `slurp`, `satty`, `wl-clipboard`, Python 3 |
| Idle locking | `swayidle` with the included `ferese-lock` | | Idle locking | `swayidle` with the included `ferese-lock` |
| Network controls | NetworkManager and its running service | | Network controls | NetworkManager and its running service |
| Audio controls | PipeWire, WirePlumber, and `wpctl` | | Audio controls | PipeWire, WirePlumber, and `wpctl` |
@@ -244,9 +244,16 @@ A directly launched source build writes to its terminal. Keep another desktop
available while testing scaling, monitor hotplug, suspend, and locking. available while testing scaling, monitor hotplug, suspend, and locking.
The launcher enables the screencopy protocol for screenshot tools. Set The launcher enables the screencopy protocol for screenshot tools. Set
`FERESE_ENABLE_SCREENCOPY=0` in the session environment to disable it. When `FERESE_ENABLE_SCREENCOPY=0` in the session environment to disable it. This
enabled, Wayland clients can capture the unlocked desktop. Capture remains turns off every path that can read the screen, including the built-in
blocked while the session is locked. Launcher changes take effect at next login. screenshot command, not only the Wayland protocol. When enabled, Wayland clients
can capture the unlocked desktop, and `feresectl screenshot` is available.
Capture remains blocked while the session is locked. Launcher changes take
effect at next login.
Starting the compositor directly instead of through `ferese-session` leaves
`FERESE_ENABLE_SCREENCOPY` unset, which disables screenshots. Export
`FERESE_ENABLE_SCREENCOPY=1` to use them.
## Updating ## Updating
+74 -3
View File
@@ -1,6 +1,7 @@
use std::env; use std::env;
use std::error::Error; use std::error::Error;
use std::io; use std::fs;
use std::io::{self, Write};
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::path::PathBuf; use std::path::PathBuf;
@@ -13,7 +14,7 @@ fn main() -> Result<(), Box<dyn Error>> {
version: VERSION, version: VERSION,
id: 1, id: 1,
kind: "command".to_owned(), kind: "command".to_owned(),
command, command: command.clone(),
args, args,
}; };
let mut stream = UnixStream::connect(socket_path()?)?; let mut stream = UnixStream::connect(socket_path()?)?;
@@ -24,11 +25,37 @@ fn main() -> Result<(), Box<dyn Error>> {
return Err(format!("{}: {}", error.code, error.message).into()); return Err(format!("{}: {}", error.code, error.message).into());
} }
if command == "screenshot" {
write_png(&response)
} else {
println!( println!(
"{}", "{}",
serde_json::to_string_pretty(&response.result.unwrap_or(Value::Null))? serde_json::to_string_pretty(&response.result.unwrap_or(Value::Null))?
); );
Ok(()) Ok(())
}
}
// The compositor stages the PNG in a private directory and replies with its
// path. Open it before unlinking so a failure never destroys the only copy, and
// stream the bytes with explicit writes: println! would corrupt binary output.
fn write_png(response: &Response) -> Result<(), Box<dyn Error>> {
let path = response
.result
.as_ref()
.and_then(|result| result.get("path"))
.and_then(Value::as_str)
.ok_or("the compositor did not return a screenshot path")?;
let mut file = fs::File::open(path)?;
if let Err(error) = fs::remove_file(path) {
// The compositor sweeps anything left behind, so a failure here is not
// worth losing the capture over.
eprintln!("feresectl: could not remove {path}: {error}");
}
let mut stdout = io::stdout().lock();
io::copy(&mut file, &mut stdout)?;
stdout.flush()?;
Ok(())
} }
fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value), String> { fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value), String> {
@@ -47,6 +74,18 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
.map_err(|_| format!("{} requires a positive workspace index", command))?; .map_err(|_| format!("{} requires a positive workspace index", command))?;
json!({ "index": index }) json!({ "index": index })
} }
"screenshot" => match positional.as_slice() {
[] => json!({}),
[flag, geometry] if flag == "--geometry" || flag == "-g" => {
json!({ "geometry": geometry })
}
_ => {
return Err(format!(
"{command} accepts an optional --geometry \"x,y WxH\" ({})",
usage()
));
}
},
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout" "toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
| "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel" | "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel"
| "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "reload-config" | "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "reload-config"
@@ -79,7 +118,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
} }
fn usage() -> String { fn usage() -> String {
"usage: feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit>\n feresectl <get-focused-window|get-workspaces|get-outputs|reload-config>".to_owned() "usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit>\n feresectl <get-focused-window|get-workspaces|get-outputs|reload-config>".to_owned()
} }
#[cfg(test)] #[cfg(test)]
@@ -115,6 +154,38 @@ mod tests {
); );
} }
#[test]
fn screenshot_takes_an_optional_geometry() {
assert_eq!(
parse_args(["screenshot".into()]).unwrap(),
("screenshot".into(), json!({})),
"no geometry captures every enabled output"
);
assert_eq!(
parse_args(["screenshot".into(), "-g".into(), "10,-20 300x200".into()])
.unwrap()
.1,
json!({ "geometry": "10,-20 300x200" }),
"negative origins survive argument parsing"
);
assert_eq!(
parse_args(["screenshot".into(), "--geometry".into(), "0,0 8x8".into()])
.unwrap()
.1,
json!({ "geometry": "0,0 8x8" })
);
}
#[test]
fn screenshot_rejects_stray_arguments() {
assert!(parse_args(["screenshot".into(), "extra".into()]).is_err());
assert!(parse_args(["screenshot".into(), "-g".into()]).is_err());
assert!(
parse_args(["screenshot".into(), "0,0 8x8".into()]).is_err(),
"a bare geometry is not accepted without the flag"
);
}
#[test] #[test]
fn rejects_unknown_or_missing_arguments() { fn rejects_unknown_or_missing_arguments() {
assert_eq!( assert_eq!(
+23 -11
View File
@@ -25,7 +25,7 @@ case ${1:-} in
esac esac
(($# == 0)) || fail 'Usage: ferese-screenshot [--full | --all]' (($# == 0)) || fail 'Usage: ferese-screenshot [--full | --all]'
for tool in grim satty wl-copy flock; do for tool in feresectl satty wl-copy flock python3; do
command -v "$tool" >/dev/null || fail "Required command not found: $tool" command -v "$tool" >/dev/null || fail "Required command not found: $tool"
done done
: "${XDG_RUNTIME_DIR:?A Wayland login session is required}" : "${XDG_RUNTIME_DIR:?A Wayland login session is required}"
@@ -40,21 +40,18 @@ if [[ $mode == area ]]; then
[[ -n $geometry ]] || exit 0 [[ -n $geometry ]] || exit 0
capture_args=(-g "$geometry") capture_args=(-g "$geometry")
elif [[ $mode == full ]]; then elif [[ $mode == full ]]; then
for tool in feresectl python3; do
command -v "$tool" >/dev/null || fail "Required command not found: $tool"
done
# Resolve the active monitor before Satty can change keyboard focus. # Resolve the active monitor before Satty can change keyboard focus.
# Never fall back to grim's all-monitor capture when IPC is unavailable. # Never fall back to an all-monitor capture when IPC is unavailable.
output=$(feresectl get-outputs | python3 -c ' geometry=$(feresectl get-outputs | python3 -c '
import json, sys import json, sys
outputs = [output for output in json.load(sys.stdin) if output.get("enabled")] outputs = [output for output in json.load(sys.stdin) if output.get("enabled")]
focused = [output for output in outputs if output.get("focused")] focused = [output for output in outputs if output.get("focused")]
selected = focused if len(focused) == 1 else outputs if len(outputs) == 1 else [] selected = focused if len(focused) == 1 else outputs if len(outputs) == 1 else []
if len(selected) != 1 or not selected[0].get("name"): if len(selected) != 1 or any(selected[0].get(k) is None for k in ("x", "y", "width", "height")):
sys.exit(1) sys.exit(1)
print(selected[0]["name"]) print("%d,%d %dx%d" % (selected[0]["x"], selected[0]["y"], selected[0]["width"], selected[0]["height"]))
') || fail 'Could not determine the active monitor. Use area selection or --all.' ') || fail 'Could not determine the active monitor. Use area selection or --all.'
capture_args=(-o "$output") capture_args=(-g "$geometry")
fi fi
pictures=$(xdg-user-dir PICTURES 2>/dev/null) || pictures="$HOME/Pictures" pictures=$(xdg-user-dir PICTURES 2>/dev/null) || pictures="$HOME/Pictures"
directory=${pictures:-$HOME/Pictures}/Screenshots directory=${pictures:-$HOME/Pictures}/Screenshots
@@ -63,12 +60,27 @@ file=$(mktemp "$directory/Screenshot-$(date +%Y-%m-%d_%H-%M-%S)-XXXXXX.png") ||
input='' input=''
trap '[[ -z $input ]] || rm -f -- "$input"; [[ -s $file ]] || rm -f -- "$file"' EXIT trap '[[ -z $input ]] || rm -f -- "$input"; [[ -s $file ]] || rm -f -- "$file"' EXIT
input=$(mktemp "$XDG_RUNTIME_DIR/ferese-screenshot-XXXXXX.png") || fail 'Could not create the temporary capture.' input=$(mktemp "$XDG_RUNTIME_DIR/ferese-screenshot-XXXXXX.png") || fail 'Could not create the temporary capture.'
grim "${capture_args[@]}" "$input" || fail 'Could not capture the screen. Log back into Ferese after enabling screen capture.' feresectl screenshot "${capture_args[@]}" >"$input" || fail 'Could not capture the screen. Ferese must be running, unlocked, and reachable over its socket.'
editor_size=$(feresectl get-outputs | python3 -c '
import json, sys
outputs = [output for output in json.load(sys.stdin) if output.get("enabled")]
focused = [output for output in outputs if output.get("focused")]
selected = focused if len(focused) == 1 else outputs if len(outputs) == 1 else []
if len(selected) != 1 or any(selected[0].get(key) is None for key in ("width", "height")):
sys.exit(1)
width = max(1, min(1500, round(selected[0]["width"] * 0.8)))
height = max(1, min(920, round(selected[0]["height"] * 0.8), round(width * 0.68)))
print(f"{width}x{height}")
') || editor_size=1000x700
# Enter saves the annotated image and copies it; Escape discards the capture. # Enter saves the annotated image and copies it; Escape discards the capture.
# Keep the parent lock through editing. Close it only in Satty so clipboard # Keep the parent lock through editing. Close it only in Satty so clipboard
# helpers cannot inherit the lock and keep later screenshots blocked. # helpers cannot inherit the lock and keep later screenshots blocked.
satty --filename "$input" --output-filename "$file" \ satty --filename "$input" --output-filename "$file" \
--fullscreen current-screen \ --app-id dev.ferese.Screenshot \
--resize "$editor_size" \
--no-window-decoration \
--copy-command 'wl-copy --type image/png' \ --copy-command 'wl-copy --type image/png' \
--save-after-copy \ --save-after-copy \
--actions-on-enter save-to-file,save-to-clipboard,exit \ --actions-on-enter save-to-file,save-to-clipboard,exit \
+23 -2
View File
@@ -1,7 +1,10 @@
"""Opt-in rendered regression test; opens only a temporary nested compositor. """Opt-in rendered regression test; opens only a temporary nested compositor.
FERESE_TEST_FLOATING=1 FERESE_TEST_BINARY=target/debug/ferese python3 scripts/tests/test_floating_isolated.py FERESE_TEST_FLOATING=1 FERESE_TEST_BINARY=target/debug/ferese python3 scripts/tests/test_floating_isolated.py
Requires cc, pkg-config, wayland-scanner, wayland-protocols, grim, and Pillow. Requires cc, pkg-config, wayland-scanner, wayland-protocols, and Pillow.
The compositor and feresectl must be built; set FERESE_TEST_BINARY to point at
the compositor and FERESE_TEST_CTL at feresectl when they are not siblings.
""" """
import json
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
@@ -42,6 +45,9 @@ class FloatingSizeTest(unittest.TestCase):
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"),
WAYLAND_DISPLAY=display, FERESE_ENABLE_SCREENCOPY="1") WAYLAND_DISPLAY=display, FERESE_ENABLE_SCREENCOPY="1")
binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
ctl = repo / os.environ.get("FERESE_TEST_CTL", str(binary.parent / "feresectl"))
if not ctl.is_file():
self.fail(f"missing {ctl}; build it with cargo build -p feresectl")
with (root / "compositor.log").open("w") as log: with (root / "compositor.log").open("w") as log:
process = subprocess.Popen([str(binary), "--backend", "nested", "--", str(root / "client")], process = subprocess.Popen([str(binary), "--backend", "nested", "--", str(root / "client")],
env=env, stdout=log, stderr=subprocess.STDOUT) env=env, stdout=log, stderr=subprocess.STDOUT)
@@ -56,8 +62,23 @@ class FloatingSizeTest(unittest.TestCase):
self.assertTrue(sockets, "nested Wayland socket did not appear") self.assertTrue(sockets, "nested Wayland socket did not appear")
childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0])) childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0]))
time.sleep(2) time.sleep(2)
listed = subprocess.run([str(ctl), "get-outputs"], env=childenv,
check=True, capture_output=True, timeout=10, text=True)
enabled = [o for o in json.loads(listed.stdout) if o.get("enabled")]
if not enabled:
self.fail("compositor reported no enabled output to capture")
first = enabled[0]
if any(first.get(k) is None for k in ("x", "y", "width", "height")):
self.fail(f"enabled output {first.get('name')!r} has no logical geometry")
geometry = "%d,%d %dx%d" % (first["x"], first["y"], first["width"], first["height"])
screenshot = root / "frame.png" screenshot = root / "frame.png"
subprocess.run(["grim", "-s", "1", str(screenshot)], env=childenv, check=True, timeout=15) with screenshot.open("wb") as png:
captured = subprocess.run([str(ctl), "screenshot", "-g", geometry],
env=childenv, stdout=png, stderr=subprocess.PIPE, timeout=15)
if captured.returncode != 0:
self.fail(f"capture of {geometry} failed: {captured.stderr.decode()}")
if screenshot.stat().st_size == 0:
self.fail(f"capture of {geometry} produced an empty file")
with Image.open(screenshot) as frame: with Image.open(screenshot) as frame:
rgb = frame.convert("RGB") rgb = frame.convert("RGB")
red, green, blue = rgb.split() red, green, blue = rgb.split()
+14 -6
View File
@@ -1,6 +1,6 @@
"""Opt-in lock protocol smoke test. Opens a temporary nested compositor. """Opt-in lock protocol smoke test. Opens a temporary nested compositor.
Run: FERESE_TEST_LOCK=1 python3 scripts/tests/test_lock_isolated.py Run: FERESE_TEST_LOCK=1 python3 scripts/tests/test_lock_isolated.py
Requires release binaries, bwrap and grim. Host PAM is never modified. Requires release binaries and bwrap. Host PAM is never modified.
""" """
import os import os
import unittest import unittest
@@ -23,6 +23,8 @@ class NativeLockTest(unittest.TestCase):
env=dict(os.environ,XDG_RUNTIME_DIR=str(runtime),XDG_CONFIG_HOME=str(root/'config'),WAYLAND_DISPLAY=host_display,FERESE_ENABLE_SCREENCOPY='1') env=dict(os.environ,XDG_RUNTIME_DIR=str(runtime),XDG_CONFIG_HOME=str(root/'config'),WAYLAND_DISPLAY=host_display,FERESE_ENABLE_SCREENCOPY='1')
log=(root/'compositor.log').open('w') log=(root/'compositor.log').open('w')
compositor=subprocess.Popen(['target/release/ferese','--backend','nested'],env=env,stdout=log,stderr=subprocess.STDOUT) compositor=subprocess.Popen(['target/release/ferese','--backend','nested'],env=env,stdout=log,stderr=subprocess.STDOUT)
ctl=pathlib.Path('target/release/feresectl')
assert ctl.is_file(), f'missing {ctl}; build it with cargo build --release -p feresectl'
locker=None locker=None
locker_log=(root/'locker.log').open('w') locker_log=(root/'locker.log').open('w')
try: try:
@@ -32,19 +34,25 @@ class NativeLockTest(unittest.TestCase):
if sockets:break if sockets:break
time.sleep(.1) time.sleep(.1)
childenv=dict(env,WAYLAND_DISPLAY=str(sockets[0]),FERESE_LOCK_READY='1') childenv=dict(env,WAYLAND_DISPLAY=str(sockets[0]),FERESE_LOCK_READY='1')
def capture(destination, timeout=10):
with open(destination,'wb') as png:
return subprocess.run([str(ctl),'screenshot'],env=childenv,stdout=png,stderr=subprocess.PIPE,timeout=timeout)
time.sleep(.6) time.sleep(.6)
subprocess.run(['grim',str(root/'unlocked.png')],env=childenv,check=True,timeout=10,capture_output=True) result=capture(root/'unlocked.png')
assert result.returncode == 0, f'Capture failed while unlocked: {result.stderr.decode()}'
assert (root/'unlocked.png').stat().st_size > 0, 'Capture produced an empty file'
print('Native capture succeeded while unlocked',flush=True)
locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--','target/release/ferese-lock'],env=childenv,stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True) locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--','target/release/ferese-lock'],env=childenv,stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True)
assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text() assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text()
print('Native locker received compositor confirmation',flush=True) print('Native locker received compositor confirmation',flush=True)
result=subprocess.run(['grim',str(root/'locked.png')],env=childenv,timeout=10,capture_output=True) result=capture(root/'locked.png')
assert result.returncode != 0, 'Capture unexpectedly succeeded during lock' assert result.returncode != 0, 'Capture unexpectedly succeeded during lock'
print('Screencopy denied while native locker is running',flush=True) print('Capture denied while native locker is running',flush=True)
os.killpg(locker.pid, signal.SIGKILL) os.killpg(locker.pid, signal.SIGKILL)
time.sleep(.3) time.sleep(.3)
result=subprocess.run(['grim',str(root/'after-crash.png')],env=childenv,timeout=10,capture_output=True) result=capture(root/'after-crash.png')
assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash' assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash'
print('Screencopy remains denied after locker crash',flush=True) print('Capture remains denied after locker crash',flush=True)
finally: finally:
if locker is not None: if locker is not None:
try: os.killpg(locker.pid, signal.SIGKILL) try: os.killpg(locker.pid, signal.SIGKILL)
+124 -55
View File
@@ -10,88 +10,157 @@ import unittest
SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-screenshot' SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-screenshot'
OUTPUTS = [
{'name': 'eDP-1', 'enabled': True, 'focused': False,
'x': 0, 'y': 0, 'width': 1920, 'height': 1080},
{'name': 'HDMI-A-1', 'enabled': True, 'focused': True,
'x': 1920, 'y': 0, 'width': 2560, 'height': 1440},
]
FOCUSED_GEOMETRY = '1920,0 2560x1440'
class ScreenshotTest(unittest.TestCase): FARESECTL = '''#!/bin/bash
def test_active_output_all_outputs_and_editor_lock(self): case "$1" in
with tempfile.TemporaryDirectory() as directory: get-outputs) cat "$TEST_ROOT/outputs.json" ;;
root = Path(directory) screenshot)
bins = root / 'bin' printf '%s\\n' "$@" >> "$TEST_ROOT/captures"
bins.mkdir() printf image
env = dict(os.environ, XDG_RUNTIME_DIR=directory, TEST_ROOT=directory, ;;
PATH=f'{bins}:{os.environ["PATH"]}') *) printf 'unexpected feresectl command: %s\\n' "$1" >&2; exit 1 ;;
(root / 'outputs.json').write_text(json.dumps([ esac
{'name': 'eDP-1', 'enabled': True, 'focused': False}, '''
{'name': 'HDMI-A-1', 'enabled': True, 'focused': True},
])) SATTY = '''#!/bin/bash
scripts = {
'grim': '''#!/bin/bash
printf '%s\\n' "$@" >> "$TEST_ROOT/captures"
printf image > "${@: -1}"
''',
'feresectl': '#!/bin/bash\ncat "$TEST_ROOT/outputs.json"\n',
'notify-send': '#!/bin/bash\nexit 0\n',
'wl-copy': '#!/bin/bash\nexit 0\n',
'xdg-user-dir': '#!/bin/bash\nprintf "%s" "$TEST_ROOT/Pictures"\n',
'satty': '''#!/bin/bash
if [[ -e /proc/$$/fd/9 ]]; then touch "$TEST_ROOT/inherited-lock"; fi if [[ -e /proc/$$/fd/9 ]]; then touch "$TEST_ROOT/inherited-lock"; fi
printf '%s\\n' "$@" > "$TEST_ROOT/satty-args"
printf 'editor\\n' >> "$TEST_ROOT/editors" printf 'editor\\n' >> "$TEST_ROOT/editors"
touch "$TEST_ROOT/ready" touch "$TEST_ROOT/ready"
while [[ ! -e "$TEST_ROOT/release" ]]; do sleep 0.02; done while [[ ! -e "$TEST_ROOT/release" ]]; do sleep 0.02; done
''', '''
def fakes():
return {
'feresectl': FARESECTL,
'notify-send': '#!/bin/bash\nexit 0\n',
'wl-copy': '#!/bin/bash\nexit 0\n',
'xdg-user-dir': '#!/bin/bash\nprintf "%s" "$TEST_ROOT/Pictures"\n',
'satty': SATTY,
} }
for name, contents in scripts.items():
path = bins / name
class ScreenshotTest(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.bins = self.root / 'bin'
self.bins.mkdir()
self.env = dict(os.environ, XDG_RUNTIME_DIR=self.directory.name,
TEST_ROOT=str(self.root),
PATH=f'{self.bins}:{os.environ["PATH"]}')
self.write_outputs(OUTPUTS)
for name, contents in fakes().items():
path = self.bins / name
path.write_text(contents) path.write_text(contents)
path.chmod(0o755) path.chmod(0o755)
def run(mode):
return subprocess.run(['bash', str(SCRIPT), mode], env=env, def write_outputs(self, outputs):
capture_output=True, timeout=3) (self.root / 'outputs.json').write_text(json.dumps(outputs))
first = subprocess.Popen(['bash', str(SCRIPT), '--full'], env=env,
def captures(self):
log = self.root / 'captures'
if not log.exists():
return []
return log.read_text().splitlines()
def run_script(self, *args, timeout=3):
return subprocess.run(['bash', str(SCRIPT), *args], env=self.env,
capture_output=True, timeout=timeout)
def test_active_output_all_outputs_and_editor_lock(self):
first = subprocess.Popen(['bash', str(SCRIPT), '--full'], env=self.env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try: try:
deadline = time.monotonic() + 5 deadline = time.monotonic() + 5
while not (root / 'ready').exists(): while not (self.root / 'ready').exists():
if time.monotonic() > deadline or first.poll() is not None: if time.monotonic() > deadline or first.poll() is not None:
self.fail('First screenshot did not open its editor') self.fail('First screenshot did not open its editor')
time.sleep(0.02) time.sleep(0.02)
second = run('--full') second = self.run_script('--full')
self.assertEqual(second.returncode, 0, second.stderr) self.assertEqual(second.returncode, 0, second.stderr)
self.assertEqual((root / 'editors').read_text().splitlines(), ['editor']) self.assertEqual((self.root / 'editors').read_text().splitlines(), ['editor'])
self.assertFalse((root / 'inherited-lock').exists()) editor_args = (self.root / 'satty-args').read_text().splitlines()
(root / 'release').touch() self.assertEqual(editor_args[editor_args.index('--app-id') + 1],
'dev.ferese.Screenshot')
self.assertEqual(editor_args[editor_args.index('--resize') + 1],
'1500x920')
self.assertIn('--no-window-decoration', editor_args)
self.assertNotIn('--fullscreen', editor_args)
self.assertFalse((self.root / 'inherited-lock').exists())
(self.root / 'release').touch()
_, stderr = first.communicate(timeout=3) _, stderr = first.communicate(timeout=3)
self.assertEqual(first.returncode, 0, stderr) self.assertEqual(first.returncode, 0, stderr)
third = run('--full') third = self.run_script('--full')
self.assertEqual(third.returncode, 0, third.stderr) self.assertEqual(third.returncode, 0, third.stderr)
self.assertEqual((root / 'editors').read_text().splitlines(), ['editor', 'editor']) self.assertEqual((self.root / 'editors').read_text().splitlines(), ['editor', 'editor'])
self.assertFalse(list((root / 'Pictures/Screenshots').glob('*.png'))) self.assertFalse(list((self.root / 'Pictures/Screenshots').glob('*.png')))
self.assertFalse(list(root.glob('ferese-screenshot-*.png'))) self.assertFalse(list(self.root.glob('ferese-screenshot-*.png')))
captures = (root / 'captures').read_text().splitlines() # Each --full capture asked for the focused output by geometry, not
self.assertEqual(captures[0:2], ['-o', 'HDMI-A-1']) # by name, because native capture has no output-name selection.
self.assertEqual(captures[3:5], ['-o', 'HDMI-A-1']) self.assertEqual(self.captures()[0:3], ['screenshot', '-g', FOCUSED_GEOMETRY])
all_monitors = run('--all') self.assertEqual(self.captures()[3:6], ['screenshot', '-g', FOCUSED_GEOMETRY])
all_monitors = self.run_script('--all')
self.assertEqual(all_monitors.returncode, 0, all_monitors.stderr) self.assertEqual(all_monitors.returncode, 0, all_monitors.stderr)
captures = (root / 'captures').read_text().splitlines() self.assertEqual(len(self.captures()), 7) # --all passes only "screenshot"
self.assertEqual(len(captures), 7) # --all passes only the filename self.assertEqual(self.captures()[6], 'screenshot')
for outputs in [[], [{'enabled': True, 'name': 'A'}, {'enabled': True, 'name': 'B'}]]: captured = self.captures()
(root / 'outputs.json').write_text(json.dumps(outputs)) for outputs in [[], [dict(OUTPUTS[0], focused=False), dict(OUTPUTS[1], focused=False)]]:
unavailable = run('--full') self.write_outputs(outputs)
unavailable = self.run_script('--full')
self.assertNotEqual(unavailable.returncode, 0) self.assertNotEqual(unavailable.returncode, 0)
self.assertIn(b'Could not determine the active monitor', unavailable.stderr) self.assertIn(b'Could not determine the active monitor', unavailable.stderr)
self.assertEqual((root / 'captures').read_text().splitlines(), captures) self.assertEqual(self.captures(), captured)
(root / 'outputs.json').write_text(json.dumps([ # An enabled output with no geometry is ambiguous, not a reason to
{'name': 'eDP-1', 'enabled': False, 'focused': True}, # capture everything.
{'name': 'HDMI-A-1', 'enabled': True, 'focused': False}, self.write_outputs([dict(OUTPUTS[1], focused=True, x=None)])
])) unknown = self.run_script('--full')
single = run('--full') self.assertNotEqual(unknown.returncode, 0)
self.assertIn(b'Could not determine the active monitor', unknown.stderr)
self.assertEqual(self.captures(), captured)
# Disabled outputs are ignored, so the sole remaining one is used.
self.write_outputs([dict(OUTPUTS[0], enabled=False, focused=True),
dict(OUTPUTS[1], focused=False)])
single = self.run_script('--full')
self.assertEqual(single.returncode, 0, single.stderr) self.assertEqual(single.returncode, 0, single.stderr)
self.assertEqual((root / 'captures').read_text().splitlines()[-3:-1], ['-o', 'HDMI-A-1']) self.assertEqual(self.captures()[-2:], ['-g', FOCUSED_GEOMETRY])
finally: finally:
(root / 'release').touch() (self.root / 'release').touch()
if first.poll() is None: if first.poll() is None:
first.terminate() first.terminate()
first.communicate(timeout=3) first.communicate(timeout=3)
def test_area_selection_is_passed_through(self):
self.bins.joinpath('slurp').write_text(
'#!/bin/bash\nprintf "10,-20 300x200"\n')
self.bins.joinpath('slurp').chmod(0o755)
(self.root / 'release').touch()
area = self.run_script()
self.assertEqual(area.returncode, 0, area.stderr)
self.assertEqual(self.captures(), ['screenshot', '-g', '10,-20 300x200'])
def test_every_mode_captures_through_the_native_command(self):
(self.root / 'release').touch()
cases = [(['--all'], ['screenshot']),
(['--full'], ['screenshot', '-g', FOCUSED_GEOMETRY])]
for args, expected in cases:
with self.subTest(args=args):
before = len(self.captures())
result = self.run_script(*args)
self.assertEqual(result.returncode, 0, result.stderr)
# The fake only records these when the compositor command runs,
# so a capture taken any other way leaves the log short.
self.assertEqual(self.captures()[before:], expected)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()