From 42c3b290dd5519b1bb61c013bfdebcdd4aa2dcaf Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 11 Oct 2026 15:37:51 +0100 Subject: [PATCH] Add audio and region recording; make routine CI manual (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-bar recorder previously saved a full monitor without sound. It now lets you select a region within one monitor and choose system audio, microphone audio, or both. Audio remains off by default, and recordings with audio are saved as VP8/Opus WebM files. The portal still controls screen access and cancellation; stopping from the bar or ending the video stream finishes the recording, including its audio tracks. Region selection accounts for fractional display scales and negative monitor coordinates. The picker keeps its action buttons visible while the display list and recording options scroll together. The picker, dependency lists, and recording documentation now explain the available choices and the slurp and audio requirements. Routine pushes and pull requests no longer start CI runners. Workspace checks and website deployment are manual, while release checks run when requested manually, through a version tag, or through a PR labeled `release`. Publishing still requires a pushed version tag. This also fixes release asset checksums by normalizing RPM filenames before generating the checksum file. Validated locally with a release build, portal and shell recorder tests, formatting, Clippy, actionlint, packaging tests, and mocked publishing checks. Media tests encoded and decoded cropped video with audio, checked that video termination finishes live audio, and exercised system, microphone, and combined capture against a private PipeWire-Pulse server. Mixed recordings must contain both fixture frequencies, with negative controls for either missing source. The picker was also checked on 800 × 600 and 800 × 400 desktops and opened in a live nested window. --- .github/workflows/pages.yml | 8 - .github/workflows/release-checks.yml | 8 +- .github/workflows/release.yml | 11 + .github/workflows/workspace-checks.yml | 33 -- docs/development.md | 6 + docs/installation.md | 6 +- docs/screen-sharing.md | 20 +- packaging/arch/PKGBUILD.in | 3 +- packaging/arch/ferese-git/PKGBUILD | 3 +- packaging/fedora/ferese.spec.in | 1 + portal/src/backend.rs | 125 ++++++- portal/src/desktop.rs | 39 ++- portal/src/main.rs | 2 + portal/src/picker.rs | 138 ++++++-- portal/src/record_main.rs | 1 + portal/src/recorder.rs | 431 +++++++++++++++++++++++-- portal/src/recording.rs | 52 +++ portal/src/recording_region.rs | 137 ++++++++ shell/ferese-shell/src/recording.rs | 4 +- 19 files changed, 898 insertions(+), 130 deletions(-) create mode 100644 portal/src/recording.rs create mode 100644 portal/src/recording_region.rs 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