Add native screen recording with themed top-bar controls
This commit is contained in:
1 parent
0c7ff410a3
commit
6209d556ee
16 files changed
+1060
-29
No files matched your search
Generated
+171
-1
@@ -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",
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
+26
-7
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -26,6 +26,71 @@ struct Data {
|
||||
streaming: Arc<AtomicBool>,
|
||||
fresh: Arc<AtomicBool>,
|
||||
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 {
|
||||
@@ -88,6 +153,7 @@ pub fn run(name: String, cursor: bool) -> Result<(), Box<dyn std::error::Error>>
|
||||
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<dyn std::error::Error>>
|
||||
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::<spa::sys::spa_meta_header>() 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<dyn std::error::Error>>
|
||||
*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()?;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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<notification_ui::NotificationSurface>,
|
||||
status_service: status::Service,
|
||||
status: status::Snapshot,
|
||||
recorder: recording::Recorder,
|
||||
status_error: Option<String>,
|
||||
menu: Option<status_ui::OpenMenu>,
|
||||
note_editor: Option<DesktopNoteEditor>,
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Message>> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user