From 6209d556ee7323db8fb2b63a3c7bc8e2d3355ed2 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 28 Sep 2026 08:30:59 +0100 Subject: [PATCH] Add native screen recording with themed top-bar controls --- Cargo.lock | 172 +++++- docs/installation.md | 16 +- docs/screen-sharing.md | 33 +- portal/Cargo.toml | 15 + portal/src/record_main.rs | 14 + portal/src/recorder.rs | 488 ++++++++++++++++++ portal/src/stream.rs | 90 +++- scripts/install-session.sh | 8 +- scripts/tests/test_portal_isolated.py | 5 +- shell/ferese-shell/Cargo.toml | 1 + .../assets/icons/status/record-stop.svg | 5 + .../assets/icons/status/record.svg | 5 + shell/ferese-shell/src/main.rs | 18 + shell/ferese-shell/src/recording.rs | 141 +++++ shell/ferese-shell/src/status_ui.rs | 73 ++- site/content/index.md | 5 +- 16 files changed, 1060 insertions(+), 29 deletions(-) create mode 100644 portal/src/record_main.rs create mode 100644 portal/src/recorder.rs create mode 100644 shell/ferese-shell/assets/icons/status/record-stop.svg create mode 100644 shell/ferese-shell/assets/icons/status/record.svg create mode 100644 shell/ferese-shell/src/recording.rs diff --git a/Cargo.lock b/Cargo.lock index f0d844d..faa0376 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -534,7 +534,7 @@ dependencies = [ "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -1757,6 +1757,7 @@ dependencies = [ "jiff", "libcosmic", "serde", + "serde_json", "tempfile", "tokio", "wayland-client", @@ -2154,6 +2155,19 @@ dependencies = [ "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]] name = "gl_generator" version = "0.14.0" @@ -2171,6 +2185,50 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "glob" version = "0.3.4" @@ -2198,6 +2256,17 @@ dependencies = [ "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]] name = "gpu-allocator" version = "0.28.0" @@ -2238,6 +2307,44 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "guillotiere" version = "0.6.2" @@ -2894,6 +3001,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3110,6 +3226,15 @@ dependencies = [ "libc", ] +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + [[package]] name = "kurbo" version = "0.10.4" @@ -3550,6 +3675,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muldiv" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" + [[package]] name = "mutate_once" version = "0.1.2" @@ -3679,6 +3810,25 @@ dependencies = [ "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]] name = "num-traits" version = "0.2.19" @@ -4035,6 +4185,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "orbclient" version = "0.3.55" @@ -4163,6 +4322,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -7204,13 +7369,18 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" name = "xdg-desktop-portal-ferese" version = "0.1.0" dependencies = [ + "dirs", "ferese-config", + "futures-util", + "gstreamer", + "jiff", "libc", "libcosmic", "memmap2 0.9.11", "pipewire", "serde", "serde_json", + "tempfile", "tokio", "wayland-client", "wayland-protocols-wlr", diff --git a/docs/installation.md b/docs/installation.md index 38bb037..70d6a4a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 \ mesa-libgbm-devel mesa-libEGL-devel libdrm-devel fontconfig-devel \ 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 @@ -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 \ libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \ 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 @@ -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 \ dbus-user-session desktop-file-utils foot libpam0g clang libclang-dev \ 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 @@ -90,8 +94,8 @@ The installer adds: - Versioned releases under `/usr/local/lib/ferese/releases/`, with `current` and `previous` links for upgrades and rollback. - Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`, - `feresectl`, `ferese-lock`, `ferese-screenshot`, and - `xdg-desktop-portal-ferese`. + `feresectl`, `ferese-lock`, `ferese-screenshot`, + `xdg-desktop-portal-ferese`, and `ferese-record`. - D-Bus activation and Ferese-specific ScreenCast portal registration. - A Wayland session entry at `/usr/share/wayland-sessions/ferese.desktop`. - 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 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. ## Logout and recovery diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 48397f9..6ad7d3e 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -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, 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. -Start recording from an application that supports the Wayland ScreenCast portal. +## Record from the top bar + +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 @@ -28,6 +42,9 @@ D-Bus activation entry, and a Ferese-specific portal preference file. Install `pipewire`, a PipeWire session manager such as `wireplumber`, `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. +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 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. - ScreenCast backend version 3, without persistent or automatic consent. -Window-only sharing, region selection, audio capture, and a built-in recorder -control in the shell are not implemented yet. Audio can be handled separately by -the recording application. Video currently uses CPU copies through the -compositor's shared-memory screencopy path, not DMA-BUF zero-copy. High-resolution +Window-only sharing, region selection, and audio capture in Ferese’s recorder +are not implemented yet. Other recording applications can handle audio separately. +Video currently uses CPU copies through the compositor's shared-memory screencopy path, not DMA-BUF zero-copy. High-resolution or multiple-display recording can therefore use significant CPU and memory bandwidth. A paused stream checks capture availability once per second so locking also ends sessions without an active consumer. @@ -66,12 +82,15 @@ Build the backend after installing the native dependencies in the ```sh cargo build --release --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 private D-Bus session. It requires built Ferese and locker binaries, Python 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 FERESE_TEST_PORTAL=1 \ diff --git a/portal/Cargo.toml b/portal/Cargo.toml index b1c941a..c071341 100644 --- a/portal/Cargo.toml +++ b/portal/Cargo.toml @@ -5,7 +5,19 @@ edition.workspace = true license.workspace = true rust-version = "1.95" +[[bin]] +name = "xdg-desktop-portal-ferese" +path = "src/main.rs" + +[[bin]] +name = "ferese-record" +path = "src/record_main.rs" + [dependencies] +gstreamer = "0.24" +futures-util = "0.3" +dirs = "6" +jiff.workspace = true libcosmic.workspace = true ferese-config = { path = "../crates/ferese-config" } libc.workspace = true @@ -17,3 +29,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync" wayland-client.workspace = true wayland-protocols-wlr.workspace = true zbus = { version = "5.19.0", default-features = false, features = ["tokio"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/portal/src/record_main.rs b/portal/src/record_main.rs new file mode 100644 index 0000000..0fc9cf8 --- /dev/null +++ b/portal/src/record_main.rs @@ -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); + } +} diff --git a/portal/src/recorder.rs b/portal/src/recorder.rs new file mode 100644 index 0000000..f13745d --- /dev/null +++ b/portal/src/recorder.rs @@ -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; +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( + connection: &Connection, + method: &str, + token: &str, + body: &B, + stop: &mut watch::Receiver, +) -> Result, 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, +) -> Result, 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 { + 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) -> Result { + 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::() + .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::(); + 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:?}" + ); + } +} diff --git a/portal/src/stream.rs b/portal/src/stream.rs index 0e1144d..109dd88 100644 --- a/portal/src/stream.rs +++ b/portal/src/stream.rs @@ -26,6 +26,71 @@ struct Data { streaming: Arc, fresh: Arc, 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, +} +impl<'a> OutputBuffer<'a> { + fn dequeue(stream: &'a pw::stream::Stream) -> Option { + // 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::().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::(), + ) + .cast::(); + 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 { @@ -88,6 +153,7 @@ pub fn run(name: String, cursor: bool) -> Result<(), Box> streaming: streaming.clone(), fresh: fresh.clone(), announced: false, + sequence: 0, }; let _listener = stream .add_local_listener_with_user_data(data) @@ -152,18 +218,34 @@ pub fn run(name: String, cursor: bool) -> Result<(), Box> int_property(spa::sys::SPA_PARAM_BUFFERS_stride, (width * 4) as i32), 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::() as i32 + ) + )); 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() { data.stop.store(true, Ordering::Relaxed); } }) .process(|stream, data| { - let Some(mut buffer) = stream.dequeue_buffer() else { + let Some(mut buffer) = OutputBuffer::dequeue(stream) else { return; }; - let Some(target) = buffer.datas_mut().first_mut() else { + let Some(target) = buffer.data() else { return; }; if data.stop.load(Ordering::Relaxed) || !data.fresh.load(Ordering::SeqCst) { @@ -189,6 +271,8 @@ pub fn run(name: String, cursor: bool) -> Result<(), Box> *target.chunk_mut().offset_mut() = 0; *target.chunk_mut().size_mut() = bytes as u32; *target.chunk_mut().stride_mut() = row as i32; + buffer.timestamp(data.sequence); + data.sequence = data.sequence.wrapping_add(1); }) .register()?; diff --git a/scripts/install-session.sh b/scripts/install-session.sh index be738ea..0570135 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop [[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; } # 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 if [[ -e $link || -L $link ]]; then [[ -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; } 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; } done 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 install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop" 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" done install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" @@ -101,7 +101,7 @@ if [[ -L $install_root/current ]]; then fi ln -s -- "releases/$release_id" "$install_root/current-$release_id" 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" done install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry" diff --git a/scripts/tests/test_portal_isolated.py b/scripts/tests/test_portal_isolated.py index 7372337..79ad131 100644 --- a/scripts/tests/test_portal_isolated.py +++ b/scripts/tests/test_portal_isolated.py @@ -71,10 +71,11 @@ class PortalTest(unittest.TestCase): if fd is not None: source += f' fd={fd}' pipeline = Gst.parse_launch(source + ' ! video/x-raw,format=BGRx ! appsink name=sink sync=false emit-signals=true') - sizes = [] + sizes, timestamps = [], [] def sample(sink): buffer = sink.emit("pull-sample").get_buffer() sizes.append(buffer.get_size()) + timestamps.append(buffer.pts) return Gst.FlowReturn.OK pipeline.get_by_name("sink").connect("new-sample", sample) try: @@ -84,6 +85,8 @@ class PortalTest(unittest.TestCase): self.assertEqual(message.type, Gst.MessageType.EOS) self.assertEqual(len(sizes), 15) 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: pipeline.set_state(Gst.State.NULL) diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 4aa9927..4d21f25 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -15,6 +15,7 @@ jiff.workspace = true libcosmic = { workspace = true, features = ["wgpu", "a11y"] } iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" } serde.workspace = true +serde_json.workspace = true tempfile.workspace = true tokio = { version = "1", features = ["time", "rt", "sync"] } wayland-client.workspace = true diff --git a/shell/ferese-shell/assets/icons/status/record-stop.svg b/shell/ferese-shell/assets/icons/status/record-stop.svg new file mode 100644 index 0000000..015bfe7 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/record-stop.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/record.svg b/shell/ferese-shell/assets/icons/status/record.svg new file mode 100644 index 0000000..6dfa83e --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/record.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index b6d02de..d321578 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -4,6 +4,7 @@ mod motion; mod note_store; mod notification_ui; mod notifications; +mod recording; mod status; mod status_ui; @@ -205,6 +206,7 @@ struct FereseShell { notification_surface: Option, status_service: status::Service, status: status::Snapshot, + recorder: recording::Recorder, status_error: Option, menu: Option, note_editor: Option, @@ -264,6 +266,8 @@ enum Message { ActivateWorkspace(u64), ToggleOverview, StatusUpdated(status::Update), + StartRecording, + StopRecording, NotificationEvent(notifications::Event), NotificationTick, DismissNotification(u32), @@ -311,6 +315,7 @@ impl cosmic::Application for FereseShell { notification_surface: None, status_service: status::Service::start(config.status.settings_command.clone()), status: status::Snapshot::default(), + recorder: recording::Recorder::default(), status_error: None, menu: None, note_editor: None, @@ -543,6 +548,18 @@ impl cosmic::Application for FereseShell { } 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) => { if update.generation >= self.status_service.generation { self.status = update.snapshot; @@ -659,6 +676,7 @@ impl cosmic::Application for FereseShell { } Message::Event(event, id) => self.handle_event(event, id), Message::Tick => { + self.recorder.poll(); self.clock = current_time(); if self.config.desktop_widgets.clock.enabled { self.desktop_clock = self diff --git a/shell/ferese-shell/src/recording.rs b/shell/ferese-shell/src/recording.rs new file mode 100644 index 0000000..9801aa6 --- /dev/null +++ b/shell/ferese-shell/src/recording.rs @@ -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, + path: Option, +} +enum Event { + Update(Update), + Exited, +} +#[derive(Default)] +pub struct Recorder { + pub state: State, + control: Option, + updates: Option>, +} +impl Recorder { + pub fn busy(&self) -> bool { + self.updates.is_some() + } + pub fn elapsed(&self) -> Option { + 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::(&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(); + } + } +} diff --git a/shell/ferese-shell/src/status_ui.rs b/shell/ferese-shell/src/status_ui.rs index 890d134..400ce79 100644 --- a/shell/ferese-shell/src/status_ui.rs +++ b/shell/ferese-shell/src/status_ui.rs @@ -16,6 +16,7 @@ pub enum Menu { Bluetooth, Audio, Battery, + Recording, Notifications, System, } @@ -41,6 +42,7 @@ impl Menu { Self::Bluetooth => "Bluetooth", Self::Audio => "Sound", Self::Battery => "Battery", + Self::Recording => "Screen recording", Self::Notifications => "Notifications", } } @@ -52,7 +54,7 @@ impl Menu { Self::Audio => status.audio.is_some(), Self::Battery => status.battery.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> { let theme = self.config.theme.for_bar(); 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 [ Menu::System, Menu::Network, Menu::Bluetooth, Menu::Audio, + Menu::Recording, Menu::Notifications, Menu::Battery, ] { if !kind.available(&self.status) { 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 foreground = color(if selected { theme.accent @@ -271,6 +281,15 @@ impl FereseShell { )] .spacing(4) .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 let Some(battery) = &self.status.battery { content = content.push( @@ -304,14 +323,22 @@ impl FereseShell { .align_x(alignment::Horizontal::Center) .height(metrics.control_height) .align_y(alignment::Vertical::Center); + let recording_busy = kind == Menu::Recording && self.recorder.busy(); 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([ 0.0, ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0), ]) .height(metrics.control_height) .on_press_with_rectangle(move |offset, bounds| { + if recording_busy { + return cosmic::Action::App(Message::StopRecording); + } cosmic::Action::App(Message::OpenMenu( kind, Rectangle { @@ -407,6 +434,42 @@ impl FereseShell { ] .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 { let combined = menu.kind == Menu::System; if combined { @@ -1106,6 +1169,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String { || "Battery: unavailable".into(), |b| format!("Battery: {}%, {}", b.percent, b.status), ), + Menu::Recording => "Screen recording".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 => ( include_bytes!("../assets/icons/status/control-center.svg"), true, diff --git a/site/content/index.md b/site/content/index.md index 2e839a0..7d60613 100644 --- a/site/content/index.md +++ b/site/content/index.md @@ -48,9 +48,8 @@ Ferese reloads changes as you make them. An invalid edit keeps the last working ## Share your screen 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 -does not yet have a Record button in the shell. See [Screen sharing and -recording](screen-sharing.md) for setup, stopping a session, and supported sources. +recording or conferencing app. To save a video directly, click the recording +icon in the top bar. See [Screen sharing and recording](screen-sharing.md) for setup, stopping a session, and supported sources. ## Keep a recovery path