Add native screen recording with themed top-bar controls

This commit is contained in:
Samuel Onoja committed 2026-09-28 08:30:59 +01:00
1 parent 0c7ff410a3
commit 6209d556ee
16 files changed
+1060 -29

No files matched your search

Generated
+171 -1
View File
@@ -534,7 +534,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"cexpr", "cexpr",
"clang-sys", "clang-sys",
"itertools", "itertools 0.13.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"regex", "regex",
@@ -1757,6 +1757,7 @@ dependencies = [
"jiff", "jiff",
"libcosmic", "libcosmic",
"serde", "serde",
"serde_json",
"tempfile", "tempfile",
"tokio", "tokio",
"wayland-client", "wayland-client",
@@ -2154,6 +2155,19 @@ dependencies = [
"weezl", "weezl",
] ]
[[package]]
name = "gio-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "gl_generator" name = "gl_generator"
version = "0.14.0" version = "0.14.0"
@@ -2171,6 +2185,50 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
[[package]]
name = "glib"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
dependencies = [
"bitflags 2.13.2",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17"
dependencies = [
"heck 0.5.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "glib-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c"
dependencies = [
"libc",
"system-deps",
]
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.4" version = "0.3.4"
@@ -2198,6 +2256,17 @@ dependencies = [
"gl_generator", "gl_generator",
] ]
[[package]]
name = "gobject-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]] [[package]]
name = "gpu-allocator" name = "gpu-allocator"
version = "0.28.0" version = "0.28.0"
@@ -2238,6 +2307,44 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5"
[[package]]
name = "gstreamer"
version = "0.24.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8251db223ca38d9aefaf3d19f6f11581a9123cd12dacebd8b9e182da965023"
dependencies = [
"cfg-if",
"futures-channel",
"futures-core",
"futures-util",
"glib",
"gstreamer-sys",
"itertools 0.14.0",
"kstring",
"libc",
"muldiv",
"num-integer",
"num-rational",
"option-operations",
"pastey",
"pin-project-lite",
"smallvec",
"thiserror 2.0.21",
]
[[package]]
name = "gstreamer-sys"
version = "0.24.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5d37c1a599ae57b8186948bd5699f2dbfc044baea9d400228b489a85bcf2759"
dependencies = [
"cfg-if",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]] [[package]]
name = "guillotiere" name = "guillotiere"
version = "0.6.2" version = "0.6.2"
@@ -2894,6 +3001,15 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -3110,6 +3226,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "kstring"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1"
dependencies = [
"static_assertions",
]
[[package]] [[package]]
name = "kurbo" name = "kurbo"
version = "0.10.4" version = "0.10.4"
@@ -3550,6 +3675,12 @@ dependencies = [
"pxfm", "pxfm",
] ]
[[package]]
name = "muldiv"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0"
[[package]] [[package]]
name = "mutate_once" name = "mutate_once"
version = "0.1.2" version = "0.1.2"
@@ -3679,6 +3810,25 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -4035,6 +4185,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "option-operations"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aca39cf52b03268400c16eeb9b56382ea3c3353409309b63f5c8f0b1faf42754"
dependencies = [
"pastey",
]
[[package]] [[package]]
name = "orbclient" name = "orbclient"
version = "0.3.55" version = "0.3.55"
@@ -4163,6 +4322,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -7204,13 +7369,18 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
name = "xdg-desktop-portal-ferese" name = "xdg-desktop-portal-ferese"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dirs",
"ferese-config", "ferese-config",
"futures-util",
"gstreamer",
"jiff",
"libc", "libc",
"libcosmic", "libcosmic",
"memmap2 0.9.11", "memmap2 0.9.11",
"pipewire", "pipewire",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"tokio", "tokio",
"wayland-client", "wayland-client",
"wayland-protocols-wlr", "wayland-protocols-wlr",
+10 -6
View File
@@ -23,7 +23,8 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
wayland-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \ wayland-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \
mesa-libgbm-devel mesa-libEGL-devel libdrm-devel fontconfig-devel \ mesa-libgbm-devel mesa-libEGL-devel libdrm-devel fontconfig-devel \
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 xdg-desktop-portal xdg-desktop-portal-gtk clang clang-devel pipewire-devel pipewire wireplumber xdg-desktop-portal xdg-desktop-portal-gtk \
gstreamer1-devel gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer
``` ```
### Arch Linux ### Arch Linux
@@ -32,7 +33,8 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
sudo pacman -S --needed base-devel git curl cmake pkgconf wayland libxkbcommon \ sudo pacman -S --needed base-devel git curl cmake pkgconf wayland libxkbcommon \
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 \ desktop-file-utils foot pam clang pipewire libpipewire wireplumber \
xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal xdg-desktop-portal-gtk \
gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire
``` ```
### Debian and Ubuntu ### Debian and Ubuntu
@@ -44,7 +46,9 @@ sudo apt install build-essential git curl cmake pkg-config libwayland-dev \
libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev dbus-bin \ libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev dbus-bin \
dbus-user-session desktop-file-utils foot libpam0g clang libclang-dev \ dbus-user-session desktop-file-utils foot libpam0g clang libclang-dev \
libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber \ libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber \
xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal xdg-desktop-portal-gtk \
libgstreamer1.0-dev gstreamer1.0-pipewire gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good
``` ```
These are package recipes, not a claim that every distribution release has been These are package recipes, not a claim that every distribution release has been
@@ -90,8 +94,8 @@ The installer adds:
- Versioned releases under `/usr/local/lib/ferese/releases/`, with `current` and - Versioned releases under `/usr/local/lib/ferese/releases/`, with `current` and
`previous` links for upgrades and rollback. `previous` links for upgrades and rollback.
- Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`, - Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`,
`feresectl`, `ferese-lock`, `ferese-screenshot`, and `feresectl`, `ferese-lock`, `ferese-screenshot`,
`xdg-desktop-portal-ferese`. `xdg-desktop-portal-ferese`, and `ferese-record`.
- D-Bus activation and Ferese-specific ScreenCast portal registration. - D-Bus activation and Ferese-specific ScreenCast portal registration.
- A Wayland session entry at `/usr/share/wayland-sessions/ferese.desktop`. - A Wayland session entry at `/usr/share/wayland-sessions/ferese.desktop`.
- A Settings application entry, icons, the default wallpaper, and an example config. - A Settings application entry, icons, the default wallpaper, and an example config.
@@ -251,7 +255,7 @@ Installer options:
`--dry-run` prints the planned commands. `--offline` requires dependencies to be `--dry-run` prints the planned commands. `--offline` requires dependencies to be
cached already. `--skip-build` installs the binaries already in `target/release/`; cached already. `--skip-build` installs the binaries already in `target/release/`;
use it only after building all six components from the intended revision. use it only after building all session components from the intended revision.
Run `./scripts/install.sh --help` for all options. Run `./scripts/install.sh --help` for all options.
## Logout and recovery ## Logout and recovery
+26 -7
View File
@@ -5,8 +5,22 @@ Applications use the standard desktop portal to request a display, then receive
video through PipeWire. A recorder or conferencing application handles encoding, video through PipeWire. A recorder or conferencing application handles encoding,
saving files, or sending video; those tasks do not run inside the compositor. saving files, or sending video; those tasks do not run inside the compositor.
There is no built-in Record button or video-file recorder in the shell yet. ## Record from the top bar
Start recording from an application that supports the Wayland ScreenCast portal.
1. Click the screen-recording icon in the top bar, then **Record display**.
2. Choose a monitor in the native picker and click **Share**.
3. The icon changes to a stop square and shows elapsed time. Click it to stop.
The recorder finishes the video before reporting it saved. Open the recording
menu again to see its saved path. Videos are saved as VP8 WebM files under
`Videos/Ferese` (using your configured XDG Videos folder). Audio is off.
The icon uses the same theme accent, hover effect, and corner radius as the
other bar controls.
Encoding runs in a separate native `ferese-record` process using GStreamer.
The shell remains responsive while a file is recorded or finalized. If saving
fails, an error appears in the recording menu; an unfinished `.webm.part` file
may remain at the path shown in the error.
## Start sharing ## Start sharing
@@ -28,6 +42,9 @@ D-Bus activation entry, and a Ferese-specific portal preference file. Install
`pipewire`, a PipeWire session manager such as `wireplumber`, `pipewire`, a PipeWire session manager such as `wireplumber`,
`xdg-desktop-portal`, and `xdg-desktop-portal-gtk` using your distribution's package `xdg-desktop-portal`, and `xdg-desktop-portal-gtk` using your distribution's package
manager. The GTK backend supplies other portal interfaces such as file selection. manager. The GTK backend supplies other portal interfaces such as file selection.
The built-in recorder also needs GStreamer’s PipeWire, base, and good plugins
(`pipewiresrc`, `videoconvert`, `vp8enc`, and `webmmux`). The installation guide
includes these packages.
Log into a new Ferese session after installation. The session launcher imports Log into a new Ferese session after installation. The session launcher imports
its Wayland display into the D-Bus activation environment. Nested previews leave its Wayland display into the D-Bus activation environment. Nested previews leave
@@ -50,10 +67,9 @@ configuration is preserved during installation.
- Cancellation during selection, an explicit stop control, and process cleanup. - Cancellation during selection, an explicit stop control, and process cleanup.
- ScreenCast backend version 3, without persistent or automatic consent. - ScreenCast backend version 3, without persistent or automatic consent.
Window-only sharing, region selection, audio capture, and a built-in recorder Window-only sharing, region selection, and audio capture in Ferese’s recorder
control in the shell are not implemented yet. Audio can be handled separately by are not implemented yet. Other recording applications can handle audio separately.
the recording application. Video currently uses CPU copies through the Video currently uses CPU copies through the compositor's shared-memory screencopy path, not DMA-BUF zero-copy. High-resolution
compositor's shared-memory screencopy path, not DMA-BUF zero-copy. High-resolution
or multiple-display recording can therefore use significant CPU and memory or multiple-display recording can therefore use significant CPU and memory
bandwidth. A paused stream checks capture availability once per second so locking bandwidth. A paused stream checks capture availability once per second so locking
also ends sessions without an active consumer. also ends sessions without an active consumer.
@@ -66,12 +82,15 @@ Build the backend after installing the native dependencies in the
```sh ```sh
cargo build --release --locked -p xdg-desktop-portal-ferese cargo build --release --locked -p xdg-desktop-portal-ferese
cargo test --locked -p xdg-desktop-portal-ferese cargo test --locked -p xdg-desktop-portal-ferese
# Exercise real video encoding and playback (requires the plugins above):
cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record encoder_finishes_a_real_webm -- --ignored
``` ```
The opt-in integration test opens a temporary nested compositor and uses a The opt-in integration test opens a temporary nested compositor and uses a
private D-Bus session. It requires built Ferese and locker binaries, Python private D-Bus session. It requires built Ferese and locker binaries, Python
GI/GStreamer with `pipewiresrc`, `dbus-daemon`, `xdg-desktop-portal`, and `bwrap`. GI/GStreamer with `pipewiresrc`, `dbus-daemon`, `xdg-desktop-portal`, and `bwrap`.
It checks capture delivery, cancellation, frontend loss, and lock revocation: It checks capture delivery and timestamps, cancellation, frontend loss, and lock
revocation:
```sh ```sh
FERESE_TEST_PORTAL=1 \ FERESE_TEST_PORTAL=1 \
+15
View File
@@ -5,7 +5,19 @@ edition.workspace = true
license.workspace = true license.workspace = true
rust-version = "1.95" rust-version = "1.95"
[[bin]]
name = "xdg-desktop-portal-ferese"
path = "src/main.rs"
[[bin]]
name = "ferese-record"
path = "src/record_main.rs"
[dependencies] [dependencies]
gstreamer = "0.24"
futures-util = "0.3"
dirs = "6"
jiff.workspace = true
libcosmic.workspace = true libcosmic.workspace = true
ferese-config = { path = "../crates/ferese-config" } ferese-config = { path = "../crates/ferese-config" }
libc.workspace = true libc.workspace = true
@@ -17,3 +29,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"
wayland-client.workspace = true wayland-client.workspace = true
wayland-protocols-wlr.workspace = true wayland-protocols-wlr.workspace = true
zbus = { version = "5.19.0", default-features = false, features = ["tokio"] } zbus = { version = "5.19.0", default-features = false, features = ["tokio"] }
[dev-dependencies]
tempfile.workspace = true
+14
View File
@@ -0,0 +1,14 @@
//! Native recording client. The portal retains capture authorization; encoding
//! and file I/O live in this separate process, never in the shell render loop.
mod recorder;
fn main() {
if let Err(error) = tokio::runtime::Runtime::new().and_then(|runtime| {
runtime
.block_on(recorder::run())
.map_err(std::io::Error::other)
}) {
recorder::event("error", Some(&error.to_string()), None);
std::process::exit(1);
}
}
+488
View File
@@ -0,0 +1,488 @@
use futures_util::StreamExt;
use gstreamer::{self as gst, prelude::*};
use std::{
collections::HashMap,
fs::{File, OpenOptions},
io::{Read, Write},
os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use tokio::sync::watch;
use zbus::{
Connection, Proxy,
zvariant::{OwnedFd, OwnedObjectPath, OwnedValue, Value},
};
type Options = HashMap<String, OwnedValue>;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
const DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop";
pub fn event(state: &str, message: Option<&str>, path: Option<&Path>) {
let value = serde_json::json!({"state": state, "message": message, "path": path});
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{value}");
let _ = stdout.flush();
}
fn option_string(value: String) -> OwnedValue {
OwnedValue::try_from(Value::from(value)).unwrap()
}
async fn request<B: serde::Serialize + zbus::zvariant::DynamicType>(
connection: &Connection,
method: &str,
token: &str,
body: &B,
stop: &mut watch::Receiver<bool>,
) -> Result<Option<Options>, String> {
let sender = connection
.unique_name()
.ok_or("No D-Bus identity")?
.as_str()[1..]
.replace('.', "_");
let path = format!("{DESKTOP_PATH}/request/{sender}/{token}");
let request = Proxy::new(
connection,
DESKTOP,
path.as_str(),
"org.freedesktop.portal.Request",
)
.await
.map_err(|e| e.to_string())?;
let mut responses = request
.receive_signal("Response")
.await
.map_err(|e| e.to_string())?;
let portal = Proxy::new(
connection,
DESKTOP,
DESKTOP_PATH,
"org.freedesktop.portal.ScreenCast",
)
.await
.map_err(|e| e.to_string())?;
let returned: OwnedObjectPath = portal
.call(method, body)
.await
.map_err(|e| format!("Screen recording portal unavailable: {e}"))?;
if returned.as_str() != path {
return Err("Unexpected portal request handle".into());
}
if *stop.borrow() {
let _: Result<(), _> = request.call("Close", &()).await;
return Ok(None);
}
tokio::select! {
_ = stop.changed() => {
let _: Result<(), _> = request.call("Close", &()).await;
Ok(None)
}
signal = tokio::time::timeout(Duration::from_secs(125), responses.next()) => {
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())?;
match code { 0 => Ok(Some(options)), 1 => Ok(None), _ => Err("Screen recording request failed".into()) }
}
}
}
pub async fn run() -> Result<(), String> {
gst::init().map_err(|e| e.to_string())?;
for factory in [
"pipewiresrc",
"queue",
"videoconvert",
"vp8enc",
"webmmux",
"fdsink",
] {
if gst::ElementFactory::find(factory).is_none() {
return Err(format!(
"Recording needs the GStreamer {factory} plugin. See the recording installation guide."
));
}
}
let output = output_directory()?;
let (stop_send, mut stop) = watch::channel(false);
std::thread::spawn(move || {
let _ = std::io::stdin().read(&mut [0u8]);
let _ = stop_send.send(true);
});
let connection = Connection::session().await.map_err(|e| e.to_string())?;
let token = |step| format!("ferese_record_{}_{step}", std::process::id());
let Some(created) = request(
&connection,
"CreateSession",
&token(0),
&(HashMap::from([
("handle_token", option_string(token(0))),
("session_handle_token", option_string(token(0))),
]),),
&mut stop,
)
.await?
else {
event("cancelled", None, None);
return Ok(());
};
let handle = created
.get("session_handle")
.ok_or("Portal returned no session")?;
// The public portal historically returns this path as a D-Bus string.
let handle = OwnedObjectPath::try_from(
String::try_from(handle.try_clone().map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
let session = Proxy::new(
&connection,
DESKTOP,
handle.as_str(),
"org.freedesktop.portal.Session",
)
.await
.map_err(|e| e.to_string())?;
let result = record_session(&connection, &session, &handle, &token, &output, &mut stop).await;
let _: Result<(), _> = session.call("Close", &()).await;
match result {
Ok(Some(path)) => event("saved", None, Some(&path)),
Ok(None) => event("cancelled", None, None),
Err(e) => return Err(e),
}
Ok(())
}
async fn record_session(
connection: &Connection,
session: &Proxy<'_>,
handle: &OwnedObjectPath,
token: &impl Fn(u8) -> String,
output: &Path,
stop: &mut watch::Receiver<bool>,
) -> Result<Option<PathBuf>, String> {
let mut closed = session
.receive_signal("Closed")
.await
.map_err(|e| e.to_string())?;
if request(
connection,
"SelectSources",
&token(1),
&(
handle,
HashMap::from([
("handle_token", option_string(token(1))),
("types", 1u32.into()),
("multiple", false.into()),
("cursor_mode", 2u32.into()),
]),
),
stop,
)
.await?
.is_none()
{
return Ok(None);
}
event("selecting", None, None);
let Some(started) = request(
connection,
"Start",
&token(2),
&(
handle,
"",
HashMap::from([("handle_token", option_string(token(2)))]),
),
stop,
)
.await?
else {
return Ok(None);
};
let streams: Vec<(u32, Options)> = started
.get("streams")
.ok_or("Portal returned no video stream")?
.try_clone()
.map_err(|e| e.to_string())?
.try_into()
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
if streams.len() != 1 {
return Err("Recording requires exactly one display".into());
}
let portal = Proxy::new(
connection,
DESKTOP,
DESKTOP_PATH,
"org.freedesktop.portal.ScreenCast",
)
.await
.map_err(|e| e.to_string())?;
let remote: OwnedFd = portal
.call("OpenPipeWireRemote", &(handle, Options::new()))
.await
.map_err(|e| e.to_string())?;
if *stop.borrow() {
return Ok(None);
}
let stop_capture = Arc::new(AtomicBool::new(*stop.borrow()));
let worker_stop = stop_capture.clone();
let node = streams[0].0;
let output = output.to_owned();
let mut worker = tokio::task::spawn_blocking(move || {
let source = gst::ElementFactory::make("pipewiresrc")
.property("fd", remote.as_raw_fd())
.property("path", node.to_string())
.property("do-timestamp", true)
// Keep queued frames valid when the portal revokes PipeWire buffers.
.property("always-copy", true)
.build()
.map_err(|e| e.to_string())?;
let result = encode(source, &output, worker_stop);
drop(remote);
result
});
let result = tokio::select! {
result = &mut worker => result,
_ = stop.changed() => { 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)
}
fn output_directory() -> Result<PathBuf, String> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
match args.as_slice() {
[] => dirs::video_dir()
.or_else(|| dirs::home_dir().map(|p| p.join("Videos")))
.map(|p| p.join("Ferese"))
.ok_or("Cannot find your Videos folder".into()),
[flag, path] if flag == "--output-dir" => Ok(PathBuf::from(path)),
_ => Err("Usage: ferese-record [--output-dir DIRECTORY]".into()),
}
}
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}"))?;
let name = format!(
"Recording-{}-{}",
jiff::Zoned::now().strftime("%Y-%m-%d_%H-%M-%S"),
std::process::id()
);
for suffix in 0..100 {
let final_path = directory.join(format!("{name}-{suffix}.webm"));
let partial = final_path.with_extension("webm.part");
if final_path.exists() {
continue;
}
match OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&partial)
{
Ok(file) => return Ok((file, partial, final_path)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("Cannot save recording: {e}")),
}
}
Err("Cannot choose a unique recording filename".into())
}
fn encode(source: gst::Element, output: &Path, stop: Arc<AtomicBool>) -> Result<PathBuf, String> {
let (file, partial, destination) = recording_file(output)?;
let pipeline = gst::Pipeline::new();
let result = (|| {
let queue = gst::ElementFactory::make("queue")
.property("max-size-buffers", 4u32)
.property("max-size-bytes", 0u32)
.property("max-size-time", 0u64)
.property_from_str("leaky", "downstream")
.build()
.map_err(|e| e.to_string())?;
let convert = gst::ElementFactory::make("videoconvert")
.build()
.map_err(|e| e.to_string())?;
let encoder = gst::ElementFactory::make("vp8enc")
.property("deadline", 1i64)
.property("cpu-used", 8i32)
.property("threads", 4i32)
.property("target-bitrate", 8_000_000i32)
.property("keyframe-max-dist", 60i32)
.build()
.map_err(|e| e.to_string())?;
let mux = gst::ElementFactory::make("webmmux")
.build()
.map_err(|e| e.to_string())?;
let sink = gst::ElementFactory::make("fdsink")
.property("fd", file.as_raw_fd())
.property("sync", false)
.build()
.map_err(|e| e.to_string())?;
pipeline
.add_many([&source, &queue, &convert, &encoder, &mux, &sink])
.map_err(|e| e.to_string())?;
gst::Element::link_many([&source, &queue, &convert, &encoder, &mux, &sink])
.map_err(|e| e.to_string())?;
let announced = Arc::new(AtomicBool::new(false));
let notice = announced.clone();
source
.static_pad("src")
.ok_or("Capture has no video output")?
.add_probe(gst::PadProbeType::BUFFER, move |_, info| {
if info.buffer().is_some_and(|b| b.size() > 0)
&& !notice.swap(true, Ordering::Relaxed)
{
event("recording", None, None);
}
gst::PadProbeReturn::Ok
});
pipeline
.set_state(gst::State::Playing)
.map_err(|e| e.to_string())?;
let bus = pipeline.bus().ok_or("Encoder has no message bus")?;
let mut finishing = None;
let started = Instant::now();
let mut failure = None;
loop {
if stop.load(Ordering::Relaxed) && finishing.is_none() {
event("saving", None, None);
finishing = Some(Instant::now());
// Inject after the capture source: it may already have disappeared
// when the portal closes on lock or monitor removal.
if !queue
.static_pad("sink")
.ok_or("Encoder queue has no input")?
.send_event(gst::event::Eos::new())
{
// The source may already have sent EOS. Let the bus confirm
// finalization; a duplicate EOS can legitimately be rejected.
failure.get_or_insert_with(|| "Encoder could not finish the recording".into());
}
}
if finishing.is_some_and(|t: Instant| t.elapsed() > Duration::from_secs(10)) {
return Err(failure.unwrap_or("Finishing the recording timed out".into()));
}
if !announced.load(Ordering::Relaxed) && started.elapsed() > Duration::from_secs(15) {
return Err("No video frames arrived from the selected display".into());
}
let Some(message) = bus.timed_pop(gst::ClockTime::from_mseconds(50)) else {
continue;
};
match message.view() {
gst::MessageView::Eos(_) => {
if !announced.load(Ordering::Relaxed) {
return Err("Recording ended before receiving video".into());
}
return Ok(());
}
gst::MessageView::Error(error) => {
failure = Some(format!("Recording interrupted: {}", error.error()));
stop.store(true, Ordering::Relaxed);
}
_ => (),
}
}
})();
let _ = pipeline.set_state(gst::State::Null);
result.map_err(|e| format!("{e}. Incomplete file: {}", partial.display()))?;
file.sync_all()
.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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filenames_do_not_overwrite_existing_recordings() {
let directory = tempfile::tempdir().unwrap();
let (_, first, _) = recording_file(directory.path()).unwrap();
let (_, second, _) = recording_file(directory.path()).unwrap();
assert_ne!(first, second);
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(first).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
#[ignore = "requires an explicitly provided temporary PipeWire test node"]
fn encoder_finishes_pipewire_stream() {
gst::init().unwrap();
let node = std::env::var("FERESE_TEST_RECORD_NODE").expect("temporary test node required");
let directory = tempfile::tempdir().unwrap();
let source = gst::ElementFactory::make("pipewiresrc")
.property("path", node)
.property("do-timestamp", true)
// Keep queued frames valid when the portal revokes PipeWire buffers.
.property("always-copy", true)
.build()
.unwrap();
let stop = Arc::new(AtomicBool::new(false));
let request_stop = stop.clone();
let stopper = std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(3));
request_stop.store(true, Ordering::Relaxed);
});
let result = encode(source, directory.path(), stop);
stopper.join().unwrap();
assert_video_decodes(&result.unwrap());
}
#[test]
#[ignore = "requires GStreamer video plugins"]
fn encoder_finishes_a_real_webm() {
gst::init().unwrap();
let directory = tempfile::tempdir().unwrap();
let source = gst::ElementFactory::make("videotestsrc")
.property("num-buffers", 90i32)
.property("is-live", true)
.build()
.unwrap();
let stop = Arc::new(AtomicBool::new(false));
let request_stop = stop.clone();
let stopper = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(350));
request_stop.store(true, Ordering::Relaxed);
});
let path = encode(source, directory.path(), stop).unwrap();
stopper.join().unwrap();
let data = std::fs::read(&path).unwrap();
assert_eq!(&data[..4], &[0x1a, 0x45, 0xdf, 0xa3]);
assert!(data.len() > 1000);
assert_video_decodes(&path);
}
fn assert_video_decodes(path: &Path) {
let decoder = gst::parse::launch("filesrc name=input ! matroskademux ! vp8dec ! fakesink")
.unwrap()
.downcast::<gst::Pipeline>()
.unwrap();
decoder
.by_name("input")
.unwrap()
.set_property("location", path.to_str().unwrap());
decoder.set_state(gst::State::Playing).unwrap();
let message = decoder.bus().unwrap().timed_pop_filtered(
gst::ClockTime::from_seconds(10),
&[gst::MessageType::Eos, gst::MessageType::Error],
);
let duration = decoder.query_duration::<gst::ClockTime>();
decoder.set_state(gst::State::Null).unwrap();
assert!(
duration.is_some_and(|d| d > gst::ClockTime::from_mseconds(100)),
"Invalid saved duration: {duration:?}"
);
assert!(
matches!(
message.as_ref().map(|m| m.view()),
Some(gst::MessageView::Eos(_))
),
"Saved video did not decode: {message:?}"
);
}
}
+87 -3
View File
@@ -26,6 +26,71 @@ struct Data {
streaming: Arc<AtomicBool>, streaming: Arc<AtomicBool>,
fresh: Arc<AtomicBool>, fresh: Arc<AtomicBool>,
announced: bool, announced: bool,
sequence: u64,
}
// pipewire-rs exposes read-only metadata through Buffer. Own a dequeued raw
// buffer here so header writes retain exclusive access and every exit requeues it.
struct OutputBuffer<'a> {
stream: &'a pw::stream::Stream,
raw: std::ptr::NonNull<pw::sys::pw_buffer>,
}
impl<'a> OutputBuffer<'a> {
fn dequeue(stream: &'a pw::stream::Stream) -> Option<Self> {
// SAFETY: called on the owning PipeWire loop; the guard queues exactly once.
let raw = std::ptr::NonNull::new(unsafe { stream.dequeue_raw_buffer() })?;
Some(Self { stream, raw })
}
fn data(&mut self) -> Option<&mut spa::buffer::Data> {
// SAFETY: the dequeued buffer is exclusively ours until Drop. SPA Data
// is repr(transparent), and PipeWire owns the mapped allocation.
unsafe {
let buffer = self.raw.as_ref().buffer.as_mut()?;
if buffer.n_datas == 0 || buffer.datas.is_null() {
return None;
}
buffer.datas.cast::<spa::buffer::Data>().as_mut()
}
}
fn timestamp(&mut self, sequence: u64) {
// SAFETY: mutable access is exclusive, the metadata lookup checks its size,
// and clock_gettime writes only to our initialized stack timespec.
unsafe {
let buffer = self.raw.as_ref().buffer;
if buffer.is_null() {
return;
}
let header = spa::sys::spa_buffer_find_meta_data(
buffer,
spa::sys::SPA_META_Header,
std::mem::size_of::<spa::sys::spa_meta_header>(),
)
.cast::<spa::sys::spa_meta_header>();
if let Some(header) = header.as_mut() {
let mut now = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
header.pts = if libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) == 0 {
now.tv_sec * 1_000_000_000 + now.tv_nsec
} else {
-1
};
header.flags = 0;
header.offset = 0;
header.dts_offset = 0;
header.seq = sequence;
}
}
}
}
impl Drop for OutputBuffer<'_> {
fn drop(&mut self) {
// SAFETY: raw came from this stream and has not yet been queued.
unsafe {
self.stream.queue_raw_buffer(self.raw.as_ptr());
}
}
} }
fn int_property(key: u32, value: i32) -> spa::pod::Property { fn int_property(key: u32, value: i32) -> spa::pod::Property {
@@ -88,6 +153,7 @@ pub fn run(name: String, cursor: bool) -> Result<(), Box<dyn std::error::Error>>
streaming: streaming.clone(), streaming: streaming.clone(),
fresh: fresh.clone(), fresh: fresh.clone(),
announced: false, announced: false,
sequence: 0,
}; };
let _listener = stream let _listener = stream
.add_local_listener_with_user_data(data) .add_local_listener_with_user_data(data)
@@ -152,18 +218,34 @@ pub fn run(name: String, cursor: bool) -> Result<(), Box<dyn std::error::Error>>
int_property(spa::sys::SPA_PARAM_BUFFERS_stride, (width * 4) as i32), int_property(spa::sys::SPA_PARAM_BUFFERS_stride, (width * 4) as i32),
int_property(spa::sys::SPA_PARAM_BUFFERS_align, 16) int_property(spa::sys::SPA_PARAM_BUFFERS_align, 16)
)); ));
let header = pod(spa::pod::object!(
spa::utils::SpaTypes::ObjectParamMeta,
spa::param::ParamType::Meta,
spa::pod::Property {
key: spa::sys::SPA_PARAM_META_type,
flags: spa::pod::PropertyFlags::empty(),
value: spa::pod::Value::Id(spa::utils::Id(spa::sys::SPA_META_Header)),
},
int_property(
spa::sys::SPA_PARAM_META_size,
std::mem::size_of::<spa::sys::spa_meta_header>() as i32
)
));
if stream if stream
.update_params(&mut [Pod::from_bytes(&buffers).unwrap()]) .update_params(&mut [
Pod::from_bytes(&buffers).unwrap(),
Pod::from_bytes(&header).unwrap(),
])
.is_err() .is_err()
{ {
data.stop.store(true, Ordering::Relaxed); data.stop.store(true, Ordering::Relaxed);
} }
}) })
.process(|stream, data| { .process(|stream, data| {
let Some(mut buffer) = stream.dequeue_buffer() else { let Some(mut buffer) = OutputBuffer::dequeue(stream) else {
return; return;
}; };
let Some(target) = buffer.datas_mut().first_mut() else { let Some(target) = buffer.data() else {
return; return;
}; };
if data.stop.load(Ordering::Relaxed) || !data.fresh.load(Ordering::SeqCst) { if data.stop.load(Ordering::Relaxed) || !data.fresh.load(Ordering::SeqCst) {
@@ -189,6 +271,8 @@ pub fn run(name: String, cursor: bool) -> Result<(), Box<dyn std::error::Error>>
*target.chunk_mut().offset_mut() = 0; *target.chunk_mut().offset_mut() = 0;
*target.chunk_mut().size_mut() = bytes as u32; *target.chunk_mut().size_mut() = bytes as u32;
*target.chunk_mut().stride_mut() = row as i32; *target.chunk_mut().stride_mut() = row as i32;
buffer.timestamp(data.sequence);
data.sequence = data.sequence.wrapping_add(1);
}) })
.register()?; .register()?;
+4 -4
View File
@@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; } [[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
# Refuse to overwrite unrelated installations. # Refuse to overwrite unrelated installations.
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese; do for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
link=/usr/local/bin/$name link=/usr/local/bin/$name
if [[ -e $link || -L $link ]]; then if [[ -e $link || -L $link ]]; then
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || { [[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
@@ -39,7 +39,7 @@ if [[ -e $session_entry ]]; then
echo "Different session entry exists: $session_entry" >&2; exit 1; echo "Different session entry exists: $session_entry" >&2; exit 1;
} }
fi fi
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese; do for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese ferese-record; do
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; } [[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
done done
for name in ferese.png ferese.svg; do for name in ferese.png ferese.svg; do
@@ -79,7 +79,7 @@ install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions
if [[ -e $session_entry ]]; then if [[ -e $session_entry ]]; then
install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop" install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop"
fi fi
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese; do for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese ferese-record; do
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name" install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
done done
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
@@ -101,7 +101,7 @@ if [[ -L $install_root/current ]]; then
fi fi
ln -s -- "releases/$release_id" "$install_root/current-$release_id" ln -s -- "releases/$release_id" "$install_root/current-$release_id"
mv -Tf -- "$install_root/current-$release_id" "$install_root/current" mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese; do for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name" ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
done done
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry" install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
+4 -1
View File
@@ -71,10 +71,11 @@ class PortalTest(unittest.TestCase):
if fd is not None: if fd is not None:
source += f' fd={fd}' source += f' fd={fd}'
pipeline = Gst.parse_launch(source + ' ! video/x-raw,format=BGRx ! appsink name=sink sync=false emit-signals=true') pipeline = Gst.parse_launch(source + ' ! video/x-raw,format=BGRx ! appsink name=sink sync=false emit-signals=true')
sizes = [] sizes, timestamps = [], []
def sample(sink): def sample(sink):
buffer = sink.emit("pull-sample").get_buffer() buffer = sink.emit("pull-sample").get_buffer()
sizes.append(buffer.get_size()) sizes.append(buffer.get_size())
timestamps.append(buffer.pts)
return Gst.FlowReturn.OK return Gst.FlowReturn.OK
pipeline.get_by_name("sink").connect("new-sample", sample) pipeline.get_by_name("sink").connect("new-sample", sample)
try: try:
@@ -84,6 +85,8 @@ class PortalTest(unittest.TestCase):
self.assertEqual(message.type, Gst.MessageType.EOS) self.assertEqual(message.type, Gst.MessageType.EOS)
self.assertEqual(len(sizes), 15) self.assertEqual(len(sizes), 15)
self.assertGreater(min(sizes), 0) self.assertGreater(min(sizes), 0)
self.assertTrue(all(b > a for a, b in zip(timestamps, timestamps[1:])), timestamps)
self.assertGreater(timestamps[-1] - timestamps[0], Gst.SECOND // 5)
finally: finally:
pipeline.set_state(Gst.State.NULL) pipeline.set_state(Gst.State.NULL)
+1
View File
@@ -15,6 +15,7 @@ jiff.workspace = true
libcosmic = { workspace = true, features = ["wgpu", "a11y"] } libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" } iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true tempfile.workspace = true
tokio = { version = "1", features = ["time", "rt", "sync"] } tokio = { version = "1", features = ["time", "rt", "sync"] }
wayland-client.workspace = true wayland-client.workspace = true
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Stop recording">
<rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/>
<path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<rect x="22" y="18" width="16" height="16" rx="3" fill="#3d7be6"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Record screen">
<rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/>
<path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<circle cx="30" cy="26" r="9" fill="#3d7be6"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

+18
View File
@@ -4,6 +4,7 @@ mod motion;
mod note_store; mod note_store;
mod notification_ui; mod notification_ui;
mod notifications; mod notifications;
mod recording;
mod status; mod status;
mod status_ui; mod status_ui;
@@ -205,6 +206,7 @@ struct FereseShell {
notification_surface: Option<notification_ui::NotificationSurface>, notification_surface: Option<notification_ui::NotificationSurface>,
status_service: status::Service, status_service: status::Service,
status: status::Snapshot, status: status::Snapshot,
recorder: recording::Recorder,
status_error: Option<String>, status_error: Option<String>,
menu: Option<status_ui::OpenMenu>, menu: Option<status_ui::OpenMenu>,
note_editor: Option<DesktopNoteEditor>, note_editor: Option<DesktopNoteEditor>,
@@ -264,6 +266,8 @@ enum Message {
ActivateWorkspace(u64), ActivateWorkspace(u64),
ToggleOverview, ToggleOverview,
StatusUpdated(status::Update), StatusUpdated(status::Update),
StartRecording,
StopRecording,
NotificationEvent(notifications::Event), NotificationEvent(notifications::Event),
NotificationTick, NotificationTick,
DismissNotification(u32), DismissNotification(u32),
@@ -311,6 +315,7 @@ impl cosmic::Application for FereseShell {
notification_surface: None, notification_surface: None,
status_service: status::Service::start(config.status.settings_command.clone()), status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(), status: status::Snapshot::default(),
recorder: recording::Recorder::default(),
status_error: None, status_error: None,
menu: None, menu: None,
note_editor: None, note_editor: None,
@@ -543,6 +548,18 @@ impl cosmic::Application for FereseShell {
} }
Task::none() Task::none()
} }
Message::StartRecording => {
self.recorder.start();
if self.recorder.busy() {
self.close_menu()
} else {
Task::none()
}
}
Message::StopRecording => {
self.recorder.stop();
Task::none()
}
Message::StatusUpdated(update) => { Message::StatusUpdated(update) => {
if update.generation >= self.status_service.generation { if update.generation >= self.status_service.generation {
self.status = update.snapshot; self.status = update.snapshot;
@@ -659,6 +676,7 @@ impl cosmic::Application for FereseShell {
} }
Message::Event(event, id) => self.handle_event(event, id), Message::Event(event, id) => self.handle_event(event, id),
Message::Tick => { Message::Tick => {
self.recorder.poll();
self.clock = current_time(); self.clock = current_time();
if self.config.desktop_widgets.clock.enabled { if self.config.desktop_widgets.clock.enabled {
self.desktop_clock = self self.desktop_clock = self
+141
View File
@@ -0,0 +1,141 @@
//! The bar only controls the recorder and consumes bounded status messages.
//! Encoding and portal negotiation run in the separate ferese-record process.
use serde::Deserialize;
use std::{
io::{BufRead, BufReader, Read},
path::PathBuf,
process::{ChildStdin, Command, Stdio},
sync::mpsc::{self, Receiver},
time::Instant,
};
#[derive(Debug, Default)]
pub enum State {
#[default]
Idle,
Selecting,
Recording(Instant),
Saving,
Saved(PathBuf),
Error(String),
}
#[derive(Deserialize)]
struct Update {
state: String,
message: Option<String>,
path: Option<PathBuf>,
}
enum Event {
Update(Update),
Exited,
}
#[derive(Default)]
pub struct Recorder {
pub state: State,
control: Option<ChildStdin>,
updates: Option<Receiver<Event>>,
}
impl Recorder {
pub fn busy(&self) -> bool {
self.updates.is_some()
}
pub fn elapsed(&self) -> Option<String> {
let State::Recording(start) = self.state else {
return None;
};
let seconds = start.elapsed().as_secs();
Some(format!("{:02}:{:02}", seconds / 60, seconds % 60))
}
pub fn start(&mut self) {
if self.busy() {
return;
}
let program = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join("ferese-record")))
.filter(|p| p.is_file())
.unwrap_or_else(|| PathBuf::from("ferese-record"));
match Command::new(program)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
{
Ok(mut child) => {
self.control = child.stdin.take();
let output = child.stdout.take().unwrap();
let (send, receive) = mpsc::sync_channel(8);
self.updates = Some(receive);
self.state = State::Selecting;
std::thread::spawn(move || {
let mut output = BufReader::new(output);
loop {
let mut line = String::new();
match output.by_ref().take(65537).read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) if line.len() > 65536 => break,
Ok(_) => {
if let Ok(update) = serde_json::from_str::<Update>(&line) {
if send.send(Event::Update(update)).is_err() {
break;
}
}
}
}
}
let _ = child.wait();
let _ = send.send(Event::Exited);
});
}
Err(error) => self.state = State::Error(format!("Cannot start recorder: {error}")),
}
}
pub fn stop(&mut self) {
if self.busy() {
// EOF requests EOS/finalization. Never terminate the encoder abruptly.
self.control.take();
self.state = State::Saving;
}
}
pub fn poll(&mut self) {
let mut done = false;
if let Some(updates) = &self.updates {
while let Ok(event) = updates.try_recv() {
match event {
Event::Update(update) => match update.state.as_str() {
"selecting" if self.control.is_some() => self.state = State::Selecting,
"recording" if self.control.is_some() => {
self.state = State::Recording(Instant::now())
}
"saving" => self.state = State::Saving,
"saved" => {
self.state = update.path.map(State::Saved).unwrap_or_else(|| {
State::Error("Recorder returned no saved file".into())
})
}
"cancelled" => self.state = State::Idle,
"error" => {
self.state = State::Error(
update.message.unwrap_or_else(|| "Recording failed".into()),
)
}
_ => (),
},
Event::Exited => {
if matches!(
self.state,
State::Selecting | State::Recording(_) | State::Saving
) {
self.state = State::Error("The recorder stopped unexpectedly".into());
}
done = true;
}
}
}
}
if done {
self.control.take();
self.updates.take();
}
}
}
+69 -4
View File
@@ -16,6 +16,7 @@ pub enum Menu {
Bluetooth, Bluetooth,
Audio, Audio,
Battery, Battery,
Recording,
Notifications, Notifications,
System, System,
} }
@@ -41,6 +42,7 @@ impl Menu {
Self::Bluetooth => "Bluetooth", Self::Bluetooth => "Bluetooth",
Self::Audio => "Sound", Self::Audio => "Sound",
Self::Battery => "Battery", Self::Battery => "Battery",
Self::Recording => "Screen recording",
Self::Notifications => "Notifications", Self::Notifications => "Notifications",
} }
} }
@@ -52,7 +54,7 @@ impl Menu {
Self::Audio => status.audio.is_some(), Self::Audio => status.audio.is_some(),
Self::Battery => status.battery.is_some(), Self::Battery => status.battery.is_some(),
Self::Notifications => status.notifications.is_some(), Self::Notifications => status.notifications.is_some(),
Self::System => true, Self::System | Self::Recording => true,
} }
} }
} }
@@ -242,19 +244,27 @@ impl FereseShell {
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> { pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme.for_bar(); let theme = self.config.theme.for_bar();
let metrics = BarMetrics::from(theme); let metrics = BarMetrics::from(theme);
let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center); let mut controls = row::with_capacity(7).spacing(1).align_y(Alignment::Center);
for kind in [ for kind in [
Menu::System, Menu::System,
Menu::Network, Menu::Network,
Menu::Bluetooth, Menu::Bluetooth,
Menu::Audio, Menu::Audio,
Menu::Recording,
Menu::Notifications, Menu::Notifications,
Menu::Battery, Menu::Battery,
] { ] {
if !kind.available(&self.status) { if !kind.available(&self.status) {
continue; continue;
} }
let (source, enabled) = status_icon(kind, &self.status); let (source, enabled) = if kind == Menu::Recording && self.recorder.busy() {
(
include_bytes!("../assets/icons/status/record-stop.svg").as_slice(),
true,
)
} else {
status_icon(kind, &self.status)
};
let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind); let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind);
let foreground = color(if selected { let foreground = color(if selected {
theme.accent theme.accent
@@ -271,6 +281,15 @@ impl FereseShell {
)] )]
.spacing(4) .spacing(4)
.align_y(Alignment::Center); .align_y(Alignment::Center);
if kind == Menu::Recording
&& let Some(elapsed) = self.recorder.elapsed()
{
content = content.push(
text(elapsed)
.size(metrics.text_size)
.class(theme::Text::Color(foreground)),
);
}
if kind == Menu::Battery && self.config.status.battery_percentage { if kind == Menu::Battery && self.config.status.battery_percentage {
if let Some(battery) = &self.status.battery { if let Some(battery) = &self.status.battery {
content = content.push( content = content.push(
@@ -304,14 +323,22 @@ impl FereseShell {
.align_x(alignment::Horizontal::Center) .align_x(alignment::Horizontal::Center)
.height(metrics.control_height) .height(metrics.control_height)
.align_y(alignment::Vertical::Center); .align_y(alignment::Vertical::Center);
let recording_busy = kind == Menu::Recording && self.recorder.busy();
let control = button::custom(content) let control = button::custom(content)
.name(status_label(kind, &self.status)) .name(if recording_busy {
"Stop screen recording".into()
} else {
status_label(kind, &self.status)
})
.padding([ .padding([
0.0, 0.0,
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0), ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
]) ])
.height(metrics.control_height) .height(metrics.control_height)
.on_press_with_rectangle(move |offset, bounds| { .on_press_with_rectangle(move |offset, bounds| {
if recording_busy {
return cosmic::Action::App(Message::StopRecording);
}
cosmic::Action::App(Message::OpenMenu( cosmic::Action::App(Message::OpenMenu(
kind, kind,
Rectangle { Rectangle {
@@ -407,6 +434,42 @@ impl FereseShell {
] ]
.spacing(8), .spacing(8),
); );
} else if kind == Menu::Recording {
use recording::State;
let description = match &self.recorder.state {
State::Idle => {
"Record a display. Video is saved to Videos/Ferese as WebM. Audio is off."
.to_owned()
}
State::Selecting => "Choose a display in the sharing dialog.".to_owned(),
State::Recording(_) => format!(
"Recording {} · audio off",
self.recorder.elapsed().unwrap_or_default()
),
State::Saving => "Finishing your recording…".to_owned(),
State::Saved(path) => format!("Saved to {}", path.display()),
State::Error(message) => message.clone(),
};
rows = rows.push(text(description).size(13).class(theme::Text::Color(muted)));
if !self.recorder.busy() {
rows = rows.push(menu_button(
"Record display",
Message::StartRecording,
primary,
p,
));
} else if !matches!(self.recorder.state, State::Saving) {
rows = rows.push(menu_button(
if matches!(self.recorder.state, State::Selecting) {
"Cancel"
} else {
"Stop and save"
},
Message::StopRecording,
primary,
p,
));
}
} else { } else {
let combined = menu.kind == Menu::System; let combined = menu.kind == Menu::System;
if combined { if combined {
@@ -1106,6 +1169,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
|| "Battery: unavailable".into(), || "Battery: unavailable".into(),
|b| format!("Battery: {}%, {}", b.percent, b.status), |b| format!("Battery: {}%, {}", b.percent, b.status),
), ),
Menu::Recording => "Screen recording".into(),
Menu::System => "Control Center".into(), Menu::System => "Control Center".into(),
} }
} }
@@ -1193,6 +1257,7 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
) )
} }
} }
Menu::Recording => (include_bytes!("../assets/icons/status/record.svg"), true),
Menu::System => ( Menu::System => (
include_bytes!("../assets/icons/status/control-center.svg"), include_bytes!("../assets/icons/status/control-center.svg"),
true, true,
+2 -3
View File
@@ -48,9 +48,8 @@ Ferese reloads changes as you make them. An invalid edit keeps the last working
## Share your screen ## Share your screen
Use Ferese’s native display picker to share a monitor with a portal-compatible Use Ferese’s native display picker to share a monitor with a portal-compatible
recording or conferencing app. The app handles saving or sending video; Ferese recording or conferencing app. To save a video directly, click the recording
does not yet have a Record button in the shell. See [Screen sharing and icon in the top bar. See [Screen sharing and recording](screen-sharing.md) for setup, stopping a session, and supported sources.
recording](screen-sharing.md) for setup, stopping a session, and supported sources.
## Keep a recovery path ## Keep a recovery path