Add isolated window screenshot targets with native selection
This commit is contained in:
1 parent
c8ed8e6c30
commit
0ab5f2e05b
12 files changed
+647
-61
No files matched your search
@@ -85,6 +85,30 @@ impl DirectBackendState {
|
||||
remaining,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn capture_window_buffer(
|
||||
&mut self,
|
||||
window: &smithay::desktop::Window,
|
||||
geometry: smithay::utils::Rectangle<i32, smithay::utils::Logical>,
|
||||
output: &Output,
|
||||
) -> Result<crate::handlers::screenshot::CaptureBuffer, String> {
|
||||
let device = self
|
||||
.devices
|
||||
.values_mut()
|
||||
.find(|device| {
|
||||
device
|
||||
.outputs
|
||||
.values()
|
||||
.any(|candidate| &candidate.output == output)
|
||||
})
|
||||
.ok_or("Window output is unavailable")?;
|
||||
crate::winit::capture_window_buffer(
|
||||
&mut device.renderer,
|
||||
window,
|
||||
geometry,
|
||||
output.current_scale().fractional_scale(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -10,6 +10,7 @@ use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Size, Transfor
|
||||
const BYTES_PER_PIXEL: usize = 4;
|
||||
|
||||
pub(crate) struct OutputFrame {
|
||||
pub(crate) preserve_alpha: bool,
|
||||
pub(crate) width: i32,
|
||||
pub(crate) height: i32,
|
||||
pub(crate) stride: usize,
|
||||
@@ -114,7 +115,7 @@ fn scale_and_convert(
|
||||
out[to] = pixel[2];
|
||||
out[to + 1] = pixel[1];
|
||||
out[to + 2] = pixel[0];
|
||||
out[to + 3] = 255;
|
||||
out[to + 3] = if frame.preserve_alpha { pixel[3] } else { 255 };
|
||||
}
|
||||
}
|
||||
return Some(out);
|
||||
@@ -147,7 +148,7 @@ fn scale_and_convert(
|
||||
out[to] = pixel[2];
|
||||
out[to + 1] = pixel[1];
|
||||
out[to + 2] = pixel[0];
|
||||
out[to + 3] = 255;
|
||||
out[to + 3] = if frame.preserve_alpha { pixel[3] } else { 255 };
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
@@ -362,6 +363,7 @@ pub(crate) type PartSender = smithay::reexports::calloop::channel::Sender<PartOu
|
||||
// Geometry snapshotted when the request was admitted, so the encoding worker
|
||||
// never re-queries an output whose transform or scale has since changed.
|
||||
pub(crate) struct PartSpec {
|
||||
pub(crate) preserve_alpha: bool,
|
||||
pub(crate) transform: Transform,
|
||||
pub(crate) scale: f64,
|
||||
pub(crate) location: (i32, i32),
|
||||
@@ -510,6 +512,7 @@ impl Coordinator {
|
||||
} else {
|
||||
pixels.extend_from_slice(&buffer.pixels);
|
||||
Some(OutputFrame {
|
||||
preserve_alpha: spec.preserve_alpha,
|
||||
width: buffer.width,
|
||||
height: buffer.height,
|
||||
stride: buffer.stride,
|
||||
@@ -664,6 +667,7 @@ pub(crate) struct PlannedPart {
|
||||
impl PlannedPart {
|
||||
pub(crate) fn spec(&self, output: &OutputLayout) -> PartSpec {
|
||||
PartSpec {
|
||||
preserve_alpha: false,
|
||||
transform: output.transform,
|
||||
scale: output.scale,
|
||||
location: (self.logical.loc.x, self.logical.loc.y),
|
||||
@@ -984,6 +988,7 @@ mod completion {
|
||||
|
||||
fn spec(width: i32, height: i32) -> PartSpec {
|
||||
PartSpec {
|
||||
preserve_alpha: false,
|
||||
transform: Transform::Normal,
|
||||
scale: 1.0,
|
||||
location: (0, 0),
|
||||
@@ -1276,6 +1281,7 @@ mod completion {
|
||||
assert!(overflow.is_err(), "outstanding requests are bounded");
|
||||
|
||||
let huge = PartSpec {
|
||||
preserve_alpha: false,
|
||||
buffer_width: 1 << 20,
|
||||
buffer_height: 1 << 20,
|
||||
..spec(2, 2)
|
||||
@@ -1351,6 +1357,7 @@ mod tests {
|
||||
pixels.extend_from_slice(&[b'a' + index as u8, 0, 0, 255]);
|
||||
}
|
||||
OutputFrame {
|
||||
preserve_alpha: false,
|
||||
width,
|
||||
height,
|
||||
stride: width as usize * BYTES_PER_PIXEL,
|
||||
@@ -1377,6 +1384,7 @@ mod tests {
|
||||
pixels.extend_from_slice(&[(index % 251) as u8, 0, 0, 255]);
|
||||
}
|
||||
OutputFrame {
|
||||
preserve_alpha: false,
|
||||
width,
|
||||
height,
|
||||
stride: width as usize * BYTES_PER_PIXEL,
|
||||
|
||||
@@ -200,6 +200,7 @@ mod tests {
|
||||
pixel[3] = 255;
|
||||
}
|
||||
OutputFrame {
|
||||
preserve_alpha: false,
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
|
||||
+122
-1
@@ -125,7 +125,9 @@ pub(crate) fn init(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if call.request.command == "screenshot" {
|
||||
if call.request.command == "screenshot-window" {
|
||||
state.start_window_screenshot(call.request, call.response);
|
||||
} else 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);
|
||||
@@ -309,6 +311,8 @@ fn serve_connection(
|
||||
};
|
||||
let (response, receiver) = sync_channel(1);
|
||||
let exit_requested = request.command == "exit";
|
||||
let screenshot_requested =
|
||||
matches!(request.command.as_str(), "screenshot" | "screenshot-window");
|
||||
let call = IpcEvent::Call(IpcCall {
|
||||
owner,
|
||||
request,
|
||||
@@ -323,6 +327,9 @@ fn serve_connection(
|
||||
};
|
||||
let exit_accepted = exit_requested && response.error.is_none();
|
||||
if let Err(error) = write_frame(&mut stream, &response) {
|
||||
if screenshot_requested {
|
||||
discard_undelivered_screenshot(&response);
|
||||
}
|
||||
tracing::debug!(%error, "IPC client disconnected before receiving its response");
|
||||
return;
|
||||
}
|
||||
@@ -335,6 +342,19 @@ fn serve_connection(
|
||||
}
|
||||
}
|
||||
|
||||
fn discard_undelivered_screenshot(response: &Response) {
|
||||
if response.error.is_none()
|
||||
&& let Some(path) = response
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|result| result["path"].as_str())
|
||||
&& let Err(error) = fs::remove_file(path)
|
||||
&& error.kind() != io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::debug!(%error, "failed to remove an undelivered screenshot");
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response {
|
||||
if self.session_lock.active && request.command != "portal-shortcuts-poll" {
|
||||
@@ -520,6 +540,93 @@ impl Ferese {
|
||||
crate::backends::direct::render_all(self);
|
||||
}
|
||||
|
||||
fn start_window_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
|
||||
let reject = |message: String| {
|
||||
let _ = response.try_send(Response::error(
|
||||
request.id,
|
||||
"window_capture_failed",
|
||||
message,
|
||||
));
|
||||
};
|
||||
if self.session_lock.active || !screencopy::capture_allowed() {
|
||||
reject("Screen capture is unavailable".into());
|
||||
return;
|
||||
}
|
||||
if let Err(error) = validate_request(&request) {
|
||||
reject(error.message);
|
||||
return;
|
||||
}
|
||||
let Some(id) = request
|
||||
.args
|
||||
.get("window")
|
||||
.and_then(Value::as_u64)
|
||||
.map(ferese_layout::WindowId)
|
||||
else {
|
||||
reject("Expected a window ID".into());
|
||||
return;
|
||||
};
|
||||
let Some(window) = self
|
||||
.window_ids
|
||||
.iter()
|
||||
.find(|(_, candidate)| **candidate == id)
|
||||
.map(|(window, _)| window.clone())
|
||||
else {
|
||||
reject("Window no longer exists".into());
|
||||
return;
|
||||
};
|
||||
let Some(output) = self
|
||||
.space
|
||||
.outputs()
|
||||
.find(|output| self.window_belongs_to_output(id, output))
|
||||
.cloned()
|
||||
else {
|
||||
reject("Window output is unavailable".into());
|
||||
return;
|
||||
};
|
||||
let geometry = window.geometry();
|
||||
let scale = output.current_scale().fractional_scale();
|
||||
let size = geometry.size.to_physical_precise_round(scale);
|
||||
let spec = crate::handlers::screenshot::PartSpec {
|
||||
preserve_alpha: true,
|
||||
transform: Transform::Normal,
|
||||
scale,
|
||||
location: (0, 0),
|
||||
logical_width: geometry.size.w,
|
||||
logical_height: geometry.size.h,
|
||||
buffer_width: size.w,
|
||||
buffer_height: size.h,
|
||||
};
|
||||
let fallback = response.clone();
|
||||
let capture = match self.screenshot.admit(request.id, response, vec![spec]) {
|
||||
Ok(capture) => capture,
|
||||
Err(error) => {
|
||||
let _ =
|
||||
fallback.try_send(Response::error(request.id, "screenshot_rejected", error));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let result = if let Some(backend) = &self.nested_backend {
|
||||
match backend.try_borrow_mut() {
|
||||
Ok(mut backend) => crate::winit::capture_window_buffer(
|
||||
backend.renderer(),
|
||||
&window,
|
||||
geometry,
|
||||
scale,
|
||||
),
|
||||
Err(_) => Err("Window renderer is busy".into()),
|
||||
}
|
||||
} else if let Some(backend) = &mut self.direct_backend {
|
||||
backend.capture_window_buffer(&window, geometry, &output)
|
||||
} else {
|
||||
Err("Window renderer is unavailable".into())
|
||||
};
|
||||
self.on_screenshot_part(PartOutcome {
|
||||
request: capture,
|
||||
part: 0,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) {
|
||||
let action = self
|
||||
.screenshot
|
||||
@@ -1030,6 +1137,20 @@ mod tests {
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_screenshot_delivery_removes_only_its_staging_file() {
|
||||
let root = unique_test_directory("undelivered-screenshot");
|
||||
fs::create_dir(&root).unwrap();
|
||||
let path = root.join("capture.png");
|
||||
fs::write(&path, b"capture").unwrap();
|
||||
let response = Response::success(1, serde_json::json!({"path": path}));
|
||||
|
||||
discard_undelivered_screenshot(&response);
|
||||
assert!(!path.exists());
|
||||
discard_undelivered_screenshot(&response);
|
||||
fs::remove_dir(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_socket_directory_is_private() {
|
||||
let root = unique_test_directory("private-parent");
|
||||
|
||||
+52
-1
@@ -14,7 +14,8 @@ use smithay::{
|
||||
backend::{
|
||||
allocator::Fourcc,
|
||||
renderer::{
|
||||
Bind, Color32F, ErasedContextId, Frame, ImportDma, Offscreen, Renderer, Texture,
|
||||
Bind, Color32F, ErasedContextId, ExportMem, Frame, ImportDma, Offscreen, Renderer,
|
||||
Texture,
|
||||
damage::OutputDamageTracker,
|
||||
element::{
|
||||
Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage,
|
||||
@@ -169,6 +170,56 @@ pub(crate) fn capture_resize_snapshot(
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn capture_window_buffer(
|
||||
renderer: &mut GlesRenderer,
|
||||
window: &smithay::desktop::Window,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
scale: f64,
|
||||
) -> Result<crate::handlers::screenshot::CaptureBuffer, String> {
|
||||
let snapshot = capture_resize_snapshot(renderer, window, geometry, scale, 128 * 1024 * 1024)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or("Window has no capturable content")?;
|
||||
let size = snapshot.texture.size();
|
||||
let mapping = renderer
|
||||
.copy_texture(
|
||||
&snapshot.texture,
|
||||
Rectangle::from_size(size),
|
||||
Fourcc::Abgr8888,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if mapping.format() != Some(Fourcc::Abgr8888) {
|
||||
return Err("Unsupported window readback format".into());
|
||||
}
|
||||
let bytes = (size.w as usize)
|
||||
.checked_mul(size.h as usize)
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.ok_or("Window is too large")?;
|
||||
let source = renderer
|
||||
.map_texture(&mapping)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if source.len() < bytes {
|
||||
return Err("Incomplete window readback".into());
|
||||
}
|
||||
let mut pixels = source[..bytes].to_vec();
|
||||
for pixel in pixels.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
let alpha = pixel[3] as u16;
|
||||
for channel in &mut pixel[..3] {
|
||||
*channel = if alpha == 0 {
|
||||
0
|
||||
} else {
|
||||
((*channel as u16 * 255 + alpha / 2) / alpha).min(255) as u8
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(crate::handlers::screenshot::CaptureBuffer {
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
stride: size.w as usize * 4,
|
||||
pixels,
|
||||
})
|
||||
}
|
||||
|
||||
render_elements! {
|
||||
WindowContentRenderElement<=GlesRenderer>;
|
||||
Rounded=RoundedSurfaceRenderElement,
|
||||
|
||||
+21
-7
@@ -9,9 +9,13 @@ standard file, print and application dialogs. Install both backends.
|
||||
- **Settings:** Ferese's color scheme, accent color, contrast preference and
|
||||
reduced-motion setting. Changes are signalled after valid KDL appearance
|
||||
changes; malformed saves leave the last valid preferences intact.
|
||||
- **Screenshot:** whole-screen and selected-area PNG captures, plus a screen
|
||||
color picker. Every capture asks for consent. Interactive requests offer area
|
||||
selection. Selection uses `slurp`; captures use Ferese's bounded screenshot IPC.
|
||||
- **Screenshot:** screen, selected-area, selected-window and active-window PNG
|
||||
captures, plus a screen color picker. Window captures render the selected
|
||||
toplevel and its subsurfaces in isolation, preserving transparency; occluding
|
||||
windows, separate popups, shell layers and compositor borders are excluded.
|
||||
The native window picker lists window titles and application IDs. Every capture
|
||||
asks for consent. Interactive requests offer area selection; area and color
|
||||
selection use `slurp`. Captures use Ferese's bounded screenshot IPC.
|
||||
The helper must disappear from the compositor before capture starts.
|
||||
- **Wallpaper:** a preview and confirmation, then an atomic configuration update.
|
||||
Desktop and lock-screen wallpaper can be set independently, or together.
|
||||
@@ -59,7 +63,7 @@ Matching D-Bus signatures does not establish complete feature parity.
|
||||
| Interface | Ferese integration |
|
||||
| --- | --- |
|
||||
| Settings | Native, including appearance change signals |
|
||||
| Screenshot | Native screen/area capture and PickColor; true window capture remains missing |
|
||||
| Screenshot | Native screen/area/window/active-window capture and PickColor |
|
||||
| Wallpaper | Native desktop, lock-screen and combined targets |
|
||||
| ScreenCast | Native monitors with logical geometry metadata; window sources and persistence remain missing |
|
||||
| Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK |
|
||||
@@ -76,9 +80,9 @@ The frontend supplies additional APIs such as OpenURI, network monitoring,
|
||||
document export and permission storage. Adding a similarly named backend service
|
||||
is neither required nor sufficient for those APIs.
|
||||
|
||||
Do not advertise window capture or input services until the compositor provides
|
||||
the corresponding operation. Screen crops include occluding windows and cannot
|
||||
serve as isolated window captures. GNOME's remote-input features require native
|
||||
Do not advertise window sources for ScreenCast or input services until the
|
||||
compositor provides the corresponding operation. Screen crops include occluding
|
||||
windows and cannot serve as isolated window streams. GNOME's remote-input features require native
|
||||
compositor/session work, rather than substituting successful empty replies.
|
||||
|
||||
## Validation
|
||||
@@ -98,6 +102,16 @@ and configuration conflicts in an isolated nested compositor:
|
||||
FERESE_TEST_SHORTCUTS=1 python3 scripts/tests/test_shortcuts_isolated.py
|
||||
```
|
||||
|
||||
Check window capture isolation, transparency and orientation with overlapping
|
||||
clients in a temporary nested compositor:
|
||||
|
||||
```sh
|
||||
FERESE_TEST_WINDOW_CAPTURE=1 python3 scripts/tests/test_window_capture_isolated.py
|
||||
```
|
||||
|
||||
Capture a managed window directly from the CLI with `feresectl screenshot-window
|
||||
<window-id> > capture.png`; `feresectl get-windows` lists IDs.
|
||||
|
||||
The contract test compares introspection against the installed official backend
|
||||
XML, verifies appearance changes and invalid-save retention, and checks that
|
||||
sensitive methods reject callers outside the portal frontend. It does not open
|
||||
|
||||
@@ -33,7 +33,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
return Err(format!("{}: {}", error.code, error.message).into());
|
||||
}
|
||||
|
||||
if command == "screenshot" {
|
||||
if matches!(command.as_str(), "screenshot" | "screenshot-window") {
|
||||
write_png(&response)
|
||||
} else {
|
||||
println!(
|
||||
@@ -71,6 +71,13 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
|
||||
let command = args.next().ok_or_else(usage)?;
|
||||
let positional = args.collect::<Vec<_>>();
|
||||
let payload = match command.as_str() {
|
||||
"screenshot-window" => {
|
||||
exactly_one(&command, &positional, "window ID")?;
|
||||
let window = positional[0]
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "Expected an unsigned window ID")?;
|
||||
json!({"window":window})
|
||||
}
|
||||
"focus" | "move" | "resize" => {
|
||||
exactly_one(&command, &positional, "direction")?;
|
||||
json!({ "direction": positional[0] })
|
||||
@@ -126,7 +133,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: feresectl autostart\n 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|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
|
||||
"usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\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|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -503,6 +503,7 @@ async fn start_streams(
|
||||
sources,
|
||||
multiple,
|
||||
parent: parent.into(),
|
||||
window_capture: false,
|
||||
};
|
||||
let picker = picker(&prompt).await?;
|
||||
let picker_pid = picker.id().ok_or("Missing picker process ID")?;
|
||||
|
||||
+172
-40
@@ -91,7 +91,7 @@ impl Screenshot {
|
||||
|
||||
#[zbus(property)]
|
||||
fn available_targets(&self) -> u32 {
|
||||
1 | 4
|
||||
1 | 2 | 4 | 8
|
||||
}
|
||||
|
||||
async fn screenshot(
|
||||
@@ -225,7 +225,12 @@ pub(crate) async fn ipc(
|
||||
command: &'static str,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tokio::task::spawn_blocking(move || ipc_sync(command, args))
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let path = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.ok_or("Missing runtime directory")?
|
||||
@@ -251,39 +256,21 @@ pub(crate) async fn ipc(
|
||||
return Err(error.message);
|
||||
}
|
||||
response.result.ok_or("Missing IPC result".into())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
async fn select_geometry(point: bool, windows: Option<String>) -> Result<Option<String>, String> {
|
||||
async fn select_geometry(point: bool) -> Result<Option<String>, String> {
|
||||
let mut command = crate::backend::child_command_for("slurp")?;
|
||||
if point {
|
||||
command.arg("-p");
|
||||
}
|
||||
if windows.is_some() {
|
||||
command.arg("-r");
|
||||
}
|
||||
command.stdin(if windows.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
});
|
||||
let mut child = command
|
||||
command.stdin(Stdio::null());
|
||||
let child = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let pid = child.id().ok_or("Missing selection process ID")?;
|
||||
if let Some(windows) = windows {
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input
|
||||
.write_all(windows.as_bytes())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
drop(input);
|
||||
}
|
||||
let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| "Selection timed out")?
|
||||
@@ -313,14 +300,31 @@ pub(crate) async fn wait_for_surface_removal(pid: u32) -> Result<(), String> {
|
||||
}
|
||||
|
||||
async fn capture(geometry: Option<String>) -> Result<Vec<u8>, String> {
|
||||
let result = ipc("screenshot", serde_json::json!({"geometry": geometry})).await?;
|
||||
let path = result["path"]
|
||||
.as_str()
|
||||
.ok_or("Missing screenshot path")?
|
||||
.to_owned();
|
||||
capture_command("screenshot", serde_json::json!({"geometry": geometry})).await
|
||||
}
|
||||
|
||||
async fn capture_command(
|
||||
command: &'static str,
|
||||
args: serde_json::Value,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let file = std::fs::File::open(&path).map_err(|error| error.to_string())?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
let result = ipc_sync(command, args)?;
|
||||
let path = result["path"].as_str().ok_or("Missing screenshot path")?;
|
||||
read_screenshot(path)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
fn read_screenshot(path: &str) -> Result<Vec<u8>, String> {
|
||||
struct Cleanup<'a>(&'a str);
|
||||
impl Drop for Cleanup<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(self.0);
|
||||
}
|
||||
}
|
||||
let _cleanup = Cleanup(path);
|
||||
let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_IMAGE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
@@ -329,9 +333,6 @@ async fn capture(geometry: Option<String>) -> Result<Vec<u8>, String> {
|
||||
return Err("Screenshot is too large".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option<Options>, String> {
|
||||
@@ -353,9 +354,18 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
|
||||
1
|
||||
},
|
||||
);
|
||||
if !matches!(target, 1 | 4) {
|
||||
if !matches!(target, 1 | 2 | 4 | 8) {
|
||||
return Err("Unsupported screenshot target".into());
|
||||
}
|
||||
let active_window = if target == 8 {
|
||||
Some(
|
||||
ipc("get-focused-window", serde_json::json!({})).await?["id"]
|
||||
.as_u64()
|
||||
.ok_or("No active window")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !consent(
|
||||
app,
|
||||
parent,
|
||||
@@ -368,17 +378,28 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let geometry = match target {
|
||||
1 => None,
|
||||
let bytes = match target {
|
||||
1 => capture(None).await?,
|
||||
4 => {
|
||||
let Some(geometry) = select_geometry(false, None).await? else {
|
||||
let Some(geometry) = select_geometry(false).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Some(geometry)
|
||||
capture(Some(geometry)).await?
|
||||
}
|
||||
2 | 8 => {
|
||||
let id = match active_window {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let Some(id) = select_window(app, parent).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
id
|
||||
}
|
||||
};
|
||||
capture_command("screenshot-window", serde_json::json!({"window":id})).await?
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let bytes = capture(geometry).await?;
|
||||
let directory = private_directory("screenshots")?;
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix("screenshot-")
|
||||
@@ -395,6 +416,88 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
|
||||
)])))
|
||||
}
|
||||
|
||||
fn window_sources(windows: &serde_json::Value) -> Result<Vec<crate::capture::Source>, String> {
|
||||
let windows = windows.as_array().ok_or("Invalid window list")?;
|
||||
if windows.len() > 128 {
|
||||
return Err("Too many windows to choose from".into());
|
||||
}
|
||||
let mut sources = Vec::new();
|
||||
for window in windows.iter().filter(|window| window["mapped"] == true) {
|
||||
let Some(id) = window["id"].as_u64() else {
|
||||
continue;
|
||||
};
|
||||
let title = window["title"].as_str().unwrap_or("Untitled window");
|
||||
let app = window["app_id"].as_str().unwrap_or("");
|
||||
let label = format!("{title} — {app}")
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(256)
|
||||
.collect();
|
||||
sources.push(crate::capture::Source {
|
||||
name: id.to_string(),
|
||||
label,
|
||||
width: window["width"]
|
||||
.as_i64()
|
||||
.and_then(|w| i32::try_from(w).ok())
|
||||
.unwrap_or(0),
|
||||
height: window["height"]
|
||||
.as_i64()
|
||||
.and_then(|h| i32::try_from(h).ok())
|
||||
.unwrap_or(0),
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
});
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
async fn select_window(app: &str, parent: &str) -> Result<Option<u64>, String> {
|
||||
let windows = ipc("get-windows", serde_json::json!({})).await?;
|
||||
let sources = window_sources(&windows)?;
|
||||
if sources.is_empty() {
|
||||
return Err("No windows are available".into());
|
||||
}
|
||||
let prompt = crate::picker::Prompt {
|
||||
app: app.into(),
|
||||
sources,
|
||||
multiple: false,
|
||||
parent: parent.into(),
|
||||
window_capture: true,
|
||||
};
|
||||
let mut child = crate::backend::child_command()?
|
||||
.arg("--picker")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let pid = child.id().ok_or("Missing window selector PID")?;
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
stdin
|
||||
.write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
drop(stdin);
|
||||
let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| "Window selection timed out")?
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
let names: Vec<String> =
|
||||
serde_json::from_slice(&output.stdout).map_err(|_| "Invalid window selection")?;
|
||||
if names.len() != 1 || !prompt.sources.iter().any(|source| source.name == names[0]) {
|
||||
return Err("Unknown selected window".into());
|
||||
}
|
||||
wait_for_surface_removal(pid).await?;
|
||||
names[0]
|
||||
.parse::<u64>()
|
||||
.map(Some)
|
||||
.map_err(|_| "Invalid window ID".into())
|
||||
}
|
||||
|
||||
async fn pick_color(app: &str, parent: &str) -> Result<Option<Options>, String> {
|
||||
if !consent(
|
||||
app,
|
||||
@@ -408,7 +511,7 @@ async fn pick_color(app: &str, parent: &str) -> Result<Option<Options>, String>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(geometry) = select_geometry(true, None).await? else {
|
||||
let Some(geometry) = select_geometry(true).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = capture(Some(geometry)).await?;
|
||||
@@ -654,6 +757,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_reads_remove_staging_files_on_success_and_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("capture.png");
|
||||
std::fs::write(&path, b"capture").unwrap();
|
||||
assert_eq!(read_screenshot(path.to_str().unwrap()).unwrap(), b"capture");
|
||||
assert!(!path.exists());
|
||||
|
||||
let file = std::fs::File::create(&path).unwrap();
|
||||
file.set_len(MAX_IMAGE_BYTES + 1).unwrap();
|
||||
assert!(read_screenshot(path.to_str().unwrap()).is_err());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_choices_preserve_identity_and_bound_untrusted_titles() {
|
||||
let windows = serde_json::json!([
|
||||
{"id": 1, "mapped": true, "title": "Editor\n", "app_id": "zed"},
|
||||
{"id": 2, "mapped": false, "title": "Hidden", "app_id": "foot"},
|
||||
{"id": 3, "mapped": true, "title": "x".repeat(1024)}
|
||||
]);
|
||||
let sources = window_sources(&windows).unwrap();
|
||||
assert_eq!(sources.len(), 2);
|
||||
assert_eq!(sources[0].name, "1");
|
||||
assert_eq!(sources[0].label, "Editor — zed");
|
||||
assert_eq!(sources[1].label.chars().count(), 256);
|
||||
assert!(window_sources(&serde_json::json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_color_is_normalized_rgb() {
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
+53
-8
@@ -16,6 +16,8 @@ pub struct Prompt {
|
||||
pub multiple: bool,
|
||||
#[serde(default)]
|
||||
pub parent: String,
|
||||
#[serde(default)]
|
||||
pub window_capture: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -49,11 +51,16 @@ struct Picker {
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.take(64 * 1024)
|
||||
.take(256 * 1024 + 1)
|
||||
.read_to_string(&mut input)?;
|
||||
if input.len() > 256 * 1024 {
|
||||
return Err("Picker request is too large".into());
|
||||
}
|
||||
let prompt: Prompt = serde_json::from_str(&input)?;
|
||||
|
||||
if prompt.sources.is_empty() || prompt.sources.len() > 16 {
|
||||
if prompt.sources.is_empty()
|
||||
|| prompt.sources.len() > if prompt.window_capture { 128 } else { 16 }
|
||||
{
|
||||
return Err("No shareable displays".into());
|
||||
}
|
||||
|
||||
@@ -114,7 +121,16 @@ impl cosmic::Application for Picker {
|
||||
let task = app
|
||||
.core
|
||||
.main_window_id()
|
||||
.map(|id| app.set_window_title("Ferese — share your screen".into(), id))
|
||||
.map(|id| {
|
||||
app.set_window_title(
|
||||
if app.prompt.window_capture {
|
||||
"Ferese — capture a window".into()
|
||||
} else {
|
||||
"Ferese — share your screen".into()
|
||||
},
|
||||
id,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(Task::none);
|
||||
|
||||
(app, task)
|
||||
@@ -199,8 +215,21 @@ impl cosmic::Application for Picker {
|
||||
row![
|
||||
glyph(ferese_theme::icons::DISPLAY, 24),
|
||||
column![
|
||||
self.text("Choose a display").size(18),
|
||||
self.text(format!("Share with {app}")).size(12),
|
||||
self.text(if self.prompt.window_capture {
|
||||
"Choose a window"
|
||||
} else {
|
||||
"Choose a display"
|
||||
})
|
||||
.size(18),
|
||||
self.text(format!(
|
||||
"{} {app}",
|
||||
if self.prompt.window_capture {
|
||||
"Screenshot for"
|
||||
} else {
|
||||
"Share with"
|
||||
}
|
||||
))
|
||||
.size(12),
|
||||
]
|
||||
.spacing(2)
|
||||
.width(Length::Fill),
|
||||
@@ -214,7 +243,12 @@ impl cosmic::Application for Picker {
|
||||
let mut entry = row![
|
||||
glyph(ferese_theme::icons::DISPLAY, 24),
|
||||
column![
|
||||
self.text(&source.name).size(14),
|
||||
self.text(if self.prompt.window_capture {
|
||||
&source.label
|
||||
} else {
|
||||
&source.name
|
||||
})
|
||||
.size(14),
|
||||
self.text(format!("{} × {}", source.width, source.height))
|
||||
.size(12)
|
||||
]
|
||||
@@ -242,12 +276,23 @@ impl cosmic::Application for Picker {
|
||||
(self.prompt.sources.len().min(4) * 56) as f32,
|
||||
)));
|
||||
content = content.push(
|
||||
self.text("Everything on this display, including notifications, is visible.")
|
||||
self.text(if self.prompt.window_capture {
|
||||
"Only the selected window will be captured."
|
||||
} else {
|
||||
"Everything on this display, including notifications, is visible."
|
||||
})
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
let mut share = button::custom(
|
||||
row![self.text("Share"), glyph(ferese_theme::icons::ARROW, 16)]
|
||||
row![
|
||||
self.text(if self.prompt.window_capture {
|
||||
"Capture"
|
||||
} else {
|
||||
"Share"
|
||||
}),
|
||||
glyph(ferese_theme::icons::ARROW, 16)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/* Two overlapping clients with distinct colors for isolated capture tests. */
|
||||
#define _GNU_SOURCE
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#include <wayland-client.h>
|
||||
#include "xdg-shell-client-protocol.h"
|
||||
|
||||
static struct wl_compositor *compositor;
|
||||
static struct wl_shm *shm;
|
||||
static struct xdg_wm_base *wm;
|
||||
static struct wl_surface *surface;
|
||||
static struct wl_buffer *buffer;
|
||||
|
||||
static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) {
|
||||
(void)data;
|
||||
xdg_wm_base_pong(base, serial);
|
||||
}
|
||||
static const struct xdg_wm_base_listener wm_listener = {.ping = ping};
|
||||
|
||||
static void global(void *data, struct wl_registry *registry, uint32_t name,
|
||||
const char *interface, uint32_t version) {
|
||||
(void)data; (void)version;
|
||||
if (!strcmp(interface, "wl_compositor"))
|
||||
compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4);
|
||||
else if (!strcmp(interface, "wl_shm"))
|
||||
shm = wl_registry_bind(registry, name, &wl_shm_interface, 1);
|
||||
else if (!strcmp(interface, "xdg_wm_base")) {
|
||||
wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1);
|
||||
xdg_wm_base_add_listener(wm, &wm_listener, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
static void removed(void *data, struct wl_registry *registry, uint32_t name) {
|
||||
(void)data; (void)registry; (void)name;
|
||||
}
|
||||
static const struct wl_registry_listener registry_listener = {global, removed};
|
||||
|
||||
static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) {
|
||||
(void)data;
|
||||
xdg_surface_ack_configure(xdg, serial);
|
||||
xdg_surface_set_window_geometry(xdg, 0, 0, 640, 480);
|
||||
wl_surface_attach(surface, buffer, 0, 0);
|
||||
wl_surface_damage(surface, 0, 0, 640, 480);
|
||||
wl_surface_commit(surface);
|
||||
}
|
||||
static const struct xdg_surface_listener surface_listener = {.configure = configure};
|
||||
|
||||
static void size(void *data, struct xdg_toplevel *top, int32_t w, int32_t h,
|
||||
struct wl_array *states) {
|
||||
(void)data; (void)top; (void)w; (void)h; (void)states;
|
||||
}
|
||||
|
||||
static void close_window(void *data, struct xdg_toplevel *top) {
|
||||
(void)data; (void)top; exit(0);
|
||||
}
|
||||
static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window};
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
struct wl_display *display = wl_display_connect(NULL);
|
||||
if (!display) return 1;
|
||||
struct wl_registry *registry = wl_display_get_registry(display);
|
||||
wl_registry_add_listener(registry, ®istry_listener, NULL);
|
||||
if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm) return 2;
|
||||
int fd = memfd_create("floating-size-test", MFD_CLOEXEC);
|
||||
size_t bytes = 640 * 480 * 4;
|
||||
if (fd < 0 || ftruncate(fd, bytes)) return 3;
|
||||
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
if (pixels == MAP_FAILED) return 4;
|
||||
int covered = argc > 1 && !strcmp(argv[1], "cover");
|
||||
for (size_t i = 0; i < 640 * 480; i++) {
|
||||
pixels[i] = covered ? 0xff00ff00 : (i % 640 < 80 ? 0x80800000 : (i / 640 < 240 ? 0xffff0000 : 0xff0000ff));
|
||||
}
|
||||
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
|
||||
buffer = wl_shm_pool_create_buffer(pool, 0, 640, 480, 640 * 4, WL_SHM_FORMAT_ARGB8888);
|
||||
wl_shm_pool_destroy(pool);
|
||||
close(fd);
|
||||
surface = wl_compositor_create_surface(compositor);
|
||||
struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface);
|
||||
xdg_surface_add_listener(xdg, &surface_listener, NULL);
|
||||
struct xdg_toplevel *top = xdg_surface_get_toplevel(xdg);
|
||||
xdg_toplevel_add_listener(top, &top_listener, NULL);
|
||||
xdg_toplevel_set_app_id(top, "ferese.test.window-capture");
|
||||
xdg_toplevel_set_title(top, covered ? "Occluding window" : "Capture target");
|
||||
wl_surface_commit(surface);
|
||||
while (wl_display_dispatch(display) != -1) {}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Verify isolated window capture without exposing host desktop content.
|
||||
|
||||
FERESE_TEST_WINDOW_CAPTURE=1 python3 scripts/tests/test_window_capture_isolated.py
|
||||
Requires built debug binaries, a Wayland host, cc, wayland-scanner and Pillow.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FERESE_TEST_WINDOW_CAPTURE") == "1", "requires a Wayland host")
|
||||
class WindowCapture(unittest.TestCase):
|
||||
def test_occluding_window_does_not_appear_and_rows_keep_orientation(self):
|
||||
from PIL import Image
|
||||
processes = []
|
||||
with tempfile.TemporaryDirectory(prefix="ferese-window-capture-") as directory:
|
||||
root = Path(directory)
|
||||
protocols = subprocess.check_output(["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip()
|
||||
xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml")
|
||||
for kind, output in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]:
|
||||
subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True)
|
||||
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split()
|
||||
subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(REPO / "scripts/tests/fixtures/window-capture.c"),
|
||||
str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True)
|
||||
runtime = root / "runtime"
|
||||
runtime.mkdir(mode=0o700)
|
||||
config = root / "config/ferese/config.kdl"
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n')
|
||||
display = Path(os.environ["WAYLAND_DISPLAY"])
|
||||
if not display.is_absolute():
|
||||
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1")
|
||||
env.pop("WAYLAND_SOCKET", None)
|
||||
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
|
||||
log = (root / "compositor.log").open("w")
|
||||
try:
|
||||
compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
|
||||
processes.append(compositor)
|
||||
deadline = time.monotonic() + 10
|
||||
while not (runtime / "ferese/control.sock").exists():
|
||||
if compositor.poll() is not None or time.monotonic() > deadline:
|
||||
self.fail((root / "compositor.log").read_text())
|
||||
time.sleep(0.02)
|
||||
sockets = list(runtime.glob("wayland-*"))
|
||||
env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock")))
|
||||
|
||||
def call(*args):
|
||||
return subprocess.check_output([str(REPO / "target/debug/feresectl"), *args], env=env, timeout=10)
|
||||
|
||||
def windows(count):
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
result = json.loads(call("get-windows"))
|
||||
if len(result) == count and all(window["mapped"] and window["width"] == 640 for window in result):
|
||||
return result
|
||||
time.sleep(0.02)
|
||||
self.fail((root / "compositor.log").read_text())
|
||||
|
||||
processes.append(subprocess.Popen([str(root / "client")], env=env, stdout=log, stderr=log, start_new_session=True))
|
||||
target = windows(1)[0]
|
||||
processes.append(subprocess.Popen([str(root / "client"), "cover"], env=env, stdout=log, stderr=log, start_new_session=True))
|
||||
current = windows(2)
|
||||
cover = next(window for window in current if window["title"] == "Occluding window")
|
||||
self.assertEqual((target["x"], target["y"], target["width"], target["height"]),
|
||||
(cover["x"], cover["y"], cover["width"], cover["height"]))
|
||||
scale = json.loads(call("get-outputs"))[0]["scale"]
|
||||
with Image.open(io.BytesIO(call("screenshot-window", str(target["id"])))) as image:
|
||||
self.assertEqual(image.size, (round(640 * scale), round(480 * scale)))
|
||||
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 4)), (255, 0, 0))
|
||||
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height * 3 // 4)), (0, 0, 255))
|
||||
self.assertEqual(image.convert("RGBA").getpixel((round(40 * scale), round(80 * scale))), (255, 0, 0, 128))
|
||||
with Image.open(io.BytesIO(call("screenshot-window", str(cover["id"])))) as image:
|
||||
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 2)), (0, 255, 0))
|
||||
finally:
|
||||
for process in reversed(processes):
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
process.wait(timeout=5)
|
||||
log.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user