Add audio and region recording; make routine CI manual (#25)

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.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-11 15:37:51 +01:00
1 parent 1b9fabbbe4
commit 42c3b290dd
19 files changed
+898 -130

No files matched your search

-8
View File
@@ -1,13 +1,5 @@
name: Deploy website to GitHub Pages name: Deploy website to GitHub Pages
on: on:
push:
branches: [main]
paths:
- "site/**"
- "docs/**"
- "assets/branding/**"
- "scripts/build-site.py"
- ".github/workflows/pages.yml"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
+5 -3
View File
@@ -1,8 +1,5 @@
name: Workspace release checks name: Workspace release checks
on: on:
pull_request:
push:
branches: [main]
workflow_dispatch: workflow_dispatch:
workflow_call: workflow_call:
permissions: permissions:
@@ -68,6 +65,11 @@ jobs:
cargo test --locked -p ferese state::capture_privacy::tests -- --ignored 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 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 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 - name: Test native shell, visibility and resize behavior
run: | run: |
install -m 0644 packaging/pam.d/ferese-lock.fedora /etc/pam.d/ferese-lock install -m 0644 packaging/pam.d/ferese-lock.fedora /etc/pam.d/ferese-lock
+11
View File
@@ -166,6 +166,17 @@ jobs:
RELEASE_TAG: ${{ github.ref_name }} RELEASE_TAG: ${{ github.ref_name }}
run: | run: |
cd artifacts 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 rm SHA256SUMS
sha256sum * > SHA256SUMS sha256sum * > SHA256SUMS
gh release upload "$RELEASE_TAG" ./* --clobber gh release upload "$RELEASE_TAG" ./* --clobber
-33
View File
@@ -1,38 +1,5 @@
name: Workspace and native stability checks name: Workspace and native stability checks
on: 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: workflow_dispatch:
permissions: permissions:
contents: read contents: read
+6
View File
@@ -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. [nested session](installation.md#preview-and-logs) before testing hardware behavior.
Keep another desktop available for direct-session recovery. 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 CI checks formatting, lints and tests across the whole workspace, including the
shell, settings, portal and CLI. The Fedora workspace job also builds desktop shell, settings, portal and CLI. The Fedora workspace job also builds desktop
binaries and runs installer, session-recovery and private portal-contract checks. The 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 ```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_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 ferese input_capture::tests
cargo test --locked -p xdg-desktop-portal-ferese eis::tests -- --ignored cargo test --locked -p xdg-desktop-portal-ferese eis::tests -- --ignored
``` ```
+3 -3
View File
@@ -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 \ freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam \
clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs \ clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs \
xdg-desktop-portal xdg-desktop-portal-gtk \ 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 ### 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 \ libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \
desktop-file-utils foot pam clang pipewire libpipewire wireplumber polkit \ desktop-file-utils foot pam clang pipewire libpipewire wireplumber polkit \
xdg-desktop-portal xdg-desktop-portal-gtk \ 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 ### 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 \ libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber polkitd libpolkit-agent-1-0 \
xdg-desktop-portal xdg-desktop-portal-gtk \ xdg-desktop-portal xdg-desktop-portal-gtk \
libgstreamer1.0-dev gstreamer1.0-pipewire gstreamer1.0-plugins-base \ 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 Installation has been tested on Fedora. Package names or versions may differ on
+14 -6
View File
@@ -7,12 +7,17 @@ portal and PipeWire dependencies.
## Record from the top bar ## Record from the top bar
1. Click the recording icon. 1. Click the recording icon.
2. Select a monitor and click **Share**. 2. Choose a display and an audio source: **Off**, **System sound**, **Microphone**,
3. Click the stop square to finish. 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 The recorder saves WebM files with VP8 video and, when selected, Opus audio under
shows the path in a notification. The built-in recorder does not support audio or `Ferese` in your XDG Videos folder, then shows the path in a notification. Region
region recording; other recording apps can handle audio separately. recordings cover a rectangle within one display; they cannot span displays.
If recording fails, a notification reports the error and any unfinished If recording fails, a notification reports the error and any unfinished
`.webm.part` file. `.webm.part` file.
@@ -50,7 +55,10 @@ permissions are not saved. Stopping a stream does not erase a saved permission.
## Requirements and limits ## Requirements and limits
Install PipeWire, WirePlumber, `xdg-desktop-portal`, and `xdg-desktop-portal-gtk`. 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). see the [package lists](installation.md#requirements).
The installer supplies Ferese's portal backend and selects it for screen sharing. The installer supplies Ferese's portal backend and selects it for screen sharing.
+2 -1
View File
@@ -10,10 +10,11 @@ depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libd
'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk') 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk')
makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'desktop-file-utils') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'desktop-file-utils')
checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') 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' 'satty: screenshot editing' 'wl-clipboard: screenshot clipboard'
'gst-plugins-base: recording codecs' 'gst-plugins-good: recording codecs' 'gst-plugins-base: recording codecs' 'gst-plugins-good: recording codecs'
'gst-plugin-pipewire: PipeWire recording' 'gst-plugin-pipewire: PipeWire recording'
'pipewire-pulse: system sound and microphone recording'
'xorg-xwayland: X11 applications' 'xwayland-satellite: X11 application integration (requires readiness support)') '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. # GCC LTO objects from the SPA C wrappers cannot be linked by Rust's lld.
options=('!lto') options=('!lto')
+2 -1
View File
@@ -10,10 +10,11 @@ depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libd
'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk') 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk')
makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'git' 'desktop-file-utils') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'git' 'desktop-file-utils')
checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') 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' 'satty: screenshot editing' 'wl-clipboard: screenshot clipboard'
'gst-plugins-base: recording codecs' 'gst-plugins-good: recording codecs' 'gst-plugins-base: recording codecs' 'gst-plugins-good: recording codecs'
'gst-plugin-pipewire: PipeWire recording' 'gst-plugin-pipewire: PipeWire recording'
'pipewire-pulse: system sound and microphone recording'
'xorg-xwayland: X11 applications' 'xwayland-satellite: X11 application integration (requires readiness support)') 'xorg-xwayland: X11 applications' 'xwayland-satellite: X11 application integration (requires readiness support)')
provides=('ferese') provides=('ferese')
conflicts=('ferese') conflicts=('ferese')
+1
View File
@@ -28,6 +28,7 @@ Requires: xdg-desktop-portal xdg-desktop-portal-gtk
Requires: libEGL.so.1()(64bit) fontconfig freetype Requires: libEGL.so.1()(64bit) fontconfig freetype
Recommends: brightnessctl slurp wl-clipboard satty Recommends: brightnessctl slurp wl-clipboard satty
Recommends: gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer Recommends: gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer
Recommends: pipewire-pulseaudio
%description %description
Ferese includes a scrolling Wayland compositor, desktop shell, Settings, Ferese includes a scrolling Wayland compositor, desktop shell, Settings,
+108 -17
View File
@@ -80,6 +80,7 @@ struct Session {
cursor: bool, cursor: bool,
source_types: u32, source_types: u32,
bar_controlled: bool, bar_controlled: bool,
recording: Option<crate::recording::Recording>,
persist_mode: u32, persist_mode: u32,
restore: Option<crate::restore::Restore>, restore: Option<crate::restore::Restore>,
cancel: Arc<Cancel>, cancel: Arc<Cancel>,
@@ -107,23 +108,31 @@ struct RecorderControl(Backend);
#[zbus::interface(name = "org.ferese.ScreenRecorder")] #[zbus::interface(name = "org.ferese.ScreenRecorder")]
impl RecorderControl { impl RecorderControl {
async fn recording_options(
&self,
session_handle: OwnedObjectPath,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<String> {
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( async fn use_bar_controls(
&self, &self,
session_handle: OwnedObjectPath, session_handle: OwnedObjectPath,
#[zbus(connection)] connection: &Connection, #[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>, #[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> { ) -> zbus::fdo::Result<()> {
let sender = header.sender().ok_or_else(|| error("Missing recorder caller"))?; authorize_recorder(&session_handle, connection, &header).await?;
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(),
));
}
let mut sessions = self.0.sessions.lock().await; let mut sessions = self.0.sessions.lock().await;
let session = sessions let session = sessions
.get_mut(session_handle.as_str()) .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 { fn owns_session(sender: &str, path: &str) -> bool {
let Some(sender) = sender.strip_prefix(':') else { let Some(sender) = sender.strip_prefix(':') else {
return false; return false;
@@ -299,6 +327,7 @@ impl Backend {
cursor: false, cursor: false,
source_types: 1, source_types: 1,
bar_controlled: false, bar_controlled: false,
recording: None,
persist_mode: 0, persist_mode: 0,
restore: None, restore: None,
cancel: Arc::default(), cancel: Arc::default(),
@@ -433,9 +462,10 @@ impl Backend {
let _ = connection.object_server().remove::<Request, _>(handle).await; let _ = connection.object_server().remove::<Request, _>(handle).await;
match result { 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()) { if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) {
session.phase = Phase::Running; session.phase = Phase::Running;
session.recording = recording;
} }
let backend = self.clone(); let backend = self.clone();
let connection = connection.clone(); let connection = connection.clone();
@@ -604,7 +634,15 @@ async fn start_streams(
app: &str, app: &str,
parent: &str, parent: &str,
options: StreamOptions, options: StreamOptions,
) -> Result<(Vec<Child>, Vec<(u32, Options)>, Options), StartError> { ) -> Result<
(
Vec<Child>,
Vec<(u32, Options)>,
Options,
Option<crate::recording::Recording>,
),
StartError,
> {
let StreamOptions { let StreamOptions {
multiple, multiple,
cursor, cursor,
@@ -643,8 +681,8 @@ async fn start_streams(
.ok()?; .ok()?;
Some((selected, restore.effective_mode(requested_persistence))) Some((selected, restore.effective_mode(requested_persistence)))
}); });
let (selected, persist_mode) = if let Some(restored) = restored { let (selected, persist_mode, audio, region) = if let Some((selected, persist_mode)) = restored {
restored (selected, persist_mode, crate::recording::Audio::Off, None)
} else { } else {
let prompt = Prompt { let prompt = Prompt {
app: if bar_controlled { "Ferese" } else { app }.to_owned(), app: if bar_controlled { "Ferese" } else { app }.to_owned(),
@@ -653,6 +691,7 @@ async fn start_streams(
rememberable: rememberable.clone(), rememberable: rememberable.clone(),
parent: parent.into(), parent: parent.into(),
window_capture: false, window_capture: false,
recording: bar_controlled,
persist_mode: if rememberable.is_empty() { persist_mode: if rememberable.is_empty() {
0 0
} else { } else {
@@ -671,6 +710,9 @@ async fn start_streams(
return Err("Invalid persistence selection".into()); return Err("Invalid persistence selection".into());
} }
let selected = validate_selection(&prompt.sources, &selection.names, multiple)?; 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(|| { crate::desktop::wait_for_surface_removal_using(|| {
capture_ipc( capture_ipc(
&cancel, &cancel,
@@ -681,7 +723,34 @@ async fn start_streams(
}) })
.await?; .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(); 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 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 let streams = streams
.into_iter() .into_iter()
.map(|(name, ready)| { .map(|(name, ready)| {
@@ -799,7 +890,7 @@ async fn start_streams(
}) })
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok((children, streams, reply)) Ok((children, streams, reply, recording))
} }
fn window_metadata(source: &Source, ready: &Ready) -> Result<Options, String> { fn window_metadata(source: &Source, ready: &Ready) -> Result<Options, String> {
+38 -1
View File
@@ -19,8 +19,8 @@ use crate::consent::Prompt;
const PATH: &str = "/org/freedesktop/portal/desktop"; const PATH: &str = "/org/freedesktop/portal/desktop";
const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024; const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024;
type Reply = (u32, Options);
type Reply = (u32, Options);
type RequestMap = HashMap<String, (String, Arc<Cancel>)>; type RequestMap = HashMap<String, (String, Arc<Cancel>)>;
#[derive(Clone)] #[derive(Clone)]
@@ -52,14 +52,17 @@ impl Requests {
handle: &OwnedObjectPath, handle: &OwnedObjectPath,
) -> zbus::fdo::Result<Arc<Cancel>> { ) -> zbus::fdo::Result<Arc<Cancel>> {
let owner = authorize(connection, header).await?; let owner = authorize(connection, header).await?;
if !handle.as_str().starts_with(&format!("{PATH}/request/")) { if !handle.as_str().starts_with(&format!("{PATH}/request/")) {
return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into())); return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into()));
} }
let mut pending = self.0.lock().await; let mut pending = self.0.lock().await;
if pending.len() >= self.1 || pending.contains_key(handle.as_str()) { if pending.len() >= self.1 || pending.contains_key(handle.as_str()) {
return Err(zbus::fdo::Error::LimitsExceeded("Request limit reached".into())); return Err(zbus::fdo::Error::LimitsExceeded("Request limit reached".into()));
} }
let cancel = Arc::new(Cancel::default()); let cancel = Arc::new(Cancel::default());
if !connection if !connection
.object_server() .object_server()
.at( .at(
@@ -74,6 +77,7 @@ impl Requests {
return Err(zbus::fdo::Error::InvalidArgs("Request already exists".into())); return Err(zbus::fdo::Error::InvalidArgs("Request already exists".into()));
} }
pending.insert(handle.to_string(), (owner, cancel.clone())); pending.insert(handle.to_string(), (owner, cancel.clone()));
Ok(cancel) Ok(cancel)
} }
@@ -113,7 +117,9 @@ impl Screenshot {
_ = cancel.wait() => (1, Options::new()), _ = cancel.wait() => (1, Options::new()),
result = screenshot(&app_id, &parent_window, &options) => reply(result), result = screenshot(&app_id, &parent_window, &options) => reply(result),
}; };
self.0.end(connection, &handle).await; self.0.end(connection, &handle).await;
Ok(result) Ok(result)
} }
@@ -132,7 +138,9 @@ impl Screenshot {
_ = cancel.wait() => (1, Options::new()), _ = cancel.wait() => (1, Options::new()),
result = pick_color(&app_id, &parent_window) => reply(result), result = pick_color(&app_id, &parent_window) => reply(result),
}; };
self.0.end(connection, &handle).await; self.0.end(connection, &handle).await;
Ok(result) Ok(result)
} }
} }
@@ -163,7 +171,9 @@ impl Wallpaper {
_ = cancel.wait() => 1, _ = cancel.wait() => 1,
result = wallpaper(&app_id, &parent_window, &uri, &options, cancel.clone()) => reply(result).0, result = wallpaper(&app_id, &parent_window, &uri, &options, cancel.clone()) => reply(result).0,
}; };
self.0.end(connection, &handle).await; self.0.end(connection, &handle).await;
Ok(result) Ok(result)
} }
} }
@@ -190,6 +200,7 @@ pub(crate) async fn consent(
if app.len() > 512 { if app.len() > 512 {
return Err("Application ID is too long".into()); return Err("Application ID is too long".into());
} }
let prompt = Prompt { let prompt = Prompt {
parent: parent.into(), parent: parent.into(),
title: title.into(), title: title.into(),
@@ -212,15 +223,18 @@ pub(crate) async fn consent(
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let pid = child.id().ok_or("Missing consent process ID")?; let pid = child.id().ok_or("Missing consent process ID")?;
let mut input = child.stdin.take().unwrap(); let mut input = child.stdin.take().unwrap();
input input
.write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?) .write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?)
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
drop(input); drop(input);
let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output()) let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output())
.await .await
.map_err(|_| "Consent timed out")? .map_err(|_| "Consent timed out")?
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
if output.status.success() && output.stdout == b"true\n" { if output.status.success() && output.stdout == b"true\n" {
wait_for_surface_removal(pid).await?; wait_for_surface_removal(pid).await?;
Ok(true) 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<serde_json::Value, String> { pub(crate) fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result<serde_json::Value, String> {
let path = ferese_ipc::socket::resolve(None).map_err(|error| error.to_string())?; 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())?; let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream stream
.set_read_timeout(Some(Duration::from_secs(15))) .set_read_timeout(Some(Duration::from_secs(15)))
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
stream stream
.set_write_timeout(Some(Duration::from_secs(2))) .set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let request = ferese_ipc::Request { let request = ferese_ipc::Request {
version: ferese_ipc::VERSION, version: ferese_ipc::VERSION,
id: 1, id: 1,
@@ -251,18 +267,22 @@ pub(crate) fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result
command: command.into(), command: command.into(),
args, args,
}; };
exchange(&mut stream, &request) exchange(&mut stream, &request)
} }
fn exchange(stream: &mut UnixStream, request: &ferese_ipc::Request) -> Result<serde_json::Value, String> { fn exchange(stream: &mut UnixStream, request: &ferese_ipc::Request) -> Result<serde_json::Value, String> {
ferese_ipc::write_frame(stream, request).map_err(|error| error.to_string())?; 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())?; 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 { if response.id != request.id || response.version != ferese_ipc::VERSION {
return Err("Unexpected compositor response ID or protocol version".into()); return Err("Unexpected compositor response ID or protocol version".into());
} }
if let Some(error) = response.error { if let Some(error) = response.error {
return Err(error.message); return Err(error.message);
} }
response.result.ok_or("Missing IPC result".into()) response.result.ok_or("Missing IPC result".into())
} }
@@ -272,6 +292,7 @@ async fn select_geometry(point: bool) -> Result<Option<String>, String> {
command.arg("-p"); command.arg("-p");
} }
command.stdin(Stdio::null()); command.stdin(Stdio::null());
let child = command let child = command
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
@@ -286,6 +307,7 @@ async fn select_geometry(point: bool) -> Result<Option<String>, String> {
if !output.status.success() { if !output.status.success() {
return Ok(None); return Ok(None);
} }
let geometry = String::from_utf8(output.stdout) let geometry = String::from_utf8(output.stdout)
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.trim() .trim()
@@ -293,7 +315,9 @@ async fn select_geometry(point: bool) -> Result<Option<String>, String> {
if geometry.len() > 128 || geometry.is_empty() { if geometry.len() > 128 || geometry.is_empty() {
return Err("Invalid selection".into()); return Err("Invalid selection".into());
} }
wait_for_surface_removal(pid).await?; wait_for_surface_removal(pid).await?;
Ok(Some(geometry)) Ok(Some(geometry))
} }
@@ -310,6 +334,7 @@ pub(crate) async fn wait_for_surface_removal_using<
if check().await? == false { if check().await? == false {
return Ok(()); return Ok(());
} }
tokio::time::sleep(Duration::from_millis(10)).await; tokio::time::sleep(Duration::from_millis(10)).await;
} }
Err("Screenshot helper is still visible".into()) Err("Screenshot helper is still visible".into())
@@ -336,15 +361,18 @@ fn read_screenshot(path: &str) -> Result<Vec<u8>, String> {
let _ = std::fs::remove_file(self.0); let _ = std::fs::remove_file(self.0);
} }
} }
let _cleanup = Cleanup(path); let _cleanup = Cleanup(path);
let file = std::fs::File::open(path).map_err(|error| error.to_string())?; let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
let mut bytes = Vec::new(); let mut bytes = Vec::new();
file.take(MAX_IMAGE_BYTES + 1) file.take(MAX_IMAGE_BYTES + 1)
.read_to_end(&mut bytes) .read_to_end(&mut bytes)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
if bytes.len() as u64 > MAX_IMAGE_BYTES { if bytes.len() as u64 > MAX_IMAGE_BYTES {
return Err("Screenshot is too large".into()); return Err("Screenshot is too large".into());
} }
Ok(bytes) Ok(bytes)
} }
@@ -370,6 +398,7 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
if !matches!(target, 1 | 2 | 4 | 8) { if !matches!(target, 1 | 2 | 4 | 8) {
return Err("Unsupported screenshot target".into()); return Err("Unsupported screenshot target".into());
} }
let active_window = if target == 8 { let active_window = if target == 8 {
Some( Some(
ipc("get-focused-window", serde_json::json!({})).await?["id"] ipc("get-focused-window", serde_json::json!({})).await?["id"]
@@ -379,6 +408,7 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
} else { } else {
None None
}; };
if !consent( if !consent(
app, app,
parent, parent,
@@ -391,6 +421,7 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
{ {
return Ok(None); return Ok(None);
} }
let bytes = match target { let bytes = match target {
1 => capture(None).await?, 1 => capture(None).await?,
4 => { 4 => {
@@ -420,7 +451,9 @@ async fn screenshot(app: &str, parent: &str, options: &Options) -> Result<Option
.tempfile_in(directory) .tempfile_in(directory)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
std::io::Write::write_all(&mut file, &bytes).map_err(|error| error.to_string())?; std::io::Write::write_all(&mut file, &bytes).map_err(|error| error.to_string())?;
let (_, path) = file.keep().map_err(|error| error.to_string())?; let (_, path) = file.keep().map_err(|error| error.to_string())?;
Ok(Some(HashMap::from([( Ok(Some(HashMap::from([(
"uri".into(), "uri".into(),
Value::from(file_uri(&path)?) Value::from(file_uri(&path)?)
@@ -434,11 +467,13 @@ fn window_sources(windows: &serde_json::Value) -> Result<Vec<crate::capture::Sou
if windows.len() > 128 { if windows.len() > 128 {
return Err("Too many windows to choose from".into()); return Err("Too many windows to choose from".into());
} }
let mut sources = Vec::new(); let mut sources = Vec::new();
for window in windows.iter().filter(|window| window["mapped"] == true) { for window in windows.iter().filter(|window| window["mapped"] == true) {
let Some(id) = window["id"].as_u64() else { let Some(id) = window["id"].as_u64() else {
continue; continue;
}; };
let title = window["title"].as_str().unwrap_or("Untitled window"); let title = window["title"].as_str().unwrap_or("Untitled window");
let app = window["app_id"].as_str().unwrap_or(""); let app = window["app_id"].as_str().unwrap_or("");
let label = format!("{title} — {app}") let label = format!("{title} — {app}")
@@ -462,6 +497,7 @@ fn window_sources(windows: &serde_json::Value) -> Result<Vec<crate::capture::Sou
scale: 1, scale: 1,
}); });
} }
Ok(sources) Ok(sources)
} }
@@ -503,6 +539,7 @@ async fn select_window(app: &str, parent: &str) -> Result<Option<u64>, String> {
multiple: false, multiple: false,
parent: parent.into(), parent: parent.into(),
window_capture: true, window_capture: true,
recording: false,
persist_mode: 0, persist_mode: 0,
rememberable: Vec::new(), rememberable: Vec::new(),
}; };
+2
View File
@@ -10,6 +10,8 @@ mod lockdown;
mod parent; mod parent;
mod permissions; mod permissions;
mod picker; mod picker;
mod recording;
mod recording_region;
mod restore; mod restore;
mod settings; mod settings;
mod shortcuts; mod shortcuts;
+106 -32
View File
@@ -8,6 +8,7 @@ use ferese_theme::icons::symbolic as glyph;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::capture::Source; use crate::capture::Source;
use crate::recording::Audio;
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Prompt { pub struct Prompt {
@@ -22,6 +23,8 @@ pub struct Prompt {
pub persist_mode: u32, pub persist_mode: u32,
#[serde(default)] #[serde(default)]
pub rememberable: Vec<String>, pub rememberable: Vec<String>,
#[serde(default)]
pub recording: bool,
} }
impl Prompt { impl Prompt {
@@ -48,6 +51,10 @@ impl Prompt {
pub(crate) struct Selection { pub(crate) struct Selection {
pub names: Vec<String>, pub names: Vec<String>,
pub persist_mode: u32, pub persist_mode: u32,
#[serde(default)]
pub audio: Audio,
#[serde(default)]
pub region: bool,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -57,6 +64,8 @@ enum Message {
Attached(crate::parent::Attachment), Attached(crate::parent::Attachment),
Select(usize), Select(usize),
Remember, Remember,
Audio(Audio),
Region,
Share, Share,
Cancel, Cancel,
} }
@@ -68,6 +77,8 @@ struct Picker {
prompt: Prompt, prompt: Prompt,
selected: Vec<usize>, selected: Vec<usize>,
remember: bool, remember: bool,
audio: Audio,
region: bool,
background: cosmic::iced::Color, background: cosmic::iced::Color,
palette: ferese_theme::Palette, palette: ferese_theme::Palette,
font: cosmic::font::Font, font: cosmic::font::Font,
@@ -86,7 +97,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
} }
let (theme, font, background, palette) = appearance(); let (theme, font, background, palette) = appearance();
let height = 180. + prompt.source_list_height() + if prompt.persist_mode > 0 { 48. } else { 0. }; let height = 180.
+ prompt.source_list_height()
+ if prompt.persist_mode > 0 { 48. } else { 0. }
+ if prompt.recording { 190. } else { 0. };
cosmic::app::run::<Picker>( cosmic::app::run::<Picker>(
Settings::default() Settings::default()
.size(cosmic::iced::Size::new(400., height)) .size(cosmic::iced::Size::new(400., height))
@@ -129,6 +143,8 @@ impl cosmic::Application for Picker {
prompt, prompt,
selected: Vec::new(), selected: Vec::new(),
remember: false, remember: false,
audio: Audio::Off,
region: false,
background, background,
palette, palette,
font, font,
@@ -138,7 +154,9 @@ impl cosmic::Application for Picker {
.main_window_id() .main_window_id()
.map(|id| { .map(|id| {
app.set_window_title( app.set_window_title(
if app.prompt.window_capture { if app.prompt.recording {
"Ferese — screen recording".into()
} else if app.prompt.window_capture {
"Ferese — capture a window".into() "Ferese — capture a window".into()
} else { } else {
"Ferese — share your screen".into() "Ferese — share your screen".into()
@@ -211,6 +229,8 @@ impl cosmic::Application for Picker {
self.remember &= self.can_remember(); self.remember &= self.can_remember();
} }
Message::Remember if self.can_remember() => self.remember = !self.remember, Message::Remember if self.can_remember() => self.remember = !self.remember,
Message::Audio(audio) if self.prompt.recording => self.audio = audio,
Message::Region if self.prompt.recording => self.region = !self.region,
Message::Share if !self.selected.is_empty() => { Message::Share if !self.selected.is_empty() => {
let names: Vec<_> = self.selected.iter().map(|i| &self.prompt.sources[*i].name).collect(); let names: Vec<_> = self.selected.iter().map(|i| &self.prompt.sources[*i].name).collect();
if self.prompt.window_capture { if self.prompt.window_capture {
@@ -223,6 +243,8 @@ impl cosmic::Application for Picker {
} else { } else {
0 0
}, },
audio: self.audio,
region: self.region,
}; };
println!("{}", serde_json::to_string(&selection).unwrap()); println!("{}", serde_json::to_string(&selection).unwrap());
} }
@@ -249,32 +271,36 @@ impl cosmic::Application for Picker {
} else { } else {
&self.prompt.app &self.prompt.app
}; };
let mut content = column([]).width(Length::Fill).height(Length::Fill).spacing(10).push( let header = row![
row![ glyph(ferese_theme::icons::DISPLAY, 24),
glyph(ferese_theme::icons::DISPLAY, 24), column![
column![ self.text(if self.prompt.recording {
self.text(if self.prompt.window_capture { "Record your screen"
"Choose a window" } else if self.prompt.window_capture {
} else { "Choose a window"
"Choose what to share" } else {
}) "Choose what to share"
.size(17), })
self.text(format!( .size(17),
self.text(if self.prompt.recording {
"Saved to your Videos folder".to_owned()
} else {
format!(
"{} {app}", "{} {app}",
if self.prompt.window_capture { if self.prompt.window_capture {
"Screenshot for" "Screenshot for"
} else { } else {
"Share with" "Share with"
} }
)) )
.size(12), })
] .size(12),
.spacing(2)
.width(Length::Fill),
] ]
.spacing(12) .spacing(2)
.align_y(Alignment::Center), .width(Length::Fill),
); ]
.spacing(12)
.align_y(Alignment::Center);
let mut sources = column([]).spacing(6); let mut sources = column([]).spacing(6);
let mut previous_kind = None; let mut previous_kind = None;
for (index, source) in self.prompt.sources.iter().enumerate() { for (index, source) in self.prompt.sources.iter().enumerate() {
@@ -315,9 +341,11 @@ impl cosmic::Application for Picker {
.on_press(Message::Select(index)), .on_press(Message::Select(index)),
); );
} }
content = content.push(scrollable(sources).height(Length::Fixed(self.prompt.source_list_height()))); let mut content = column![sources].width(Length::Fill).spacing(10);
content = content.push( content = content.push(
self.text(if self.prompt.window_capture { self.text(if self.prompt.recording {
"Choose a display, then record it in full or select a region."
} else if self.prompt.window_capture {
"Only the selected window will be captured." "Only the selected window will be captured."
} else { } else {
"Displays share everything visible. Windows share only their own content." "Displays share everything visible. Windows share only their own content."
@@ -325,6 +353,36 @@ impl cosmic::Application for Picker {
.size(12) .size(12)
.width(Length::Fill), .width(Length::Fill),
); );
if self.prompt.recording {
content = content.push(
row![
self.text("Select a region").width(Length::Fill),
ferese_theme::controls::switch(self.region, self.palette).on_press(Message::Region),
]
.align_y(Alignment::Center),
);
let mut audio = column([]).spacing(4);
for choices in [[Audio::Off, Audio::System], [Audio::Microphone, Audio::Both]] {
let mut choices_row = row([]).spacing(4);
for choice in choices {
choices_row = choices_row.push(
ferese_theme::controls::text_button(
choice.label(),
self.font,
self.palette,
self.audio == choice,
)
.width(Length::Fill)
.on_press(Message::Audio(choice)),
);
}
audio = audio.push(choices_row);
}
content = content.push(self.text("Audio").size(12)).push(audio).push(
self.text("Uses the default output and microphone selected in audio settings.")
.size(12),
);
}
if self.prompt.persist_mode > 0 && !self.prompt.window_capture { if self.prompt.persist_mode > 0 && !self.prompt.window_capture {
let label = if !self.can_remember() { let label = if !self.can_remember() {
"This selection needs approval each time" "This selection needs approval each time"
@@ -345,7 +403,13 @@ impl cosmic::Application for Picker {
} }
let share = ferese_theme::controls::text_button( let share = ferese_theme::controls::text_button(
if self.prompt.window_capture { "Capture" } else { "Share" }, if self.prompt.recording {
if self.region { "Select region" } else { "Record" }
} else if self.prompt.window_capture {
"Capture"
} else {
"Share"
},
self.font, self.font,
self.palette, self.palette,
true, true,
@@ -358,14 +422,24 @@ impl cosmic::Application for Picker {
self.background.a, self.background.a,
)) ))
.on_press(Message::Cancel); .on_press(Message::Cancel);
content = content let footer = row![cosmic::iced::widget::Space::new().width(Length::Fill), cancel, share]
.push(cosmic::iced::widget::Space::new().height(Length::Fill)) .spacing(10)
.push( .align_y(Alignment::Center);
row![cosmic::iced::widget::Space::new().width(Length::Fill), cancel, share] // Reserve space for the actions even when the compositor shrinks the
.spacing(10) // dialog. The source list and options share the remaining scroll area.
.align_y(Alignment::Center), container(
); column![
container(content).padding(14).width(Length::Fill).into() header,
scrollable(content).height(Length::Fill).width(Length::Fill),
footer
]
.spacing(10)
.height(Length::Fill),
)
.padding(14)
.width(Length::Fill)
.height(Length::Fill)
.into()
} }
} }
+1
View File
@@ -1,6 +1,7 @@
//! Native recording client. The portal retains capture authorization; encoding //! Native recording client. The portal retains capture authorization; encoding
//! and file I/O live in this separate process, never in the shell render loop. //! and file I/O live in this separate process, never in the shell render loop.
mod recorder; mod recorder;
mod recording;
fn main() { fn main() {
if let Err(error) = tokio::runtime::Runtime::new() if let Err(error) = tokio::runtime::Runtime::new()
+408 -23
View File
@@ -8,6 +8,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::recording::{Audio, Recording};
use futures_util::StreamExt; use futures_util::StreamExt;
use gstreamer as gst; use gstreamer as gst;
use gstreamer::prelude::*; use gstreamer::prelude::*;
@@ -25,9 +26,11 @@ pub fn event(state: &str, message: Option<&str>, path: Option<&Path>) {
let _ = writeln!(stdout, "{value}"); let _ = writeln!(stdout, "{value}");
let _ = stdout.flush(); let _ = stdout.flush();
} }
fn option_string(value: String) -> OwnedValue { fn option_string(value: String) -> OwnedValue {
OwnedValue::try_from(Value::from(value)).unwrap() OwnedValue::try_from(Value::from(value)).unwrap()
} }
async fn request<B: serde::Serialize + zbus::zvariant::DynamicType>( async fn request<B: serde::Serialize + zbus::zvariant::DynamicType>(
connection: &Connection, connection: &Connection,
method: &str, method: &str,
@@ -48,18 +51,22 @@ async fn request<B: serde::Serialize + zbus::zvariant::DynamicType>(
.call(method, body) .call(method, body)
.await .await
.map_err(|e| format!("Screen recording portal unavailable: {e}"))?; .map_err(|e| format!("Screen recording portal unavailable: {e}"))?;
if returned.as_str() != path { if returned.as_str() != path {
return Err("Unexpected portal request handle".into()); return Err("Unexpected portal request handle".into());
} }
if *stop.borrow() { if *stop.borrow() {
let _: Result<(), _> = request.call("Close", &()).await; let _: Result<(), _> = request.call("Close", &()).await;
return Ok(None); return Ok(None);
} }
tokio::select! { tokio::select! {
_ = stop.changed() => { _ = stop.changed() => {
let _: Result<(), _> = request.call("Close", &()).await; let _: Result<(), _> = request.call("Close", &()).await;
Ok(None) Ok(None)
} }
signal = tokio::time::timeout(Duration::from_secs(125), responses.next()) => { signal = tokio::time::timeout(Duration::from_secs(125), responses.next()) => {
let signal = signal.map_err(|_| "Display selection timed out")?.ok_or("Portal disconnected")?; let signal = signal.map_err(|_| "Display selection timed out")?.ok_or("Portal disconnected")?;
let (code, options): (u32, Options) = signal.body().deserialize().map_err(|e| e.to_string())?; let (code, options): (u32, Options) = signal.body().deserialize().map_err(|e| e.to_string())?;
@@ -70,6 +77,7 @@ async fn request<B: serde::Serialize + zbus::zvariant::DynamicType>(
pub async fn run() -> Result<(), String> { pub async fn run() -> Result<(), String> {
gst::init().map_err(|e| e.to_string())?; gst::init().map_err(|e| e.to_string())?;
for factory in ["pipewiresrc", "queue", "videoconvert", "vp8enc", "webmmux", "fdsink"] { for factory in ["pipewiresrc", "queue", "videoconvert", "vp8enc", "webmmux", "fdsink"] {
if gst::ElementFactory::find(factory).is_none() { if gst::ElementFactory::find(factory).is_none() {
return Err(format!( return Err(format!(
@@ -77,12 +85,15 @@ pub async fn run() -> Result<(), String> {
)); ));
} }
} }
let output = output_directory()?; let output = output_directory()?;
let (stop_send, mut stop) = watch::channel(false); let (stop_send, mut stop) = watch::channel(false);
std::thread::spawn(move || { std::thread::spawn(move || {
let _ = std::io::stdin().read(&mut [0u8]); let _ = std::io::stdin().read(&mut [0u8]);
let _ = stop_send.send(true); let _ = stop_send.send(true);
}); });
let connection = Connection::session().await.map_err(|e| e.to_string())?; let connection = Connection::session().await.map_err(|e| e.to_string())?;
let token = |step| format!("ferese_record_{}_{step}", std::process::id()); let token = |step| format!("ferese_record_{}_{step}", std::process::id());
let Some(created) = request( let Some(created) = request(
@@ -100,6 +111,7 @@ pub async fn run() -> Result<(), String> {
event("cancelled", None, None); event("cancelled", None, None);
return Ok(()); return Ok(());
}; };
let handle = created.get("session_handle").ok_or("Portal returned no session")?; let handle = created.get("session_handle").ok_or("Portal returned no session")?;
// The public portal historically returns this path as a D-Bus string. // The public portal historically returns this path as a D-Bus string.
let handle = OwnedObjectPath::try_from( let handle = OwnedObjectPath::try_from(
@@ -108,27 +120,33 @@ pub async fn run() -> Result<(), String> {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Older/foreign backends keep their normal sharing indicator. Only Ferese // Older/foreign backends keep their normal sharing indicator. Only Ferese
// can verify this helper's identity and that its parent owns a bar stop button. // can verify this helper's identity and that its parent owns a bar stop button.
let _ = tokio::time::timeout(Duration::from_secs(3), async { let native = matches!(
let control = Proxy::new( tokio::time::timeout(Duration::from_secs(3), async {
&connection, let control = Proxy::new(
"org.freedesktop.impl.portal.desktop.ferese", &connection,
"/org/ferese/ScreenRecorder", "org.freedesktop.impl.portal.desktop.ferese",
"org.ferese.ScreenRecorder", "/org/ferese/ScreenRecorder",
) "org.ferese.ScreenRecorder",
.await?; )
control.call::<_, _, ()>("UseBarControls", &(handle.clone(),)).await .await?;
}) control.call::<_, _, ()>("UseBarControls", &(handle.clone(),)).await
.await; })
.await,
Ok(Ok(()))
);
let session = Proxy::new(&connection, DESKTOP, handle.as_str(), "org.freedesktop.portal.Session") let session = Proxy::new(&connection, DESKTOP, handle.as_str(), "org.freedesktop.portal.Session")
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let result = record_session(&connection, &session, &handle, &token, &output, &mut stop).await; let result = record_session(&connection, &session, &handle, &token, &output, &mut stop, native).await;
let _: Result<(), _> = session.call("Close", &()).await; let _: Result<(), _> = session.call("Close", &()).await;
match result { match result {
Ok(Some(path)) => event("saved", None, Some(&path)), Ok(Some(path)) => event("saved", None, Some(&path)),
Ok(None) => event("cancelled", None, None), Ok(None) => event("cancelled", None, None),
Err(e) => return Err(e), Err(e) => return Err(e),
} }
Ok(()) Ok(())
} }
async fn record_session( async fn record_session(
@@ -138,8 +156,10 @@ async fn record_session(
token: &impl Fn(u8) -> String, token: &impl Fn(u8) -> String,
output: &Path, output: &Path,
stop: &mut watch::Receiver<bool>, stop: &mut watch::Receiver<bool>,
native: bool,
) -> Result<Option<PathBuf>, String> { ) -> Result<Option<PathBuf>, String> {
let mut closed = session.receive_signal("Closed").await.map_err(|e| e.to_string())?; let mut closed = session.receive_signal("Closed").await.map_err(|e| e.to_string())?;
if request( if request(
connection, connection,
"SelectSources", "SelectSources",
@@ -160,7 +180,9 @@ async fn record_session(
{ {
return Ok(None); return Ok(None);
} }
event("selecting", None, None); event("selecting", None, None);
let Some(started) = request( let Some(started) = request(
connection, connection,
"Start", "Start",
@@ -172,6 +194,7 @@ async fn record_session(
else { else {
return Ok(None); return Ok(None);
}; };
let streams: Vec<(u32, Options)> = started let streams: Vec<(u32, Options)> = started
.get("streams") .get("streams")
.ok_or("Portal returned no video stream")? .ok_or("Portal returned no video stream")?
@@ -179,9 +202,32 @@ async fn record_session(
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.try_into() .try_into()
.map_err(|e: zbus::zvariant::Error| e.to_string())?; .map_err(|e: zbus::zvariant::Error| e.to_string())?;
if streams.len() != 1 { if streams.len() != 1 {
return Err("Recording requires exactly one display".into()); return Err("Recording requires exactly one display".into());
} }
let recording = if native {
let control = Proxy::new(
connection,
"org.freedesktop.impl.portal.desktop.ferese",
"/org/ferese/ScreenRecorder",
"org.ferese.ScreenRecorder",
)
.await
.map_err(|e| e.to_string())?;
let options: String =
tokio::time::timeout(Duration::from_secs(3), control.call("RecordingOptions", &(handle,)))
.await
.map_err(|_| "Recording options timed out")?
.map_err(|e| {
format!("Cannot read recording options; update the Ferese portal and recorder together: {e}")
})?;
serde_json::from_str::<Recording>(&options).map_err(|e| e.to_string())?
} else {
Recording::default()
};
let portal = Proxy::new(connection, DESKTOP, DESKTOP_PATH, "org.freedesktop.portal.ScreenCast") let portal = Proxy::new(connection, DESKTOP, DESKTOP_PATH, "org.freedesktop.portal.ScreenCast")
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -189,9 +235,11 @@ async fn record_session(
.call("OpenPipeWireRemote", &(handle, Options::new())) .call("OpenPipeWireRemote", &(handle, Options::new()))
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if *stop.borrow() { if *stop.borrow() {
return Ok(None); return Ok(None);
} }
let stop_capture = Arc::new(AtomicBool::new(*stop.borrow())); let stop_capture = Arc::new(AtomicBool::new(*stop.borrow()));
let worker_stop = stop_capture.clone(); let worker_stop = stop_capture.clone();
let node = streams[0].0; let node = streams[0].0;
@@ -205,7 +253,8 @@ async fn record_session(
.property("always-copy", true) .property("always-copy", true)
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let result = encode(source, &output, worker_stop); let audio = audio_sources(recording.audio)?;
let result = encode(source, audio, recording.crop, &output, worker_stop);
drop(remote); drop(remote);
result result
}); });
@@ -214,11 +263,13 @@ async fn record_session(
_ = stop.changed() => { stop_capture.store(true, Ordering::Relaxed); worker.await }, _ = stop.changed() => { stop_capture.store(true, Ordering::Relaxed); worker.await },
_ = closed.next() => { stop_capture.store(true, Ordering::Relaxed); worker.await }, _ = closed.next() => { stop_capture.store(true, Ordering::Relaxed); worker.await },
}; };
result.map_err(|e| e.to_string())?.map(Some) result.map_err(|e| e.to_string())?.map(Some)
} }
fn output_directory() -> Result<PathBuf, String> { fn output_directory() -> Result<PathBuf, String> {
let args: Vec<_> = std::env::args_os().skip(1).collect(); let args: Vec<_> = std::env::args_os().skip(1).collect();
match args.as_slice() { match args.as_slice() {
[] => dirs::video_dir() [] => dirs::video_dir()
.or_else(|| dirs::home_dir().map(|p| p.join("Videos"))) .or_else(|| dirs::home_dir().map(|p| p.join("Videos")))
@@ -228,19 +279,24 @@ fn output_directory() -> Result<PathBuf, String> {
_ => Err("Usage: ferese-record [--output-dir DIRECTORY]".into()), _ => Err("Usage: ferese-record [--output-dir DIRECTORY]".into()),
} }
} }
fn recording_file(directory: &Path) -> Result<(File, PathBuf, PathBuf), String> { fn recording_file(directory: &Path) -> Result<(File, PathBuf, PathBuf), String> {
std::fs::create_dir_all(directory).map_err(|e| format!("Cannot create recording folder: {e}"))?; std::fs::create_dir_all(directory).map_err(|e| format!("Cannot create recording folder: {e}"))?;
let name = format!( let name = format!(
"Recording-{}-{}", "Recording-{}-{}",
jiff::Zoned::now().strftime("%Y-%m-%d_%H-%M-%S"), jiff::Zoned::now().strftime("%Y-%m-%d_%H-%M-%S"),
std::process::id() std::process::id()
); );
for suffix in 0..100 { for suffix in 0..100 {
let final_path = directory.join(format!("{name}-{suffix}.webm")); let final_path = directory.join(format!("{name}-{suffix}.webm"));
let partial = final_path.with_extension("webm.part"); let partial = final_path.with_extension("webm.part");
if final_path.exists() { if final_path.exists() {
continue; continue;
} }
match OpenOptions::new() match OpenOptions::new()
.write(true) .write(true)
.create_new(true) .create_new(true)
@@ -252,10 +308,44 @@ fn recording_file(directory: &Path) -> Result<(File, PathBuf, PathBuf), String>
Err(e) => return Err(format!("Cannot save recording: {e}")), Err(e) => return Err(format!("Cannot save recording: {e}")),
} }
} }
Err("Cannot choose a unique recording filename".into()) Err("Cannot choose a unique recording filename".into())
} }
fn encode(source: gst::Element, output: &Path, stop: Arc<AtomicBool>) -> Result<PathBuf, String> { fn audio_sources(audio: Audio) -> Result<Vec<gst::Element>, String> {
let devices: &[&str] = match audio {
Audio::Off => &[],
Audio::System => &["@DEFAULT_MONITOR@"],
Audio::Microphone => &["@DEFAULT_SOURCE@"],
Audio::Both => &["@DEFAULT_MONITOR@", "@DEFAULT_SOURCE@"],
};
devices.iter().map(|device| {
gst::ElementFactory::make("pulsesrc")
.property("device", *device)
.property("client-name", "Ferese Screen Recorder")
.property("do-timestamp", true)
.property("provide-clock", false)
.build().map_err(|e| format!("Cannot record {}. Install the GStreamer audio plugins and run PipeWire-Pulse or PulseAudio: {e}", audio.label()))
}).collect()
}
fn element(factory: &str) -> Result<gst::Element, String> {
gst::ElementFactory::make(factory)
.build()
.map_err(|e| format!("Recording needs the GStreamer {factory} plugin: {e}"))
}
fn encode(
source: gst::Element,
audio: Vec<gst::Element>,
crop: Option<crate::recording::Crop>,
output: &Path,
stop: Arc<AtomicBool>,
) -> Result<PathBuf, String> {
if let Some(crop) = crop {
crop.validate()?;
}
let (file, partial, destination) = recording_file(output)?; let (file, partial, destination) = recording_file(output)?;
let pipeline = gst::Pipeline::new(); let pipeline = gst::Pipeline::new();
let result = (|| { let result = (|| {
@@ -285,10 +375,88 @@ fn encode(source: gst::Element, output: &Path, stop: Arc<AtomicBool>) -> Result<
.property("sync", false) .property("sync", false)
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
pipeline pipeline
.add_many([&source, &queue, &convert, &encoder, &mux, &sink]) .add_many([&source, &queue, &convert, &encoder, &mux, &sink])
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
gst::Element::link_many([&source, &queue, &convert, &encoder, &mux, &sink]).map_err(|e| e.to_string())?;
if let Some(crop) = crop {
let filter = gst::ElementFactory::make("videocrop")
.property("left", crop.left)
.property("top", crop.top)
.property("right", crop.right)
.property("bottom", crop.bottom)
.build()
.map_err(|e| e.to_string())?;
pipeline.add(&filter).map_err(|e| e.to_string())?;
gst::Element::link_many([&source, &queue, &filter, &convert, &encoder, &mux, &sink])
.map_err(|e| e.to_string())?;
} else {
gst::Element::link_many([&source, &queue, &convert, &encoder, &mux, &sink]).map_err(|e| e.to_string())?;
}
let mut audio_inputs = Vec::new();
let audio_seen = Arc::new(AtomicBool::new(audio.is_empty()));
if !audio.is_empty() {
let mixer = element("audiomixer")?;
let convert = element("audioconvert")?;
let resample = element("audioresample")?;
let encoder = element("opusenc")?;
let audio_queue = element("queue")?;
pipeline
.add_many([&mixer, &convert, &resample, &encoder, &audio_queue])
.map_err(|e| e.to_string())?;
gst::Element::link_many([&mixer, &convert, &resample, &encoder, &audio_queue, &mux])
.map_err(|e| e.to_string())?;
let seen = audio_seen.clone();
audio_queue
.static_pad("src")
.ok_or("Audio encoder has no output")?
.add_probe(gst::PadProbeType::BUFFER, move |_, info| {
if info.buffer().is_some_and(|b| b.size() > 0) {
seen.store(true, Ordering::Relaxed);
}
gst::PadProbeReturn::Ok
});
for source in audio {
let queue = element("queue")?;
let convert = element("audioconvert")?;
let resample = element("audioresample")?;
let caps = gst::ElementFactory::make("capsfilter")
.property(
"caps",
gst::Caps::builder("audio/x-raw")
.field("rate", 48_000i32)
.field("channels", 2i32)
.build(),
)
.build()
.map_err(|e| e.to_string())?;
pipeline
.add_many([&source, &queue, &convert, &resample, &caps])
.map_err(|e| e.to_string())?;
gst::Element::link_many([&source, &queue, &convert, &resample, &caps, &mixer])
.map_err(|e| e.to_string())?;
audio_inputs.push((source, queue));
}
}
let ended = stop.clone();
source
.static_pad("src")
.ok_or("Capture has no video output")?
.add_probe(gst::PadProbeType::EVENT_DOWNSTREAM, move |_, info| {
if info
.event()
.is_some_and(|event| matches!(event.view(), gst::EventView::Eos(_)))
{
ended.store(true, Ordering::Relaxed);
}
gst::PadProbeReturn::Ok
});
let announced = Arc::new(AtomicBool::new(false)); let announced = Arc::new(AtomicBool::new(false));
let notice = announced.clone(); let notice = announced.clone();
source source
@@ -300,41 +468,68 @@ fn encode(source: gst::Element, output: &Path, stop: Arc<AtomicBool>) -> Result<
} }
gst::PadProbeReturn::Ok gst::PadProbeReturn::Ok
}); });
pipeline.set_state(gst::State::Playing).map_err(|e| e.to_string())?; pipeline.set_state(gst::State::Playing).map_err(|e| e.to_string())?;
let bus = pipeline.bus().ok_or("Encoder has no message bus")?; let bus = pipeline.bus().ok_or("Encoder has no message bus")?;
let mut finishing = None; let mut finishing = None;
let started = Instant::now(); let started = Instant::now();
let mut failure = None; let mut failure = None;
loop { loop {
if stop.load(Ordering::Relaxed) && finishing.is_none() { if stop.load(Ordering::Relaxed) && finishing.is_none() {
event("saving", None, None); event("saving", None, None);
finishing = Some(Instant::now()); finishing = Some(Instant::now());
// Inject after the capture source: it may already have disappeared // Inject after the capture source: it may already have disappeared
// when the portal closes on lock or monitor removal. // when the portal closes on lock or monitor removal.
if !queue let _ = queue
.static_pad("sink") .static_pad("sink")
.ok_or("Encoder queue has no input")? .ok_or("Encoder queue has no input")?
.send_event(gst::event::Eos::new()) .send_event(gst::event::Eos::new());
{ for (source, queue) in &audio_inputs {
// The source may already have sent EOS. Let the bus confirm // Audio devices are still alive when video is revoked.
// finalization; a duplicate EOS can legitimately be rejected. // Let each source order EOS after its own startup events and
failure.get_or_insert_with(|| "Encoder could not finish the recording".into()); // buffers; injecting into its queue can race late caps.
if !source.send_event(gst::event::Eos::new()) {
let _ = queue
.static_pad("sink")
.ok_or("Audio queue has no input")?
.send_event(gst::event::Eos::new());
}
} }
} }
if finishing.is_some_and(|t: Instant| t.elapsed() > Duration::from_secs(10)) { if finishing.is_some_and(|t: Instant| t.elapsed() > Duration::from_secs(10)) {
return Err(failure.unwrap_or("Finishing the recording timed out".into())); return Err(failure.unwrap_or("Finishing the recording timed out".into()));
} }
if !announced.load(Ordering::Relaxed) && started.elapsed() > Duration::from_secs(15) { if !announced.load(Ordering::Relaxed) && started.elapsed() > Duration::from_secs(15) {
return Err("No video frames arrived from the selected display".into()); return Err("No video frames arrived from the selected display".into());
} }
if !audio_seen.load(Ordering::Relaxed) && started.elapsed() > Duration::from_secs(15) {
failure = Some("No audio arrived from the selected audio source".into());
stop.store(true, Ordering::Relaxed);
}
let Some(message) = bus.timed_pop(gst::ClockTime::from_mseconds(50)) else { let Some(message) = bus.timed_pop(gst::ClockTime::from_mseconds(50)) else {
continue; continue;
}; };
match message.view() { match message.view() {
gst::MessageView::Eos(_) => { gst::MessageView::Eos(_) => {
if !announced.load(Ordering::Relaxed) { if !announced.load(Ordering::Relaxed) {
return Err("Recording ended before receiving video".into()); return Err("Recording ended before receiving video".into());
} }
if !audio_seen.load(Ordering::Relaxed) {
return Err("Recording ended before receiving audio".into());
}
if let Some(failure) = failure {
return Err(failure);
}
return Ok(()); return Ok(());
} }
gst::MessageView::Error(error) => { gst::MessageView::Error(error) => {
@@ -345,11 +540,13 @@ fn encode(source: gst::Element, output: &Path, stop: Arc<AtomicBool>) -> Result<
} }
} }
})(); })();
let _ = pipeline.set_state(gst::State::Null); let _ = pipeline.set_state(gst::State::Null);
result.map_err(|e| format!("{e}. Incomplete file: {}", partial.display()))?; result.map_err(|e| format!("{e}. Incomplete file: {}", partial.display()))?;
file.sync_all() file.sync_all()
.map_err(|e| format!("Cannot finish saving {}: {e}", partial.display()))?; .map_err(|e| format!("Cannot finish saving {}: {e}", partial.display()))?;
std::fs::rename(&partial, &destination).map_err(|e| format!("Cannot finish saving {}: {e}", partial.display()))?; std::fs::rename(&partial, &destination).map_err(|e| format!("Cannot finish saving {}: {e}", partial.display()))?;
Ok(destination) Ok(destination)
} }
@@ -384,7 +581,7 @@ mod tests {
std::thread::sleep(Duration::from_secs(3)); std::thread::sleep(Duration::from_secs(3));
request_stop.store(true, Ordering::Relaxed); request_stop.store(true, Ordering::Relaxed);
}); });
let result = encode(source, directory.path(), stop); let result = encode(source, Vec::new(), None, directory.path(), stop);
stopper.join().unwrap(); stopper.join().unwrap();
assert_video_decodes(&result.unwrap()); assert_video_decodes(&result.unwrap());
} }
@@ -405,7 +602,7 @@ mod tests {
std::thread::sleep(Duration::from_millis(350)); std::thread::sleep(Duration::from_millis(350));
request_stop.store(true, Ordering::Relaxed); request_stop.store(true, Ordering::Relaxed);
}); });
let path = encode(source, directory.path(), stop).unwrap(); let path = encode(source, Vec::new(), None, directory.path(), stop).unwrap();
stopper.join().unwrap(); stopper.join().unwrap();
let data = std::fs::read(&path).unwrap(); let data = std::fs::read(&path).unwrap();
assert_eq!(&data[..4], &[0x1a, 0x45, 0xdf, 0xa3]); assert_eq!(&data[..4], &[0x1a, 0x45, 0xdf, 0xa3]);
@@ -413,6 +610,194 @@ mod tests {
assert_video_decodes(&path); assert_video_decodes(&path);
} }
#[test]
#[ignore = "requires GStreamer video and audio plugins"]
fn encoder_saves_cropped_video_and_mixed_audio_when_stopped() {
assert_cropped_audio_recording(false, 2, None);
}
#[test]
#[ignore = "requires GStreamer video and audio plugins"]
fn video_eos_finishes_live_audio_without_waiting_for_the_user() {
// Models the portal ending video on lock or monitor removal while the
// audio devices remain available. Every track must finish together.
assert_cropped_audio_recording(true, 1, None);
}
#[test]
#[ignore = "requires an explicitly provided isolated PulseAudio test server"]
fn encoder_captures_isolated_system_and_microphone_audio() {
// The isolated fixture plays 440 Hz on the default output and 660 Hz
// on the default microphone. Both tones must survive combined capture.
let server = std::env::var("FERESE_TEST_RECORD_PULSE_SERVER").expect("isolated audio server required");
assert_eq!(std::env::var("PULSE_SERVER").unwrap(), server);
let socket = Path::new(server.strip_prefix("unix:").expect("explicit Unix socket required"));
assert!(socket.starts_with(std::env::temp_dir()));
for audio in [Audio::System, Audio::Microphone, Audio::Both, Audio::Both, Audio::Both] {
assert_cropped_audio_recording(false, 0, Some(audio));
}
}
fn assert_cropped_audio_recording(video_eos: bool, audio_count: usize, selected_audio: Option<Audio>) {
use crate::recording::Crop;
use std::sync::Mutex;
gst::init().unwrap();
let directory = tempfile::tempdir().unwrap();
let video = gst::ElementFactory::make("videotestsrc")
.property("is-live", true)
.property("num-buffers", if video_eos { 18i32 } else { -1i32 })
.build()
.unwrap();
let audio = if let Some(selected_audio) = selected_audio {
audio_sources(selected_audio).unwrap()
} else {
(0..audio_count)
.map(|index| {
gst::ElementFactory::make("audiotestsrc")
.property("is-live", true)
.property("freq", 440. + index as f64 * 220.)
.property("volume", 0.2f64)
.build()
.unwrap()
})
.collect()
};
let stop = Arc::new(AtomicBool::new(false));
let request_stop = stop.clone();
let stopper = (!video_eos).then(|| {
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(700));
request_stop.store(true, Ordering::Relaxed);
})
});
let started = Instant::now();
// videotestsrc negotiates 320x240. Crop before RGB conversion, including
// odd edge offsets, and verify the saved stream's negotiated dimensions.
let path = encode(
video,
audio,
Some(Crop {
left: 13,
top: 17,
right: 106,
bottom: 94,
}),
directory.path(),
stop,
)
.unwrap();
if let Some(stopper) = stopper {
stopper.join().unwrap();
}
assert!(started.elapsed() < Duration::from_secs(5));
assert!(!path.with_extension("webm.part").exists());
let decoder = gst::parse::launch(
"filesrc name=input ! matroskademux name=demux \
demux.video_0 ! queue ! vp8dec ! fakesink name=video \
demux.audio_0 ! queue ! opusdec ! audioconvert ! \
audio/x-raw,format=S16LE,rate=48000,channels=1 ! fakesink name=audio",
)
.unwrap()
.downcast::<gst::Pipeline>()
.unwrap();
decoder
.by_name("input")
.unwrap()
.set_property("location", path.to_str().unwrap());
let geometry = Arc::new(Mutex::new(None));
let observed = geometry.clone();
decoder.by_name("video").unwrap().static_pad("sink").unwrap().add_probe(
gst::PadProbeType::BUFFER,
move |pad, _| {
let caps = pad.current_caps().unwrap();
let structure = caps.structure(0).unwrap();
*observed.lock().unwrap() = Some((
structure.get::<i32>("width").unwrap(),
structure.get::<i32>("height").unwrap(),
));
gst::PadProbeReturn::Ok
},
);
let samples = Arc::new(Mutex::new(Vec::new()));
let observed = samples.clone();
decoder.by_name("audio").unwrap().static_pad("sink").unwrap().add_probe(
gst::PadProbeType::BUFFER,
move |_, info| {
let buffer = info.buffer().unwrap();
let data = buffer.map_readable().unwrap();
observed.lock().unwrap().extend(
data.as_slice()
.chunks_exact(2)
.map(|sample| i16::from_le_bytes([sample[0], sample[1]])),
);
gst::PadProbeReturn::Ok
},
);
decoder.set_state(gst::State::Playing).unwrap();
let result = decoder.bus().unwrap().timed_pop_filtered(
gst::ClockTime::from_seconds(5),
&[gst::MessageType::Eos, gst::MessageType::Error],
);
decoder.set_state(gst::State::Null).unwrap();
assert!(
matches!(result.as_ref().map(|m| m.view()), Some(gst::MessageView::Eos(_))),
"Saved recording did not decode: {result:?}"
);
assert_eq!(*geometry.lock().unwrap(), Some((201, 129)));
let samples = samples.lock().unwrap();
assert!(samples.len() >= 4800, "Less than 100 ms of decoded audio");
let frequencies = match selected_audio {
Some(Audio::System) => vec![440.],
Some(Audio::Microphone) => vec![660.],
Some(Audio::Both) => vec![440., 660.],
Some(Audio::Off) => unreachable!("These tests require audio"),
None => (0..audio_count).map(|index| 440. + index as f64 * 220.).collect(),
};
for frequency in frequencies {
let amplitude = tone_amplitude(&samples, frequency);
assert!(
amplitude > 0.05,
"Missing {frequency} Hz source in {selected_audio:?} recording: amplitude {amplitude:.4}"
);
}
}
fn tone_amplitude(samples: &[i16], frequency: f64) -> f64 {
// Analyze the middle half to avoid device startup and encoder padding.
// A Hann window limits leakage from the other tone and codec noise.
let samples = &samples[samples.len() / 4..samples.len() * 3 / 4];
let mut real = 0.;
let mut imaginary = 0.;
let mut weight = 0.;
for (index, sample) in samples.iter().enumerate() {
let window = 0.5 - 0.5 * (std::f64::consts::TAU * index as f64 / (samples.len() - 1) as f64).cos();
let angle = std::f64::consts::TAU * frequency * index as f64 / 48_000.;
let sample = *sample as f64 / i16::MAX as f64 * window;
real += sample * angle.cos();
imaginary += sample * angle.sin();
weight += window;
}
2. * real.hypot(imaginary) / weight
}
#[test]
fn frequency_check_rejects_a_mix_with_either_source_missing() {
let tone = |frequency: f64| {
(0..24_000)
.map(|index| {
(0.2 * i16::MAX as f64 * (std::f64::consts::TAU * frequency * index as f64 / 48_000.).sin()) as i16
})
.collect::<Vec<_>>()
};
let system = tone(440.);
let microphone = tone(660.);
let both = system.iter().zip(&microphone).map(|(a, b)| a + b).collect::<Vec<_>>();
assert!(tone_amplitude(&both, 440.) > 0.05);
assert!(tone_amplitude(&both, 660.) > 0.05);
assert!(tone_amplitude(&microphone, 440.) < 0.01);
assert!(tone_amplitude(&system, 660.) < 0.01);
}
fn assert_video_decodes(path: &Path) { fn assert_video_decodes(path: &Path) {
let decoder = gst::parse::launch("filesrc name=input ! matroskademux ! vp8dec ! fakesink") let decoder = gst::parse::launch("filesrc name=input ! matroskademux ! vp8dec ! fakesink")
.unwrap() .unwrap()
+52
View File
@@ -0,0 +1,52 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Audio {
#[default]
Off,
System,
Microphone,
Both,
}
impl Audio {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::System => "System sound",
Self::Microphone => "Microphone",
Self::Both => "Both",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Recording {
pub(crate) audio: Audio,
pub(crate) crop: Option<Crop>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Crop {
pub(crate) left: i32,
pub(crate) top: i32,
pub(crate) right: i32,
pub(crate) bottom: i32,
}
impl Crop {
pub(crate) fn validate(self) -> Result<(), String> {
if [self.left, self.top, self.right, self.bottom]
.iter()
.any(|value| *value < 0)
|| self.left.checked_add(self.right).is_none()
|| self.top.checked_add(self.bottom).is_none()
{
return Err("Invalid recording crop".into());
}
Ok(())
}
}
+137
View File
@@ -0,0 +1,137 @@
//! Map an approved logical recording region into the upright video stream.
use crate::recording::Crop;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Region {
pub(crate) x: i32,
pub(crate) y: i32,
pub(crate) width: i32,
pub(crate) height: i32,
}
impl Region {
pub(crate) fn parse(value: &str) -> Result<Self, String> {
let (position, size) = value.trim().split_once(' ').ok_or("Invalid region selection")?;
let (x, y) = position.split_once(',').ok_or("Invalid region position")?;
let (width, height) = size.split_once('x').ok_or("Invalid region size")?;
let number = |value: &str| value.parse::<i32>().map_err(|_| "Invalid region coordinate".to_owned());
let region = Self {
x: number(x)?,
y: number(y)?,
width: number(width)?,
height: number(height)?,
};
if region.width <= 0
|| region.height <= 0
|| region.x.checked_add(region.width).is_none()
|| region.y.checked_add(region.height).is_none()
{
return Err("Invalid region bounds".into());
}
Ok(region)
}
pub(crate) fn crop(self, output: Self, physical: (u32, u32)) -> Result<Crop, String> {
let x = i64::from(self.x) - i64::from(output.x);
let y = i64::from(self.y) - i64::from(output.y);
let end_x = x + i64::from(self.width);
let end_y = y + i64::from(self.height);
if self.width <= 0
|| self.height <= 0
|| output.width <= 0
|| output.height <= 0
|| x < 0
|| y < 0
|| end_x > i64::from(output.width)
|| end_y > i64::from(output.height)
|| physical.0 == 0
|| physical.1 == 0
|| physical.0 > i32::MAX as u32
|| physical.1 > i32::MAX as u32
{
return Err("Select a region entirely inside the chosen display".into());
}
// Round inward so pixels outside the selected logical rectangle never
// enter the recording, including at fractional output scales.
let near = |value: i64, pixels: u32, logical: i32| {
(value * i64::from(pixels) + i64::from(logical) - 1) / i64::from(logical)
};
let far = |value: i64, pixels: u32, logical: i32| value * i64::from(pixels) / i64::from(logical);
let left = near(x, physical.0, output.width);
let top = near(y, physical.1, output.height);
let end_x = far(end_x, physical.0, output.width);
let end_y = far(end_y, physical.1, output.height);
if end_x <= left || end_y <= top {
return Err("The selected region is smaller than one output pixel".into());
}
Ok(Crop {
left: left as i32,
top: top as i32,
right: (i64::from(physical.0) - end_x) as i32,
bottom: (i64::from(physical.1) - end_y) as i32,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn region_mapping_handles_negative_origins_and_fractional_scales() {
let output = Region::parse("-1920,20 1920x1080").unwrap();
let selected = Region::parse("-1919,21 100x50").unwrap();
assert_eq!(
selected.crop(output, (2400, 1350)).unwrap(),
Crop {
left: 2,
top: 2,
right: 2274,
bottom: 1287
}
);
assert_eq!(
output.crop(output, (2880, 1620)).unwrap(),
Crop {
left: 0,
top: 0,
right: 0,
bottom: 0
}
);
assert!(
Region::parse("-1921,20 100x50")
.unwrap()
.crop(output, (2400, 1350))
.is_err()
);
assert!(
Region::parse("-100,20 200x50")
.unwrap()
.crop(output, (2400, 1350))
.is_err()
);
}
#[test]
fn region_selection_rejects_empty_overflow_and_malformed_bounds() {
for value in ["", "1,2 0x20", "1,2 -1x20", "2147483647,0 2x20", "1,2 20x30 extra"] {
assert!(Region::parse(value).is_err(), "{value}");
}
assert!(
Crop {
left: -1,
top: 0,
right: 0,
bottom: 0
}
.validate()
.is_err()
);
}
}
+2 -2
View File
@@ -56,10 +56,10 @@ pub struct Recorder {
impl Recorder { impl Recorder {
pub fn label(&self) -> &'static str { pub fn label(&self) -> &'static str {
match self.state { match self.state {
State::Selecting => "Cancel display selection", State::Selecting => "Cancel recording setup",
State::Recording(_) => "Stop recording", State::Recording(_) => "Stop recording",
State::Saving => "Saving recording", State::Saving => "Saving recording",
_ => "Record a display", _ => "Record screen",
} }
} }