diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e92721b..51ee599 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,13 +1,5 @@ name: Deploy website to GitHub Pages on: - push: - branches: [main] - paths: - - "site/**" - - "docs/**" - - "assets/branding/**" - - "scripts/build-site.py" - - ".github/workflows/pages.yml" workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 7f0142c..db0cabc 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -1,8 +1,5 @@ name: Workspace release checks on: - pull_request: - push: - branches: [main] workflow_dispatch: workflow_call: permissions: @@ -68,6 +65,11 @@ jobs: cargo test --locked -p ferese state::capture_privacy::tests -- --ignored cargo test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored cargo test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored + - name: Test recorder audio, cropping and stream revocation + run: | + cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record encoder_finishes_a_real_webm -- --ignored + cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record encoder_saves_cropped_video_and_mixed_audio_when_stopped -- --ignored + cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record video_eos_finishes_live_audio_without_waiting_for_the_user -- --ignored - name: Test native shell, visibility and resize behavior run: | install -m 0644 packaging/pam.d/ferese-lock.fedora /etc/pam.d/ferese-lock diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58c8239..5acf374 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,6 +166,17 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} run: | cd artifacts + # GitHub replaces '~' in uploaded filenames. Use those same download + # names in SHA256SUMS so verification works after downloading RPMs. + python3 - <<'PY' + from pathlib import Path + for source in Path('.').glob('*.rpm'): + destination = source.with_name(source.name.replace('~', '.')) + if source != destination: + if destination.exists(): + raise SystemExit(f'Duplicate release asset: {destination}') + source.rename(destination) + PY rm SHA256SUMS sha256sum * > SHA256SUMS gh release upload "$RELEASE_TAG" ./* --clobber diff --git a/.github/workflows/workspace-checks.yml b/.github/workflows/workspace-checks.yml index 034af8e..2ef53ac 100644 --- a/.github/workflows/workspace-checks.yml +++ b/.github/workflows/workspace-checks.yml @@ -1,38 +1,5 @@ name: Workspace and native stability checks on: - pull_request: - paths: - - "compositor/**" - - "crates/**" - - "shell/**" - - "portal/**" - - "feresectl/**" - - "scripts/**" - - "packaging/**" - - "assets/**" - - "LICENSE" - - "rust-toolchain.toml" - - "rustfmt.toml" - - "Cargo.toml" - - "Cargo.lock" - - ".github/workflows/workspace-checks.yml" - push: - branches: [main] - paths: - - "compositor/**" - - "crates/**" - - "shell/**" - - "portal/**" - - "feresectl/**" - - "scripts/**" - - "packaging/**" - - "assets/**" - - "LICENSE" - - "rust-toolchain.toml" - - "rustfmt.toml" - - "Cargo.toml" - - "Cargo.lock" - - ".github/workflows/workspace-checks.yml" workflow_dispatch: permissions: contents: read diff --git a/docs/development.md b/docs/development.md index 8978ee8..389efa1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,6 +15,10 @@ Use release builds for performance and animation checks. Preview changes in a [nested session](installation.md#preview-and-logs) before testing hardware behavior. Keep another desktop available for direct-session recovery. +Run CI manually when a change needs it. Ordinary pushes and pull requests do not +start build runners, and website deployment is manual. Release tags and pull +requests labeled `release` run the release checks and package builds. + CI checks formatting, lints and tests across the whole workspace, including the shell, settings, portal and CLI. The Fedora workspace job also builds desktop binaries and runs installer, session-recovery and private portal-contract checks. The @@ -73,6 +77,8 @@ Check real encoding and the private InputCapture transport: ```sh cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record encoder_finishes_a_real_webm -- --ignored +cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record encoder_saves_cropped_video_and_mixed_audio_when_stopped -- --ignored +cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record video_eos_finishes_live_audio_without_waiting_for_the_user -- --ignored cargo test --locked -p ferese input_capture::tests cargo test --locked -p xdg-desktop-portal-ferese eis::tests -- --ignored ``` diff --git a/docs/installation.md b/docs/installation.md index 5f5d2e3..af5225e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -25,7 +25,7 @@ sudo dnf install python3 git curl gcc gcc-c++ make cmake pkgconf-pkg-config \ freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam \ clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs \ xdg-desktop-portal xdg-desktop-portal-gtk \ - gstreamer1-devel gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer + gstreamer1-devel gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer pipewire-pulseaudio slurp ``` ### Arch Linux @@ -35,7 +35,7 @@ sudo pacman -S --needed python base-devel git curl cmake pkgconf wayland libxkbc libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \ desktop-file-utils foot pam clang pipewire libpipewire wireplumber polkit \ xdg-desktop-portal xdg-desktop-portal-gtk \ - gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire + gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire pipewire-pulse slurp ``` ### Debian and Ubuntu @@ -49,7 +49,7 @@ sudo apt install python3 build-essential git curl cmake pkg-config libwayland-de libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber polkitd libpolkit-agent-1-0 \ xdg-desktop-portal xdg-desktop-portal-gtk \ libgstreamer1.0-dev gstreamer1.0-pipewire gstreamer1.0-plugins-base \ - gstreamer1.0-plugins-good + gstreamer1.0-plugins-good pipewire-pulse slurp ``` Installation has been tested on Fedora. Package names or versions may differ on diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 4c364fc..2c7b3f7 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -7,12 +7,17 @@ portal and PipeWire dependencies. ## Record from the top bar 1. Click the recording icon. -2. Select a monitor and click **Share**. -3. Click the stop square to finish. +2. Choose a display and an audio source: **Off**, **System sound**, **Microphone**, + or **Both**. Audio is off by default; the other choices use + your default output or input device from audio settings. +3. Click **Record** to capture the whole display. For part of it, turn on + **Select a region**, click **Select region**, then drag a rectangle inside + the chosen display. Press Escape to cancel selection. +4. Click the stop square to finish. -The recorder saves VP8 WebM files under `Ferese` in your XDG Videos folder, then -shows the path in a notification. The built-in recorder does not support audio or -region recording; other recording apps can handle audio separately. +The recorder saves WebM files with VP8 video and, when selected, Opus audio under +`Ferese` in your XDG Videos folder, then shows the path in a notification. Region +recordings cover a rectangle within one display; they cannot span displays. If recording fails, a notification reports the error and any unfinished `.webm.part` file. @@ -50,7 +55,10 @@ permissions are not saved. Stopping a stream does not erase a saved permission. ## Requirements and limits Install PipeWire, WirePlumber, `xdg-desktop-portal`, and `xdg-desktop-portal-gtk`. -The built-in recorder also needs GStreamer's PipeWire, base, and good plugins; +The built-in recorder also needs GStreamer's PipeWire, base, and good plugins. +Region selection needs `slurp`; audio recording needs PipeWire-Pulse or PulseAudio +and the GStreamer PulseAudio and Opus plugins. With audio set to Off, the recorder +does not open an audio device. For distribution package names, see the [package lists](installation.md#requirements). The installer supplies Ferese's portal backend and selects it for screen sharing. diff --git a/packaging/arch/PKGBUILD.in b/packaging/arch/PKGBUILD.in index 3af303d..89ec0f6 100644 --- a/packaging/arch/PKGBUILD.in +++ b/packaging/arch/PKGBUILD.in @@ -10,10 +10,11 @@ depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libd 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'desktop-file-utils') checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') -optdepends=('brightnessctl: brightness controls' 'slurp: screenshot region selection' +optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection' 'satty: screenshot editing' 'wl-clipboard: screenshot clipboard' 'gst-plugins-base: recording codecs' 'gst-plugins-good: recording codecs' 'gst-plugin-pipewire: PipeWire recording' + 'pipewire-pulse: system sound and microphone recording' 'xorg-xwayland: X11 applications' 'xwayland-satellite: X11 application integration (requires readiness support)') # GCC LTO objects from the SPA C wrappers cannot be linked by Rust's lld. options=('!lto') diff --git a/packaging/arch/ferese-git/PKGBUILD b/packaging/arch/ferese-git/PKGBUILD index da12f3c..b9add9b 100644 --- a/packaging/arch/ferese-git/PKGBUILD +++ b/packaging/arch/ferese-git/PKGBUILD @@ -10,10 +10,11 @@ depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libd 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'git' 'desktop-file-utils') checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') -optdepends=('brightnessctl: brightness controls' 'slurp: screenshot region selection' +optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection' 'satty: screenshot editing' 'wl-clipboard: screenshot clipboard' 'gst-plugins-base: recording codecs' 'gst-plugins-good: recording codecs' 'gst-plugin-pipewire: PipeWire recording' + 'pipewire-pulse: system sound and microphone recording' 'xorg-xwayland: X11 applications' 'xwayland-satellite: X11 application integration (requires readiness support)') provides=('ferese') conflicts=('ferese') diff --git a/packaging/fedora/ferese.spec.in b/packaging/fedora/ferese.spec.in index a1462b7..534796d 100644 --- a/packaging/fedora/ferese.spec.in +++ b/packaging/fedora/ferese.spec.in @@ -28,6 +28,7 @@ Requires: xdg-desktop-portal xdg-desktop-portal-gtk Requires: libEGL.so.1()(64bit) fontconfig freetype Recommends: brightnessctl slurp wl-clipboard satty Recommends: gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer +Recommends: pipewire-pulseaudio %description Ferese includes a scrolling Wayland compositor, desktop shell, Settings, diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 052eb3f..30f3d66 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -80,6 +80,7 @@ struct Session { cursor: bool, source_types: u32, bar_controlled: bool, + recording: Option, persist_mode: u32, restore: Option, cancel: Arc, @@ -107,23 +108,31 @@ struct RecorderControl(Backend); #[zbus::interface(name = "org.ferese.ScreenRecorder")] impl RecorderControl { + async fn recording_options( + &self, + session_handle: OwnedObjectPath, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result { + authorize_recorder(&session_handle, connection, &header).await?; + let sessions = self.0.sessions.lock().await; + let session = sessions + .get(session_handle.as_str()) + .ok_or_else(|| error("Unknown session"))?; + if !session.bar_controlled || session.phase != Phase::Running { + return Err(error("Recording has not started")); + } + serde_json::to_string(&session.recording.ok_or_else(|| error("No recording options"))?) + .map_err(|e| error(&e.to_string())) + } + async fn use_bar_controls( &self, session_handle: OwnedObjectPath, #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { - let sender = header.sender().ok_or_else(|| error("Missing recorder caller"))?; - if !owns_session(sender.as_str(), session_handle.as_str()) { - return Err(zbus::fdo::Error::AccessDenied("Not your recording session".into())); - } - let dbus = zbus::fdo::DBusProxy::new(connection).await?; - let pid = dbus.get_connection_unix_process_id(sender.clone().into()).await?; - if !shell_recorder(pid) { - return Err(zbus::fdo::Error::AccessDenied( - "The shell must own the recording controls".into(), - )); - } + authorize_recorder(&session_handle, connection, &header).await?; let mut sessions = self.0.sessions.lock().await; let session = sessions .get_mut(session_handle.as_str()) @@ -136,6 +145,25 @@ impl RecorderControl { } } +async fn authorize_recorder( + handle: &OwnedObjectPath, + connection: &Connection, + header: &Header<'_>, +) -> zbus::fdo::Result<()> { + let sender = header.sender().ok_or_else(|| error("Missing recorder caller"))?; + if !owns_session(sender.as_str(), handle.as_str()) { + return Err(zbus::fdo::Error::AccessDenied("Not your recording session".into())); + } + let dbus = zbus::fdo::DBusProxy::new(connection).await?; + let pid = dbus.get_connection_unix_process_id(sender.clone().into()).await?; + if !shell_recorder(pid) { + return Err(zbus::fdo::Error::AccessDenied( + "The shell must own the recording controls".into(), + )); + } + Ok(()) +} + fn owns_session(sender: &str, path: &str) -> bool { let Some(sender) = sender.strip_prefix(':') else { return false; @@ -299,6 +327,7 @@ impl Backend { cursor: false, source_types: 1, bar_controlled: false, + recording: None, persist_mode: 0, restore: None, cancel: Arc::default(), @@ -433,9 +462,10 @@ impl Backend { let _ = connection.object_server().remove::(handle).await; match result { - Ok((children, streams, mut reply)) if !cancel.stopped.load(Ordering::SeqCst) => { + Ok((children, streams, mut reply, recording)) if !cancel.stopped.load(Ordering::SeqCst) => { if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) { session.phase = Phase::Running; + session.recording = recording; } let backend = self.clone(); let connection = connection.clone(); @@ -604,7 +634,15 @@ async fn start_streams( app: &str, parent: &str, options: StreamOptions, -) -> Result<(Vec, Vec<(u32, Options)>, Options), StartError> { +) -> Result< + ( + Vec, + Vec<(u32, Options)>, + Options, + Option, + ), + StartError, +> { let StreamOptions { multiple, cursor, @@ -643,8 +681,8 @@ async fn start_streams( .ok()?; Some((selected, restore.effective_mode(requested_persistence))) }); - let (selected, persist_mode) = if let Some(restored) = restored { - restored + let (selected, persist_mode, audio, region) = if let Some((selected, persist_mode)) = restored { + (selected, persist_mode, crate::recording::Audio::Off, None) } else { let prompt = Prompt { app: if bar_controlled { "Ferese" } else { app }.to_owned(), @@ -653,6 +691,7 @@ async fn start_streams( rememberable: rememberable.clone(), parent: parent.into(), window_capture: false, + recording: bar_controlled, persist_mode: if rememberable.is_empty() { 0 } else { @@ -671,6 +710,9 @@ async fn start_streams( return Err("Invalid persistence selection".into()); } let selected = validate_selection(&prompt.sources, &selection.names, multiple)?; + if !bar_controlled && (selection.audio != crate::recording::Audio::Off || selection.region) { + return Err("Recording options are only available to the native recorder".into()); + } crate::desktop::wait_for_surface_removal_using(|| { capture_ipc( &cancel, @@ -681,7 +723,34 @@ async fn start_streams( }) .await?; - (selected, selection.persist_mode) + let region = if selection.region { + if selected.len() != 1 || selected[0].window_id().is_some() { + return Err("Region recording requires one display".into()); + } + let mut command = child_command_for("slurp")?; + let child = command + .args(["-f", "%x,%y %wx%h"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| format!("Cannot select a recording region; install slurp: {e}"))?; + let pid = child.id().ok_or("Missing region selector process ID")?; + let selection = child.wait_with_output().await.map_err(|e| e.to_string())?; + if !selection.status.success() { + return Err(StartError::Cancelled); + } + crate::desktop::wait_for_surface_removal_using(|| { + capture_ipc(&cancel, &work, "has-client-surfaces", serde_json::json!({"pid": pid})) + }) + .await?; + Some(crate::recording_region::Region::parse( + std::str::from_utf8(&selection.stdout).map_err(|_| "Invalid region selection")?, + )?) + } else { + None + }; + (selected, selection.persist_mode, selection.audio, region) }; let mut reply = Options::new(); @@ -776,6 +845,28 @@ async fn start_streams( } let final_windows = capture_ipc(&cancel, &work, "get-windows", serde_json::json!({})).await?; + let recording = if bar_controlled { + let crop = region + .map(|region| { + let (name, ready) = streams.first().ok_or("Missing recording display")?; + let metadata = monitor_metadata(&final_outputs, name, ready)?; + let (x, y) = <(i32, i32)>::try_from(metadata["position"].try_clone().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let (width, height) = <(i32, i32)>::try_from(metadata["size"].try_clone().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + region.crop( + crate::recording_region::Region { x, y, width, height }, + (ready.width, ready.height), + ) + }) + .transpose()?; + if let Some(crop) = crop { + crop.validate()?; + } + Some(crate::recording::Recording { audio, crop }) + } else { + None + }; let streams = streams .into_iter() .map(|(name, ready)| { @@ -799,7 +890,7 @@ async fn start_streams( }) .collect::, _>>()?; - Ok((children, streams, reply)) + Ok((children, streams, reply, recording)) } fn window_metadata(source: &Source, ready: &Ready) -> Result { diff --git a/portal/src/desktop.rs b/portal/src/desktop.rs index 2cb7a1a..53a1852 100644 --- a/portal/src/desktop.rs +++ b/portal/src/desktop.rs @@ -19,8 +19,8 @@ use crate::consent::Prompt; const PATH: &str = "/org/freedesktop/portal/desktop"; const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024; -type Reply = (u32, Options); +type Reply = (u32, Options); type RequestMap = HashMap)>; #[derive(Clone)] @@ -52,14 +52,17 @@ impl Requests { handle: &OwnedObjectPath, ) -> zbus::fdo::Result> { let owner = authorize(connection, header).await?; + if !handle.as_str().starts_with(&format!("{PATH}/request/")) { return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into())); } let mut pending = self.0.lock().await; + if pending.len() >= self.1 || pending.contains_key(handle.as_str()) { return Err(zbus::fdo::Error::LimitsExceeded("Request limit reached".into())); } let cancel = Arc::new(Cancel::default()); + if !connection .object_server() .at( @@ -74,6 +77,7 @@ impl Requests { return Err(zbus::fdo::Error::InvalidArgs("Request already exists".into())); } pending.insert(handle.to_string(), (owner, cancel.clone())); + Ok(cancel) } @@ -113,7 +117,9 @@ impl Screenshot { _ = cancel.wait() => (1, Options::new()), result = screenshot(&app_id, &parent_window, &options) => reply(result), }; + self.0.end(connection, &handle).await; + Ok(result) } @@ -132,7 +138,9 @@ impl Screenshot { _ = cancel.wait() => (1, Options::new()), result = pick_color(&app_id, &parent_window) => reply(result), }; + self.0.end(connection, &handle).await; + Ok(result) } } @@ -163,7 +171,9 @@ impl Wallpaper { _ = cancel.wait() => 1, result = wallpaper(&app_id, &parent_window, &uri, &options, cancel.clone()) => reply(result).0, }; + self.0.end(connection, &handle).await; + Ok(result) } } @@ -190,6 +200,7 @@ pub(crate) async fn consent( if app.len() > 512 { return Err("Application ID is too long".into()); } + let prompt = Prompt { parent: parent.into(), title: title.into(), @@ -212,15 +223,18 @@ pub(crate) async fn consent( .map_err(|error| error.to_string())?; let pid = child.id().ok_or("Missing consent process ID")?; let mut input = child.stdin.take().unwrap(); + input .write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?) .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(|_| "Consent timed out")? .map_err(|error| error.to_string())?; + if output.status.success() && output.stdout == b"true\n" { wait_for_surface_removal(pid).await?; Ok(true) @@ -238,12 +252,14 @@ pub(crate) async fn ipc(command: &'static str, args: serde_json::Value) -> Resul pub(crate) fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result { let path = ferese_ipc::socket::resolve(None).map_err(|error| error.to_string())?; let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?; + stream .set_read_timeout(Some(Duration::from_secs(15))) .map_err(|error| error.to_string())?; stream .set_write_timeout(Some(Duration::from_secs(2))) .map_err(|error| error.to_string())?; + let request = ferese_ipc::Request { version: ferese_ipc::VERSION, id: 1, @@ -251,18 +267,22 @@ pub(crate) fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result command: command.into(), args, }; + exchange(&mut stream, &request) } fn exchange(stream: &mut UnixStream, request: &ferese_ipc::Request) -> Result { ferese_ipc::write_frame(stream, request).map_err(|error| error.to_string())?; + let response: ferese_ipc::Response = ferese_ipc::read_frame(stream).map_err(|error| error.to_string())?; if response.id != request.id || response.version != ferese_ipc::VERSION { return Err("Unexpected compositor response ID or protocol version".into()); } + if let Some(error) = response.error { return Err(error.message); } + response.result.ok_or("Missing IPC result".into()) } @@ -272,6 +292,7 @@ async fn select_geometry(point: bool) -> Result, String> { command.arg("-p"); } command.stdin(Stdio::null()); + let child = command .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -286,6 +307,7 @@ async fn select_geometry(point: bool) -> Result, String> { if !output.status.success() { return Ok(None); } + let geometry = String::from_utf8(output.stdout) .map_err(|error| error.to_string())? .trim() @@ -293,7 +315,9 @@ async fn select_geometry(point: bool) -> Result, String> { if geometry.len() > 128 || geometry.is_empty() { return Err("Invalid selection".into()); } + wait_for_surface_removal(pid).await?; + Ok(Some(geometry)) } @@ -310,6 +334,7 @@ pub(crate) async fn wait_for_surface_removal_using< if check().await? == false { return Ok(()); } + tokio::time::sleep(Duration::from_millis(10)).await; } Err("Screenshot helper is still visible".into()) @@ -336,15 +361,18 @@ fn read_screenshot(path: &str) -> Result, String> { 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) .map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_IMAGE_BYTES { return Err("Screenshot is too large".into()); } + Ok(bytes) } @@ -370,6 +398,7 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result