Add native ScreenCast portal with PipeWire monitor streams

This commit is contained in:
Samuel Onoja committed 2026-09-28 07:54:22 +01:00
1 parent cb512eece9
commit e7d92280d6
21 files changed
+2228 -15

No files matched your search

Generated
+261 -2
View File
@@ -181,6 +181,22 @@ dependencies = [
"libc",
]
[[package]]
name = "annotate-snippets"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4"
dependencies = [
"anstyle",
"unicode-width 0.2.2",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "appendlist"
version = "1.4.0"
@@ -508,6 +524,25 @@ dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"annotate-snippets",
"bitflags 2.13.2",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash 2.1.3",
"shlex 1.3.0",
"syn 2.0.119",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -739,6 +774,25 @@ dependencies = [
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "cfg-expr"
version = "0.20.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -761,6 +815,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -877,6 +942,12 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie-factory"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1463,6 +1534,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "endi"
version = "1.1.1"
@@ -1700,7 +1777,7 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2"
dependencies = [
"toml",
"toml 0.5.11",
]
[[package]]
@@ -2094,6 +2171,12 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "glow"
version = "0.16.0"
@@ -2222,6 +2305,12 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.3.9"
@@ -2796,6 +2885,15 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -3143,6 +3241,33 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "libspa"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40"
dependencies = [
"bitflags 2.13.2",
"cc",
"cookie-factory",
"libc",
"libspa-sys",
"nom 8.0.0",
"rustix 1.1.5",
"system-deps",
]
[[package]]
name = "libspa-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924"
dependencies = [
"bindgen",
"cc",
"system-deps",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
@@ -3377,6 +3502,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3493,6 +3624,25 @@ dependencies = [
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "notify"
version = "8.2.0"
@@ -3931,7 +4081,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0"
dependencies = [
"heck",
"heck 0.4.1",
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
@@ -4113,6 +4263,31 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pipewire"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c"
dependencies = [
"bitflags 2.13.2",
"libc",
"libspa",
"libspa-sys",
"pipewire-sys",
"rustix 1.1.5",
]
[[package]]
name = "pipewire-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0"
dependencies = [
"bindgen",
"libspa-sys",
"system-deps",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -4471,6 +4646,18 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
@@ -4771,6 +4958,15 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -5178,6 +5374,19 @@ dependencies = [
"libc",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml 1.1.6+spec-1.1.0",
"version-compare",
]
[[package]]
name = "taffy"
version = "0.9.2"
@@ -5190,6 +5399,12 @@ dependencies = [
"slotmap",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5390,6 +5605,21 @@ dependencies = [
"serde",
]
[[package]]
name = "toml"
version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 1.0.4",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -5420,6 +5650,12 @@ dependencies = [
"winnow 1.0.4",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -5704,6 +5940,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -6958,6 +7200,23 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
[[package]]
name = "xdg-desktop-portal-ferese"
version = "0.1.0"
dependencies = [
"ferese-config",
"libc",
"libcosmic",
"memmap2 0.9.11",
"pipewire",
"serde",
"serde_json",
"tokio",
"wayland-client",
"wayland-protocols-wlr",
"zbus",
]
[[package]]
name = "xkbcommon"
version = "0.7.0"
+1
View File
@@ -11,6 +11,7 @@ members = [
"shell/ferese-shell",
"shell/ferese-settings",
"shell/ferese-lock",
"portal",
]
resolver = "3"
+19 -7
View File
@@ -13,7 +13,7 @@ desktop instead of direct access to the graphics device.
Build with **Rust 1.95 or newer**, a C/C++ toolchain, CMake, pkg-config, and the
native development libraries below. The installer builds Ferese, its shell,
Settings, command-line tools, and locker together. It does not install system
Settings, command-line tools, locker, and ScreenCast portal together. It does not install system
packages or enable services for you.
### Fedora
@@ -22,7 +22,8 @@ packages or enable services for you.
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
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
```
### Arch Linux
@@ -30,7 +31,8 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
```sh
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
desktop-file-utils foot pam clang pipewire libpipewire wireplumber \
xdg-desktop-portal xdg-desktop-portal-gtk
```
### Debian and Ubuntu
@@ -40,7 +42,9 @@ sudo apt update
sudo apt install build-essential git curl cmake pkg-config libwayland-dev \
libxkbcommon-dev libinput-dev libudev-dev libseat-dev libgbm-dev libegl-dev \
libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev dbus-bin \
dbus-user-session desktop-file-utils foot libpam0g
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
```
These are package recipes, not a claim that every distribution release has been
@@ -86,7 +90,9 @@ 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`, and `ferese-screenshot`.
`feresectl`, `ferese-lock`, `ferese-screenshot`, and
`xdg-desktop-portal-ferese`.
- 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.
- `/etc/pam.d/ferese-lock`, using your distribution's authentication stack.
@@ -203,7 +209,7 @@ To preview a source build before installing, run these commands from the checkou
inside an existing Wayland desktop:
```sh
cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock
cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese
target/release/ferese --backend nested --grant-effects --grant-shell-control -- \
target/release/ferese-shell
```
@@ -245,7 +251,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 five components from the intended revision.
use it only after building all six components from the intended revision.
Run `./scripts/install.sh --help` for all options.
## Logout and recovery
@@ -299,3 +305,9 @@ sudo mv /usr/share/wayland-sessions/ferese.desktop \
For a command-based greeter, remove Ferese from its session choices using that
greeter's configuration. Reinstalling restores the desktop session entry.
## Screen sharing and recording
The installer includes Ferese’s native ScreenCast portal. See
[Screen sharing](screen-sharing.md) for consent, PipeWire setup, supported sources,
and current recording limitations.
+78
View File
@@ -0,0 +1,78 @@
# Screen sharing and recording
Ferese includes a ScreenCast portal backend, `xdg-desktop-portal-ferese`.
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.
## Start sharing
Use a Wayland screen-sharing or recording source in your application. Ferese
opens a display picker. Select a display and press **Share**. Nothing is selected
by default. Applications that request multiple displays can share up to four.
The picker uses your Ferese theme colors, font, and shell corner radius.
A **screen sharing** window remains visible while sharing. Press **Stop sharing**
or close that window to revoke the session. Closing the requesting application,
locking Ferese, disconnecting the selected display, or changing its capture size
also ends sharing. A new request needs fresh consent; selections are not saved.
Everything visible on a shared display, including notifications, is included.
## Installation
The [standard installer](installation.md) builds and installs the backend, its
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.
Log into a new Ferese session after installation. The session launcher imports
its Wayland display into the D-Bus activation environment. Nested previews leave
the host activation environment alone.
Existing custom window rules can float the picker and sharing indicator:
```kdl
window-rule app-id="dev.ferese.ScreenShare" floating=#true
```
The packaged example configuration already includes this rule. Existing user
configuration is preserved during installation.
## Current capabilities
- Monitor capture, one PipeWire stream per selected display, at up to 30 fps.
- Hidden or embedded pointer, as requested by the application.
- Bounded frame storage and reusable shared-memory buffers.
- 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
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.
## Troubleshooting
Confirm that `XDG_CURRENT_DESKTOP` contains `Ferese`, PipeWire is running, and
`FERESE_ENABLE_SCREENCOPY` was not set to `0` before launching the session.
The installed preference is
`/usr/share/xdg-desktop-portal/ferese-portals.conf`. A user override under
`~/.config/xdg-desktop-portal/` can take precedence. Its ScreenCast preference
should name `ferese`:
```ini
[preferred]
default=gtk;
org.freedesktop.impl.portal.ScreenCast=ferese;
```
If capture dimensions change, stop and start sharing again. Ferese ends the old
session instead of feeding incorrectly sized frames to the application.
For development, `xdg-desktop-portal-ferese --sources` lists displays available
on the current `WAYLAND_DISPLAY`. This command does not start a stream.
+1
View File
@@ -29,6 +29,7 @@ theme {
}
window-rule app-id="dev.ferese.Settings" floating=#true
window-rule app-id="dev.ferese.ScreenShare" floating=#true
window-rule title="Choose a wallpaper" floating=#true
desktop-widgets {
+4 -1
View File
@@ -32,6 +32,9 @@ export PATH="$release_dir:$PATH"
unset WAYLAND_SOCKET
if [[ $backend == drm ]]; then
unset DISPLAY WAYLAND_DISPLAY
export FERESE_SESSION_IMPORT_ENV=1
else
export FERESE_SESSION_IMPORT_ENV=0
fi
if [[ $backend == drm ]] && command -v systemctl >/dev/null; then
@@ -39,7 +42,7 @@ if [[ $backend == drm ]] && command -v systemctl >/dev/null; then
fi
command=("$release_dir/ferese" --backend "$backend" --grant-effects
--grant-shell-control -- "$release_dir/ferese-shell")
--grant-shell-control -- "$release_dir/ferese-session-shell")
if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then
exec dbus-run-session -- "${command[@]}"
else
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
# The compositor supplies its new Wayland socket before this helper starts.
# Nested previews must never replace the host session's activation environment.
if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then
dbus-update-activation-environment --systemd \
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || \
dbus-update-activation-environment \
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || true
fi
release_dir=$(dirname -- "$(readlink -f -- "$0")")
exec "$release_dir/ferese-shell"
+3
View File
@@ -0,0 +1,3 @@
[preferred]
default=gtk;
org.freedesktop.impl.portal.ScreenCast=ferese;
+4
View File
@@ -0,0 +1,4 @@
[portal]
DBusName=org.freedesktop.impl.portal.desktop.ferese
Interfaces=org.freedesktop.impl.portal.ScreenCast;
UseIn=Ferese;
@@ -0,0 +1,3 @@
[D-BUS Service]
Name=org.freedesktop.impl.portal.desktop.ferese
Exec=/usr/local/lib/ferese/current/xdg-desktop-portal-ferese
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "xdg-desktop-portal-ferese"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
[dependencies]
libcosmic.workspace = true
ferese-config = { path = "../crates/ferese-config" }
libc.workspace = true
memmap2 = "0.9"
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util"] }
wayland-client.workspace = true
wayland-protocols-wlr.workspace = true
zbus = { version = "5.19.0", default-features = false, features = ["tokio"] }
+597
View File
@@ -0,0 +1,597 @@
//! ScreenCast portal backend. Only the portal frontend may create sessions;
//! consent never survives a session, a process restart, or a capture failure.
use crate::{
capture::{Capture, Source},
picker::Prompt,
stream::Ready,
};
use std::{
collections::HashMap,
process::Stdio,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::{Child, Command},
sync::{Mutex, Notify},
};
use zbus::{
Connection,
message::Header,
object_server::SignalEmitter,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
type Options = HashMap<String, OwnedValue>;
type Reply = (u32, Options);
#[derive(Debug)]
enum StartError {
Cancelled,
Failed(String),
}
impl From<String> for StartError {
fn from(message: String) -> Self {
Self::Failed(message)
}
}
impl From<&str> for StartError {
fn from(message: &str) -> Self {
Self::Failed(message.into())
}
}
const FRONTEND: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
#[derive(Default)]
struct Cancel {
stopped: AtomicBool,
changed: Notify,
}
impl Cancel {
fn stop(&self) {
self.stopped.store(true, Ordering::SeqCst);
self.changed.notify_one();
}
async fn wait(&self) {
if !self.stopped.load(Ordering::SeqCst) {
self.changed.notified().await;
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Phase {
Created,
Selected,
Starting,
Running,
}
struct Session {
owner: String,
app: String,
phase: Phase,
multiple: bool,
cursor: bool,
cancel: Arc<Cancel>,
}
#[derive(Clone, Default)]
pub struct Backend {
sessions: Arc<Mutex<HashMap<String, Session>>>,
}
async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result<String> {
let sender = header
.sender()
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?;
let dbus = zbus::fdo::DBusProxy::new(connection).await?;
let owner = dbus.get_name_owner(FRONTEND.try_into().unwrap()).await?;
if sender.as_str() != owner.as_str() {
return Err(zbus::fdo::Error::AccessDenied(
"Use the ScreenCast desktop portal".into(),
));
}
Ok(sender.to_string())
}
fn error(message: &str) -> zbus::fdo::Error {
zbus::fdo::Error::InvalidArgs(message.into())
}
fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
let number = |key: &str, default| {
options
.get(key)
.map(u32::try_from)
.transpose()
.map(|v| v.unwrap_or(default))
.map_err(|_| error("Invalid option type"))
};
let types = number("types", 1)?;
let cursor = number("cursor_mode", 1)?;
if types == 0 || types & !1 != 0 {
return Err(error("Only monitor sharing is supported"));
}
if cursor != 1 && cursor != 2 {
return Err(error("Unsupported cursor mode"));
}
let multiple = options
.get("multiple")
.map(bool::try_from)
.transpose()
.map_err(|_| error("Invalid multiple option"))?
.unwrap_or(false);
Ok((multiple, cursor == 2))
}
impl Backend {
async fn end(&self, connection: &Connection, path: &str) {
let session = self.sessions.lock().await.remove(path);
if let Some(session) = session {
session.cancel.stop();
if let Ok(emitter) = SignalEmitter::new(connection, path) {
let _ = SessionObject::closed(&emitter).await;
}
// A Close method may still hold the interface read lock.
let conn = connection.clone();
let path = path.to_owned();
tokio::spawn(async move {
let _ = conn
.object_server()
.remove::<SessionObject, _>(path.as_str())
.await;
});
}
}
}
#[zbus::interface(name = "org.freedesktop.impl.portal.ScreenCast")]
impl Backend {
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
3
}
#[zbus(property)]
fn available_source_types(&self) -> u32 {
1
}
#[zbus(property)]
fn available_cursor_modes(&self) -> u32 {
3
}
async fn create_session(
&self,
_handle: OwnedObjectPath,
session_handle: OwnedObjectPath,
app_id: String,
_options: Options,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<Reply> {
let owner = authorize(connection, &header).await?;
if !session_handle
.as_str()
.starts_with("/org/freedesktop/portal/desktop/session/")
|| app_id.len() > 512
{
return Err(error("Invalid session"));
}
let mut sessions = self.sessions.lock().await;
if sessions.len() >= 8 || sessions.contains_key(session_handle.as_str()) {
return Err(zbus::fdo::Error::LimitsExceeded(
"Session limit reached or duplicate session".into(),
));
}
let inserted = connection
.object_server()
.at(
session_handle.clone(),
SessionObject {
backend: self.clone(),
path: session_handle.to_string(),
owner: owner.clone(),
},
)
.await?;
if !inserted {
return Err(error("Session object already exists"));
}
sessions.insert(
session_handle.to_string(),
Session {
owner,
app: app_id,
phase: Phase::Created,
multiple: false,
cursor: false,
cancel: Arc::default(),
},
);
Ok((0, Options::new()))
}
async fn select_sources(
&self,
_handle: OwnedObjectPath,
session_handle: OwnedObjectPath,
app_id: String,
options: Options,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<Reply> {
let owner = authorize(connection, &header).await?;
let settings = source_options(&options);
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?;
if session.owner != owner
|| session.app != app_id
|| !matches!(session.phase, Phase::Created | Phase::Selected)
{
return Err(error("Invalid session state"));
}
match settings {
Ok((multiple, cursor)) => {
session.multiple = multiple;
session.cursor = cursor;
session.phase = Phase::Selected;
Ok((0, Options::new()))
}
Err(e) => {
drop(sessions);
self.end(connection, session_handle.as_str()).await;
Err(e)
}
}
}
#[allow(clippy::too_many_arguments)] // ScreenCast D-Bus method signature.
async fn start(
&self,
handle: OwnedObjectPath,
session_handle: OwnedObjectPath,
app_id: String,
_parent_window: String,
_options: Options,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<Reply> {
let owner = authorize(connection, &header).await?;
if !handle
.as_str()
.starts_with("/org/freedesktop/portal/desktop/request/")
{
return Err(error("Invalid request path"));
}
let (multiple, cursor, cancel) = {
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?;
if session.owner != owner || session.app != app_id || session.phase != Phase::Selected {
return Err(error("Select sources before starting a session"));
}
session.phase = Phase::Starting;
(session.multiple, session.cursor, session.cancel.clone())
};
let inserted = connection
.object_server()
.at(
handle.clone(),
Request {
cancel: cancel.clone(),
owner,
},
)
.await;
match inserted {
Ok(true) => (),
result => {
self.end(connection, session_handle.as_str()).await;
return Err(match result {
Err(e) => e.into(),
_ => error("Request object already exists"),
});
}
}
let result = tokio::select! {
_ = cancel.wait() => Err(StartError::Cancelled),
result = tokio::time::timeout(Duration::from_secs(120), start_streams(&app_id, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))),
};
let _ = connection
.object_server()
.remove::<Request, _>(handle)
.await;
match result {
Ok((children, indicator, streams)) if !cancel.stopped.load(Ordering::SeqCst) => {
if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) {
session.phase = Phase::Running;
}
let backend = self.clone();
let connection = connection.clone();
let path = session_handle.to_string();
tokio::spawn(async move {
supervise(children, indicator, cancel).await;
backend.end(&connection, &path).await;
});
let streams: Vec<(u32, Options)> = streams
.into_iter()
.map(|ready| {
(
ready.node,
HashMap::from([("source_type".into(), OwnedValue::from(1u32))]),
)
})
.collect();
let mut reply = Options::new();
reply.insert(
"streams".into(),
OwnedValue::try_from(Value::from(streams))
.map_err(|e| error(&e.to_string()))?,
);
Ok((0, reply))
}
result => {
let code = match result {
Err(StartError::Failed(message)) => {
eprintln!("ferese portal: {message}");
2
}
_ => 1,
};
self.end(connection, session_handle.as_str()).await;
Ok((code, Options::new()))
}
}
}
}
struct Request {
cancel: Arc<Cancel>,
owner: String,
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Request")]
impl Request {
fn close(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> {
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
return Err(zbus::fdo::Error::AccessDenied("Wrong request owner".into()));
}
self.cancel.stop();
Ok(())
}
}
struct SessionObject {
backend: Backend,
path: String,
owner: String,
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Session")]
impl SessionObject {
async fn close(
&self,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
return Err(zbus::fdo::Error::AccessDenied("Wrong session owner".into()));
}
self.backend.end(connection, &self.path).await;
Ok(())
}
#[zbus(signal)]
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
}
// All helpers die with the service, including a picker awaiting user input.
fn child_command() -> Result<Command, String> {
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
let parent = std::process::id() as libc::pid_t;
// SAFETY: only async-signal-safe libc calls run between fork and exec.
unsafe {
command.pre_exec(move || {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) != 0 {
return Err(std::io::Error::last_os_error());
}
if libc::getppid() != parent {
libc::_exit(1);
}
Ok(())
});
}
command.kill_on_drop(true);
Ok(command)
}
async fn picker(prompt: &Prompt) -> Result<Child, String> {
let mut child = child_command()?
.arg("--picker")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
.map_err(|e| e.to_string())?;
let mut input = child.stdin.take().unwrap();
input
.write_all(&serde_json::to_vec(prompt).unwrap())
.await
.map_err(|e| e.to_string())?;
drop(input);
Ok(child)
}
async fn start_streams(
app: &str,
multiple: bool,
cursor: bool,
) -> Result<(Vec<Child>, Child, Vec<Ready>), StartError> {
let sources = tokio::task::spawn_blocking(|| {
Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources())
})
.await
.map_err(|e| e.to_string())??;
let prompt = Prompt {
app: app.to_owned(),
sources,
multiple,
indicator: false,
};
let selection = picker(&prompt)
.await?
.wait_with_output()
.await
.map_err(|e| e.to_string())?;
if !selection.status.success() {
return Err(StartError::Cancelled);
}
let names: Vec<String> =
serde_json::from_slice(&selection.stdout).map_err(|_| StartError::Cancelled)?;
let selected = validate_selection(&prompt.sources, &names, multiple)?;
let mut children = Vec::new();
let mut streams = Vec::new();
for source in &selected {
let mut child = child_command()?
.args([
"--stream",
&source.name,
if cursor { "embedded" } else { "hidden" },
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
.map_err(|e| e.to_string())?;
let mut line = String::new();
let output = child.stdout.take().unwrap();
tokio::time::timeout(
Duration::from_secs(10),
BufReader::new(output).read_line(&mut line),
)
.await
.map_err(|_| "PipeWire stream startup timed out")?
.map_err(|e| e.to_string())?;
let ready: Ready =
serde_json::from_str(&line).map_err(|_| "PipeWire stream could not start")?;
if ready.node == u32::MAX {
return Err("Invalid PipeWire node".into());
}
streams.push(ready);
children.push(child);
}
let indicator = picker(&Prompt {
app: app.to_owned(),
sources: selected,
multiple,
indicator: true,
})
.await?;
Ok((children, indicator, streams))
}
fn validate_selection(
sources: &[Source],
names: &[String],
multiple: bool,
) -> Result<Vec<Source>, String> {
if names.is_empty() || names.len() > 4 || (!multiple && names.len() != 1) {
return Err("Invalid display selection".into());
}
let mut selected = Vec::new();
for name in names {
if selected.iter().any(|s: &Source| &s.name == name) {
return Err("Duplicate display selection".into());
}
selected.push(
sources
.iter()
.find(|s| &s.name == name)
.ok_or("Unknown display selection")?
.clone(),
);
}
Ok(selected)
}
async fn supervise(mut children: Vec<Child>, mut indicator: Child, cancel: Arc<Cancel>) {
loop {
if cancel.stopped.load(Ordering::SeqCst)
|| !matches!(indicator.try_wait(), Ok(None))
|| children
.iter_mut()
.any(|c| !matches!(c.try_wait(), Ok(None)))
{
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
cancel.stop();
for child in &mut children {
let _ = child.kill().await;
}
let _ = indicator.kill().await;
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let backend = Backend::default();
let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.impl.portal.desktop.ferese")?
.serve_at(PATH, backend.clone())?
.build()
.await?;
// Frontend death must revoke every stream, even if Session.Close never arrives.
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
if backend.sessions.lock().await.is_empty() {
continue;
}
let owner = match zbus::fdo::DBusProxy::new(&connection).await {
Ok(proxy) => proxy
.get_name_owner(FRONTEND.try_into().unwrap())
.await
.ok()
.map(|name| name.to_string()),
Err(_) => None,
};
let stale: Vec<_> = backend
.sessions
.lock()
.await
.iter()
.filter(|(_, session)| owner.as_deref() != Some(session.owner.as_str()))
.map(|(path, _)| path.clone())
.collect();
for path in stale {
backend.end(&connection, &path).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unimplemented_sources_and_cursor_modes() {
assert_eq!(source_options(&Options::new()).unwrap(), (false, false));
for bits in [0u32, 2, 3, 4] {
assert!(source_options(&HashMap::from([("types".into(), bits.into())])).is_err());
}
assert!(source_options(&HashMap::from([("cursor_mode".into(), 4u32.into())])).is_err());
}
#[test]
fn rejects_empty_unknown_duplicate_and_excess_selections() {
let source = Source {
name: "test".into(),
label: "Test".into(),
width: 640,
height: 480,
x: 0,
y: 0,
scale: 1,
};
assert!(validate_selection(std::slice::from_ref(&source), &[], false).is_err());
assert!(
validate_selection(std::slice::from_ref(&source), &["other".into()], false).is_err()
);
assert!(
validate_selection(
std::slice::from_ref(&source),
&["test".into(), "test".into()],
true
)
.is_err()
);
assert_eq!(
validate_selection(&[source], &["test".into()], false)
.unwrap()
.len(),
1
);
}
}
+425
View File
@@ -0,0 +1,425 @@
//! Bounded, cancellable Wayland capture. No screenshots or external capture tools.
use memmap2::MmapMut;
use serde::{Deserialize, Serialize};
use std::{
fs::File,
os::fd::{AsFd, AsRawFd, FromRawFd},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use wayland_client::{
Connection, Dispatch, EventQueue, QueueHandle, WEnum,
protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool},
};
use wayland_protocols_wlr::screencopy::v1::client::{
zwlr_screencopy_frame_v1 as frame, zwlr_screencopy_manager_v1 as manager,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Source {
pub name: String,
pub label: String,
pub width: i32,
pub height: i32,
pub x: i32,
pub y: i32,
pub scale: i32,
}
struct Output {
global: u32,
proxy: wl_output::WlOutput,
source: Source,
}
pub struct Frame {
pub width: u32,
pub height: u32,
pub stride: u32,
pub pixels: Vec<u8>,
}
struct Buffer {
_file: File,
map: MmapMut,
proxy: wl_buffer::WlBuffer,
width: u32,
height: u32,
stride: u32,
format: wl_shm::Format,
}
impl Drop for Buffer {
fn drop(&mut self) {
self.proxy.destroy();
}
}
#[derive(Default)]
struct State {
outputs: Vec<Output>,
manager: Option<manager::ZwlrScreencopyManagerV1>,
shm: Option<wl_shm::WlShm>,
buffer: Option<Buffer>,
result: Option<Result<(), String>>,
flipped: bool,
}
pub struct Capture {
connection: Connection,
queue: EventQueue<State>,
state: State,
}
impl Capture {
pub fn connect(stop: &AtomicBool) -> Result<Self, String> {
let connection = Connection::connect_to_env().map_err(|e| e.to_string())?;
let queue = connection.new_event_queue();
connection.display().get_registry(&queue.handle(), ());
let mut this = Self {
connection,
queue,
state: State::default(),
};
// Two syncs deliver registry bindings and output metadata without an
// uninterruptible roundtrip on a stalled compositor.
this.sync(stop)?;
this.sync(stop)?;
if this.state.manager.is_none() || this.state.shm.is_none() {
return Err("Ferese screen capture is unavailable in this session".into());
}
Ok(this)
}
fn sync(&mut self, stop: &AtomicBool) -> Result<(), String> {
let done = Arc::new(AtomicBool::new(false));
self.connection
.display()
.sync(&self.queue.handle(), done.clone());
let deadline = Instant::now() + Duration::from_secs(5);
while !done.load(Ordering::Relaxed) {
self.dispatch(stop, deadline)?;
}
Ok(())
}
pub fn sources(&self) -> Vec<Source> {
self.state
.outputs
.iter()
.map(|o| o.source.clone())
.collect()
}
pub fn frame(
&mut self,
name: &str,
cursor: bool,
stop: &AtomicBool,
mut pixels: Vec<u8>,
) -> Result<Frame, String> {
let output = self
.state
.outputs
.iter()
.find(|o| o.source.name == name)
.ok_or("Shared monitor disconnected")?;
self.state.result = None;
self.state.flipped = false;
let frame = self.state.manager.as_ref().unwrap().capture_output(
cursor as i32,
&output.proxy,
&self.queue.handle(),
(),
);
let deadline = Instant::now() + Duration::from_secs(5);
while self.state.result.is_none() {
if let Err(error) = self.dispatch(stop, deadline) {
frame.destroy();
return Err(error);
}
}
frame.destroy();
self.state.result.take().unwrap()?;
let buffer = self
.state
.buffer
.as_ref()
.ok_or("Capture supplied no buffer")?;
pixels.resize(buffer.map.len(), 0);
pixels.copy_from_slice(&buffer.map);
if self.state.flipped {
let stride = buffer.stride as usize;
for y in 0..buffer.height as usize / 2 {
let bottom = (buffer.height as usize - y - 1) * stride;
let (top, tail) = pixels.split_at_mut(bottom);
top[y * stride..(y + 1) * stride].swap_with_slice(&mut tail[..stride]);
}
}
Ok(Frame {
width: buffer.width,
height: buffer.height,
stride: buffer.stride,
pixels,
})
}
fn dispatch(&mut self, stop: &AtomicBool, deadline: Instant) -> Result<(), String> {
if stop.load(Ordering::Relaxed) {
return Err("Capture cancelled".into());
}
if Instant::now() >= deadline {
return Err("Compositor capture timed out".into());
}
self.queue
.dispatch_pending(&mut self.state)
.map_err(|e| e.to_string())?;
self.connection.flush().map_err(|e| e.to_string())?;
if let Some(read) = self.queue.prepare_read() {
let mut fd = libc::pollfd {
fd: read.connection_fd().as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: a single valid pollfd, bounded timeout; Wayland owns the FD.
let ready = unsafe { libc::poll(&mut fd, 1, 20) };
if ready > 0 {
read.read().map_err(|e| e.to_string())?;
} else if ready < 0
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
{
return Err(std::io::Error::last_os_error().to_string());
}
}
self.queue
.dispatch_pending(&mut self.state)
.map_err(|e| e.to_string())?;
Ok(())
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => match interface.as_str() {
"wl_output" => state.outputs.push(Output {
global: name,
proxy: registry.bind(name, version.min(4), qh, name),
source: Source {
name: format!("output-{name}"),
label: "Display".into(),
width: 0,
height: 0,
x: 0,
y: 0,
scale: 1,
},
}),
"wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())),
"zwlr_screencopy_manager_v1" => {
state.manager = Some(registry.bind(name, version.min(3), qh, ()))
}
_ => (),
},
wl_registry::Event::GlobalRemove { name } => state.outputs.retain(|o| o.global != name),
_ => (),
}
}
}
impl Dispatch<wl_output::WlOutput, u32> for State {
fn event(
state: &mut Self,
_: &wl_output::WlOutput,
event: wl_output::Event,
name: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
let Some(output) = state.outputs.iter_mut().find(|o| o.global == *name) else {
return;
};
match event {
wl_output::Event::Name { name } => output.source.name = name,
wl_output::Event::Description { description } => output.source.label = description,
wl_output::Event::Geometry { x, y, .. } => {
output.source.x = x;
output.source.y = y;
}
wl_output::Event::Scale { factor } => output.source.scale = factor.max(1),
wl_output::Event::Mode {
flags: WEnum::Value(flags),
width,
height,
..
} if flags.contains(wl_output::Mode::Current) => {
output.source.width = width;
output.source.height = height;
}
_ => (),
}
}
}
impl Dispatch<frame::ZwlrScreencopyFrameV1, ()> for State {
fn event(
state: &mut Self,
frame: &frame::ZwlrScreencopyFrameV1,
event: frame::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
frame::Event::Buffer {
format: WEnum::Value(format),
width,
height,
stride,
} => {
let result = (|| {
if format != wl_shm::Format::Argb8888 && format != wl_shm::Format::Xrgb8888 {
return Err("Unsupported capture pixel format".into());
}
let bytes = buffer_size(width, height, stride)?;
if let Some(buffer) = &state.buffer
&& (buffer.width, buffer.height, buffer.stride, buffer.format)
== (width, height, stride, format)
{
frame.copy(&buffer.proxy);
return Ok(());
}
// SAFETY: fixed NUL-terminated name and a newly owned memfd.
let fd = unsafe {
libc::memfd_create(c"ferese-screencast".as_ptr(), libc::MFD_CLOEXEC)
};
if fd < 0 {
return Err(std::io::Error::last_os_error().to_string());
}
let file = unsafe { File::from_raw_fd(fd) };
file.set_len(bytes as u64).map_err(|e| e.to_string())?;
// SAFETY: the file has the checked mapping length and lives with the mapping.
let map = unsafe { MmapMut::map_mut(&file) }.map_err(|e| e.to_string())?;
let pool = state
.shm
.as_ref()
.ok_or("Missing shared memory global")?
.create_pool(file.as_fd(), bytes as i32, qh, ());
let proxy = pool.create_buffer(
0,
width as i32,
height as i32,
stride as i32,
format,
qh,
(),
);
pool.destroy();
frame.copy(&proxy);
state.buffer = Some(Buffer {
_file: file,
map,
proxy,
width,
height,
stride,
format,
});
Ok::<(), String>(())
})();
if let Err(e) = result {
state.result = Some(Err(e));
}
}
frame::Event::Ready { .. } => state.result = Some(Ok(())),
frame::Event::Failed => {
state.result = Some(Err(
"Capture ended: session locked, monitor removed, or compositor unavailable"
.into(),
))
}
frame::Event::Flags {
flags: WEnum::Value(flags),
} => state.flipped = flags.contains(frame::Flags::YInvert),
_ => (),
}
}
}
fn buffer_size(width: u32, height: u32, stride: u32) -> Result<usize, String> {
let row = width.checked_mul(4).ok_or("Capture dimensions overflow")?;
let bytes = stride
.checked_mul(height)
.ok_or("Capture dimensions overflow")?;
if width == 0 || height == 0 || stride < row || bytes > 128 * 1024 * 1024 {
return Err("Unsupported capture dimensions".into());
}
Ok(bytes as usize)
}
impl Dispatch<wayland_client::protocol::wl_callback::WlCallback, Arc<AtomicBool>> for State {
fn event(
_: &mut Self,
_: &wayland_client::protocol::wl_callback::WlCallback,
_: wayland_client::protocol::wl_callback::Event,
done: &Arc<AtomicBool>,
_: &Connection,
_: &QueueHandle<Self>,
) {
done.store(true, Ordering::Relaxed);
}
}
wayland_client::delegate_noop!(State: ignore wl_shm::WlShm);
wayland_client::delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
wayland_client::delegate_noop!(State: ignore wl_buffer::WlBuffer);
wayland_client::delegate_noop!(State: ignore manager::ZwlrScreencopyManagerV1);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unbounded_and_invalid_buffers() {
assert_eq!(buffer_size(640, 480, 2560).unwrap(), 1228800);
assert!(buffer_size(640, 480, 12).is_err());
assert!(buffer_size(0, 480, 0).is_err());
assert!(buffer_size(u32::MAX, 2, u32::MAX).is_err());
assert!(buffer_size(20000, 20000, 80000).is_err());
}
}
+22
View File
@@ -0,0 +1,22 @@
mod backend;
mod capture;
mod picker;
mod stream;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[flag, name, cursor] if flag == "--stream" => {
stream::run(name.clone(), cursor == "embedded")
}
[flag] if flag == "--sources" => {
let capture = capture::Capture::connect(&std::sync::atomic::AtomicBool::new(false))?;
println!("{}", serde_json::to_string(&capture.sources())?);
Ok(())
}
[flag] if flag == "--picker" => picker::run(),
[] => tokio::runtime::Runtime::new()?.block_on(backend::run()),
_ => Err("Usage: xdg-desktop-portal-ferese [--sources]".into()),
}
}
+264
View File
@@ -0,0 +1,264 @@
use crate::capture::Source;
use cosmic::{
ApplicationExt, Element,
app::{Core, Settings, Task},
iced::Length,
widget::{button, column, container, row, text},
};
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
#[derive(Clone, Serialize, Deserialize)]
pub struct Prompt {
pub app: String,
pub sources: Vec<Source>,
pub multiple: bool,
pub indicator: bool,
}
#[derive(Clone, Debug)]
enum Message {
Select(usize),
Share,
Cancel,
}
struct Picker {
core: Core,
prompt: Prompt,
selected: Vec<usize>,
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
std::io::stdin()
.take(64 * 1024)
.read_to_string(&mut input)?;
let prompt: Prompt = serde_json::from_str(&input)?;
if prompt.sources.is_empty() || prompt.sources.len() > 16 {
return Err("No shareable displays".into());
}
let (theme, font) = appearance();
cosmic::app::run::<Picker>(
Settings::default()
.size(cosmic::iced::Size::new(480., 440.))
.theme(theme)
.default_font(font)
.default_text_size(14.)
.is_daemon(false),
prompt,
)?;
Ok(())
}
impl cosmic::Application for Picker {
type Executor = cosmic::executor::Default;
type Flags = Prompt;
type Message = Message;
const APP_ID: &'static str = "dev.ferese.ScreenShare";
fn core(&self) -> &Core {
&self.core
}
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
fn init(core: Core, prompt: Prompt) -> (Self, Task<Message>) {
// No preselected source: clicking Share must be a deliberate choice.
let mut app = Self {
core,
prompt,
selected: Vec::new(),
};
let title = if app.prompt.indicator {
"Ferese — screen sharing"
} else {
"Ferese — share your screen"
};
let task = app
.core
.main_window_id()
.map(|id| app.set_window_title(title.into(), id))
.unwrap_or_else(Task::none);
(app, task)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Select(index) if index < self.prompt.sources.len() => {
if self.selected.contains(&index) {
self.selected.retain(|i| *i != index);
} else {
if !self.prompt.multiple {
self.selected.clear();
}
if self.selected.len() < 4 {
self.selected.push(index);
}
}
}
Message::Share if !self.selected.is_empty() => {
let names: Vec<_> = self
.selected
.iter()
.map(|i| &self.prompt.sources[*i].name)
.collect();
println!("{}", serde_json::to_string(&names).unwrap());
let _ = std::io::stdout().flush();
return cosmic::iced::exit();
}
Message::Cancel => return cosmic::iced::exit(),
_ => (),
}
Task::none()
}
fn view(&self) -> Element<'_, Message> {
let app = if self.prompt.app.is_empty() {
"An application"
} else {
&self.prompt.app
};
let mut content = column([])
.spacing(16)
.push(
text(if self.prompt.indicator {
"Your screen is being shared"
} else {
"Share your screen"
})
.size(24),
)
.push(text(if self.prompt.indicator {
format!("{app} can see the displays below.")
} else {
format!("{app} wants to see your screen. Choose what to share.")
}));
for (index, source) in self.prompt.sources.iter().enumerate() {
let label = format!(
"{}{} — {} × {}",
if self.selected.contains(&index) {
"✓ "
} else {
""
},
source.name,
source.width,
source.height
);
if self.prompt.indicator {
content = content.push(text(label));
} else {
content = content.push(
button::text(label)
.width(Length::Fill)
.on_press(Message::Select(index)),
);
}
}
content = content.push(text(
"Everything visible on a shared display, including notifications, may be seen.",
));
if self.prompt.indicator {
content = content.push(button::destructive("Stop sharing").on_press(Message::Cancel));
} else {
let mut share = button::suggested("Share");
if !self.selected.is_empty() {
share = share.on_press(Message::Share);
}
content = content.push(
row([])
.spacing(12)
.push(button::text("Cancel").on_press(Message::Cancel))
.push(share),
);
}
container(content).padding(24).width(Length::Fill).into()
}
}
fn appearance() -> (cosmic::Theme, cosmic::font::Font) {
let document = ferese_config::config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| ferese_config::Document::parse(&s).ok());
let string = |key: &str, fallback: &str| {
document
.as_ref()
.and_then(|d| d.get(key))
.and_then(|v| v.as_str())
.unwrap_or(fallback)
.to_owned()
};
let rgba = |key, fallback| {
let s = string(key, fallback);
let parse = |s: &str| {
let hex = s.strip_prefix('#')?;
let n = u32::from_str_radix(hex, 16).ok()?;
Some(match hex.len() {
6 => (n << 8) | 255,
8 => n,
_ => return None,
})
};
let n = parse(&s).unwrap_or_else(|| parse(fallback).unwrap());
cosmic::cosmic_theme::palette::Srgba::new(
((n >> 24) & 255) as f32 / 255.,
((n >> 16) & 255) as f32 / 255.,
((n >> 8) & 255) as f32 / 255.,
(n & 255) as f32 / 255.,
)
};
let background = rgba("theme.colors.surface_base", "#111821");
let foreground = rgba("theme.colors.text_primary", "#F4F7FB");
let accent = rgba("theme.colors.accent", "#3D7BE6");
let radius = document
.as_ref()
.and_then(|d| d.get("theme.geometry.shell_radius"))
.and_then(|v| v.as_f64())
.unwrap_or(14.)
.clamp(0., 40.) as f32;
let corners = cosmic::cosmic_theme::CornerRadii {
radius_xs: [radius.min(4.); 4],
radius_s: [radius.min(8.); 4],
radius_m: [radius; 4],
radius_l: [radius; 4],
radius_xl: [radius; 4],
..Default::default()
};
let builder = if background.red + background.green + background.blue > 1.8 {
cosmic::cosmic_theme::ThemeBuilder::light()
} else {
cosmic::cosmic_theme::ThemeBuilder::dark()
};
let theme = cosmic::Theme::custom(std::sync::Arc::new(
builder
.bg_color(background)
.primary_container_bg(background)
.text_tint(foreground.color)
.accent(accent.color)
.corner_radii(corners)
.build(),
));
let family: &'static str =
Box::leak(string("theme.typography.font_family", "Inter").into_boxed_str());
(
theme,
cosmic::font::Font {
family: cosmic::iced::font::Family::Name(family),
..cosmic::font::default()
},
)
}
+289
View File
@@ -0,0 +1,289 @@
//! PipeWire runs outside the compositor. Keep one frame, drop old frames under
//! backpressure, and destroy the node when the portal's control pipe closes.
use crate::capture::{Capture, Frame};
use pipewire::{self as pw, properties::properties, spa};
use serde::{Deserialize, Serialize};
use spa::pod::Pod;
use std::{
io::{Read, Write},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
#[derive(Debug, Serialize, Deserialize)]
pub struct Ready {
pub node: u32,
pub width: u32,
pub height: u32,
}
struct Data {
latest: Arc<Mutex<Frame>>,
stop: Arc<AtomicBool>,
streaming: Arc<AtomicBool>,
fresh: Arc<AtomicBool>,
announced: bool,
}
fn int_property(key: u32, value: i32) -> spa::pod::Property {
spa::pod::Property {
key,
flags: spa::pod::PropertyFlags::empty(),
value: spa::pod::Value::Int(value),
}
}
fn pod(object: spa::pod::Object) -> Vec<u8> {
spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(object),
)
.unwrap()
.0
.into_inner()
}
pub fn run(name: String, cursor: bool) -> Result<(), Box<dyn std::error::Error>> {
let stop = Arc::new(AtomicBool::new(false));
let pipe_stop = stop.clone();
std::thread::spawn(move || {
let mut byte = [0u8];
let _ = std::io::stdin().read(&mut byte);
pipe_stop.store(true, Ordering::Relaxed);
});
let mut capture = Capture::connect(&stop)?;
let first = capture.frame(&name, cursor, &stop, Vec::new())?;
let (width, height) = (first.width, first.height);
let latest = Arc::new(Mutex::new(first));
let streaming = Arc::new(AtomicBool::new(false));
let fresh = Arc::new(AtomicBool::new(false));
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
let context = pw::context::ContextRc::new(&mainloop, None)?;
let core = context.connect_rc(None)?;
let stream = pw::stream::StreamBox::new(
&core,
"Ferese ScreenCast",
properties! {
"media.class" => "Video/Source",
"media.type" => "Video",
"media.category" => "Capture",
"media.role" => "Screen",
"node.name" => format!("ferese.screencast.{}", std::process::id()),
"node.description" => format!("Ferese — {name}"),
"node.virtual" => "true",
"node.want-driver" => "true",
"node.always-process" => "false",
"node.pause-on-idle" => "true",
"node.latency" => "1/30",
},
)?;
let data = Data {
latest: latest.clone(),
stop: stop.clone(),
streaming: streaming.clone(),
fresh: fresh.clone(),
announced: false,
};
let _listener = stream
.add_local_listener_with_user_data(data)
.state_changed(move |stream, data, _, state| {
data.fresh.store(false, Ordering::SeqCst);
data.streaming.store(
matches!(state, pw::stream::StreamState::Streaming),
Ordering::Relaxed,
);
match state {
pw::stream::StreamState::Paused | pw::stream::StreamState::Streaming
if !data.announced =>
{
let node = stream.node_id();
if node != u32::MAX {
data.announced = true;
println!(
"{}",
serde_json::to_string(&Ready {
node,
width,
height
})
.unwrap()
);
let _ = std::io::stdout().flush();
}
}
pw::stream::StreamState::Unconnected if data.announced => {
data.stop.store(true, Ordering::Relaxed);
}
pw::stream::StreamState::Error(error) => {
eprintln!("PipeWire stream: {error}");
data.stop.store(true, Ordering::Relaxed);
}
_ => (),
}
})
.param_changed(move |stream, data, id, param| {
if id != spa::param::ParamType::Format.as_raw() {
return;
}
let Some(param) = param else { return };
let mut format = spa::param::video::VideoInfoRaw::default();
if format.parse(param).is_err()
|| format.size().width != width
|| format.size().height != height
|| format.format() != spa::param::video::VideoFormat::BGRx
{
data.stop.store(true, Ordering::Relaxed);
return;
}
let buffers = pod(spa::pod::object!(
spa::utils::SpaTypes::ObjectParamBuffers,
spa::param::ParamType::Buffers,
int_property(spa::sys::SPA_PARAM_BUFFERS_buffers, 4),
int_property(spa::sys::SPA_PARAM_BUFFERS_blocks, 1),
int_property(
spa::sys::SPA_PARAM_BUFFERS_size,
(width * height * 4) as i32
),
int_property(spa::sys::SPA_PARAM_BUFFERS_stride, (width * 4) as i32),
int_property(spa::sys::SPA_PARAM_BUFFERS_align, 16)
));
if stream
.update_params(&mut [Pod::from_bytes(&buffers).unwrap()])
.is_err()
{
data.stop.store(true, Ordering::Relaxed);
}
})
.process(|stream, data| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let Some(target) = buffer.datas_mut().first_mut() else {
return;
};
if data.stop.load(Ordering::Relaxed) || !data.fresh.load(Ordering::SeqCst) {
*target.chunk_mut().size_mut() = 0;
return;
}
let frame = data.latest.lock().unwrap();
let row = frame.width as usize * 4;
let bytes = row * frame.height as usize;
let Some(destination) = target.data() else {
return;
};
if destination.len() < bytes {
data.stop.store(true, Ordering::Relaxed);
return;
}
for (dst, src) in destination[..bytes]
.chunks_exact_mut(row)
.zip(frame.pixels.chunks_exact(frame.stride as usize))
{
dst.copy_from_slice(&src[..row]);
}
*target.chunk_mut().offset_mut() = 0;
*target.chunk_mut().size_mut() = bytes as u32;
*target.chunk_mut().stride_mut() = row as i32;
})
.register()?;
let format = pod(spa::pod::object!(
spa::utils::SpaTypes::ObjectParamFormat,
spa::param::ParamType::EnumFormat,
spa::pod::property!(
spa::param::format::FormatProperties::MediaType,
Id,
spa::param::format::MediaType::Video
),
spa::pod::property!(
spa::param::format::FormatProperties::MediaSubtype,
Id,
spa::param::format::MediaSubtype::Raw
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoFormat,
Id,
spa::param::video::VideoFormat::BGRx
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoSize,
Rectangle,
spa::utils::Rectangle { width, height }
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoFramerate,
Fraction,
spa::utils::Fraction { num: 30, denom: 1 }
)
));
stream.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::MAP_BUFFERS | pw::stream::StreamFlags::DRIVER,
&mut [Pod::from_bytes(&format).unwrap()],
)?;
let interval = Duration::from_nanos(1_000_000_000 / 30);
let capture_stop = stop.clone();
let capture_streaming = streaming.clone();
let capture_thread = std::thread::spawn(move || {
let mut spare = Vec::new();
let mut checked = Instant::now() - Duration::from_secs(1);
while !capture_stop.load(Ordering::Relaxed) {
let start = Instant::now();
if capture_streaming.load(Ordering::Relaxed)
|| checked.elapsed() >= Duration::from_secs(1)
{
checked = Instant::now();
match capture.frame(&name, cursor, &capture_stop, std::mem::take(&mut spare)) {
Ok(frame) if frame.width == width && frame.height == height => {
spare = std::mem::replace(&mut *latest.lock().unwrap(), frame).pixels;
fresh.store(capture_streaming.load(Ordering::Relaxed), Ordering::SeqCst);
}
Ok(_) => {
eprintln!("Shared monitor changed size; start sharing again");
capture_stop.store(true, Ordering::Relaxed);
}
Err(error) => {
eprintln!("{error}");
capture_stop.store(true, Ordering::Relaxed);
}
}
}
std::thread::sleep(interval.saturating_sub(start.elapsed()));
}
});
let mut next_frame = Instant::now();
while !stop.load(Ordering::Relaxed) {
let active = streaming.load(Ordering::Relaxed);
let timeout = if active {
next_frame
.saturating_duration_since(Instant::now())
.min(Duration::from_millis(10))
} else {
Duration::from_millis(10)
};
mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(timeout));
if streaming.load(Ordering::Relaxed) && Instant::now() >= next_frame {
let _ = stream.trigger_process();
next_frame = Instant::now() + interval;
} else if !streaming.load(Ordering::Relaxed) {
next_frame = Instant::now();
}
}
let _ = stream.disconnect();
let _ = capture_thread.join();
Ok(())
}
+1
View File
@@ -18,6 +18,7 @@ PAGES = [
('desktop-widgets', 'Desktop widgets', ROOT / 'docs/desktop-widgets.md'),
('notifications', 'Notifications', ROOT / 'docs/notifications.md'),
('locking', 'Native locker', ROOT / 'docs/locking.md'),
('screen-sharing', 'Screen sharing', ROOT / 'docs/screen-sharing.md'),
]
md = MarkdownIt('commonmark', {'html': False}).enable('table')
OUTPUT.mkdir(parents=True, exist_ok=True)
+23 -4
View File
@@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
# Refuse to overwrite unrelated installations.
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot; do
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese; 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; do
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese; do
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
done
for name in ferese.png ferese.svg; do
@@ -57,6 +57,21 @@ elif [[ ! -f /etc/pam.d/login && ! -f /etc/pam.d/ferese-lock ]]; then
exit 1
fi
# These files are owned by Ferese; preserve unrelated administrator configuration.
for entry in \
'ferese.portal:portals/ferese.portal' \
'ferese-portals.conf:ferese-portals.conf'; do
source=${entry%%:*}
destination=/usr/share/xdg-desktop-portal/${entry#*:}
if [[ -e $destination ]] && ! cmp -s "$repo_dir/packaging/portal/$source" "$destination"; then
echo "Unmanaged portal configuration exists: $destination" >&2; exit 1
fi
done
service=/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service
if [[ -e $service ]] && ! cmp -s "$repo_dir/packaging/portal/$(basename "$service")" "$service"; then
echo "Unmanaged portal service exists: $service" >&2; exit 1
fi
desktop-file-validate "$repo_dir/packaging/ferese.desktop"
desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop"
@@ -64,10 +79,11 @@ 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; do
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese; 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"
install -m 0755 -- "$repo_dir/packaging/ferese-session-shell" "$release_dir/ferese-session-shell"
install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot"
install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl"
install -D -m 0644 -- "$repo_dir/assets/fonts/Comfortaa-LICENSE.txt" "$release_dir/licenses/Comfortaa-LICENSE.txt"
@@ -85,12 +101,15 @@ 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; do
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese; do
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
done
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
install -D -m 0644 -- "$repo_dir/packaging/dev.ferese.Settings.desktop" /usr/local/share/applications/dev.ferese.Settings.desktop
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/ferese.svg
install -D -m 0644 -- "$repo_dir/packaging/portal/ferese.portal" /usr/share/xdg-desktop-portal/portals/ferese.portal
install -D -m 0644 -- "$repo_dir/packaging/portal/ferese-portals.conf" /usr/share/xdg-desktop-portal/ferese-portals.conf
install -D -m 0644 -- "$repo_dir/packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service" "$service"
if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/local/share/applications || true; fi
echo "Installed Ferese $release_id. Select Ferese at your login screen, or run /usr/local/bin/ferese-session from a local TTY."
+1 -1
View File
@@ -49,7 +49,7 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop"
build=(cargo build --manifest-path "$repo_dir/Cargo.toml"
--target-dir "$repo_dir/target" --release --locked
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock)
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese)
if $offline; then build+=(--offline); fi
if ! $skip_build; then
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)'
+199
View File
@@ -0,0 +1,199 @@
"""Opt-in portal integration test; only captures a temporary nested compositor.
FERESE_TEST_PORTAL=1 python3 scripts/tests/test_portal_isolated.py
Add FERESE_TEST_PORTAL_CONSENT=1 to exercise the actual picker interactively.
Requires built binaries, Python GI/GStreamer (pipewiresrc), dbus-daemon,
xdg-desktop-portal and bwrap. Host PAM and D-Bus activation are never modified.
"""
import json
import os
from pathlib import Path
import selectors
import signal
import subprocess
import tempfile
import time
import unittest
@unittest.skipUnless(os.environ.get("FERESE_TEST_PORTAL") == "1", "nested portal test is opt-in")
class PortalTest(unittest.TestCase):
def test_portal_capture_cancel_and_lock(self):
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gio, GLib, Gst
Gst.init(None)
repo = Path(__file__).resolve().parents[2]
binary = repo / os.environ.get("FERESE_TEST_PORTAL_BINARY", "target/debug/xdg-desktop-portal-ferese")
compositor_binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/release/ferese")
processes, logs = [], []
with tempfile.TemporaryDirectory(prefix="ferese-portal-test-") as tmp:
root = Path(tmp)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
config.mkdir(parents=True)
(config / "config.kdl").write_text('window-rule app-id="dev.ferese.ScreenShare" floating=#true\n')
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"),
WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1",
PIPEWIRE_RUNTIME_DIR=os.environ["XDG_RUNTIME_DIR"], XDG_CURRENT_DESKTOP="Ferese",
XDG_DESKTOP_PORTAL_DIR=str(repo / "packaging/portal"))
def launch(name, command, **kwargs):
log = (root / (name + ".log")).open("w")
logs.append(log)
child = subprocess.Popen(command, env=env, stderr=log,
stdout=kwargs.pop("stdout", log), start_new_session=True, **kwargs)
processes.append(child)
return child
def until(predicate, seconds=10):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if predicate():
return
while GLib.MainContext.default().iteration(False):
pass
time.sleep(.02)
self.fail("Timed out waiting for test condition")
def read_node(worker):
with selectors.DefaultSelector() as selector:
selector.register(worker.stdout, selectors.EVENT_READ)
self.assertTrue(selector.select(10), "stream did not publish a node")
return json.loads(worker.stdout.readline())["node"]
def consume(node, fd=None):
source = f'pipewiresrc path={node} num-buffers=15'
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 = []
def sample(sink):
buffer = sink.emit("pull-sample").get_buffer()
sizes.append(buffer.get_size())
return Gst.FlowReturn.OK
pipeline.get_by_name("sink").connect("new-sample", sample)
try:
pipeline.set_state(Gst.State.PLAYING)
message = pipeline.get_bus().timed_pop_filtered(15 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR)
self.assertIsNotNone(message, "PipeWire consumer timed out")
self.assertEqual(message.type, Gst.MessageType.EOS)
self.assertEqual(len(sizes), 15)
self.assertGreater(min(sizes), 0)
finally:
pipeline.set_state(Gst.State.NULL)
try:
launch("compositor", [str(compositor_binary), "--backend", "nested"])
sockets = lambda: [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")]
until(sockets)
env["WAYLAND_DISPLAY"] = str(sockets()[0])
# Private bus, including the real portal frontend and permission store.
bus = launch("bus", ["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().decode().strip()
connection = Gio.DBusConnection.new_for_address_sync(env["DBUS_SESSION_BUS_ADDRESS"],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
backend = launch("backend", [str(binary)])
frontend = launch("frontend", [os.environ.get("FERESE_TEST_PORTAL_FRONTEND", "/usr/libexec/xdg-desktop-portal")])
name, path, iface = "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.ScreenCast"
responses = {}
connection.signal_subscribe(name, "org.freedesktop.portal.Request", "Response", None, None,
Gio.DBusSignalFlags.NONE, lambda c, s, p, i, n, args, _: responses.update({p: args.unpack()}), None)
def call(method, args):
return connection.call_sync(name, path, iface, method, args, GLib.VariantType("(o)"),
Gio.DBusCallFlags.NONE, 10000, None).unpack()[0]
def request(method, args, timeout=10):
handle = call(method, args)
until(lambda: handle in responses, timeout)
code, result = responses.pop(handle)
self.assertEqual(code, 0)
return result
def has_frontend():
return connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
"NameHasOwner", GLib.Variant("(s)", (name,)), GLib.VariantType("(b)"), Gio.DBusCallFlags.NONE, 1000, None).unpack()[0]
until(has_frontend)
# Direct implementation calls must not bypass the public frontend.
with self.assertRaises(GLib.Error):
connection.call_sync("org.freedesktop.impl.portal.desktop.ferese", path, "org.freedesktop.impl.portal.ScreenCast",
"CreateSession", GLib.Variant("(oosa{sv})", (path + "/request/test", path + "/session/test", "", {})),
None, Gio.DBusCallFlags.NONE, 5000, None)
def create(token):
session = request("CreateSession", GLib.Variant("(a{sv})", ({"session_handle_token": GLib.Variant("s", token)},)))["session_handle"]
request("SelectSources", GLib.Variant("(oa{sv})", (session, {"types": GLib.Variant("u", 1)})))
return session
def helpers():
return [pid for children in Path(f"/proc/{backend.pid}/task").glob("*/children")
for pid in children.read_text().split()]
session = create("canceltest")
handle = call("Start", GLib.Variant("(osa{sv})", (session, "", {})))
until(helpers)
connection.call_sync(name, handle, "org.freedesktop.portal.Request", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
until(lambda: not helpers())
print("PASS: public session negotiation, caller authorization, request cancellation", flush=True)
if os.environ.get("FERESE_TEST_PORTAL_CONSENT") == "1":
session = create("consenttest")
print("Select the temporary display and click Share in the nested preview.", flush=True)
result = request("Start", GLib.Variant("(osa{sv})", (session, "", {})), 120)
reply, fds = connection.call_with_unix_fd_list_sync(name, path, iface, "OpenPipeWireRemote",
GLib.Variant("(oa{sv})", (session, {})), GLib.VariantType("(h)"), Gio.DBusCallFlags.NONE, 5000, None, None)
fd = fds.get(reply.unpack()[0])
try:
consume(result["streams"][0][0], fd)
finally:
os.close(fd)
connection.call_sync(name, session, "org.freedesktop.portal.Session", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
print("PASS: native consent and video through restricted portal FD", flush=True)
session = create("frontenddeath")
call("Start", GLib.Variant("(osa{sv})", (session, "", {})))
until(helpers)
frontend.terminate()
frontend.wait(timeout=5)
until(lambda: not helpers())
print("PASS: frontend death revokes pending consent", flush=True)
sources = json.loads(subprocess.check_output([str(binary), "--sources"], env=env, timeout=10))
worker = launch("capture", [str(binary), "--stream", sources[0]["name"], "hidden"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
node = read_node(worker)
consume(node)
worker.stdin.close()
self.assertEqual(worker.wait(timeout=7), 0)
print("PASS: frame delivery and control-pipe shutdown", flush=True)
# Test revocation with NO consumer: paused captures must also end on lock.
worker = launch("paused", [str(binary), "--stream", sources[0]["name"], "hidden"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
read_node(worker)
pam = root / "pam"
pam.mkdir()
(pam / "ferese-lock").write_text("auth required pam_deny.so\naccount required pam_deny.so\n")
env["FERESE_LOCK_READY"] = "1"
locker = launch("locker", ["bwrap", "--bind", "/", "/", "--dev-bind", "/dev", "/dev", "--ro-bind", str(pam), "/etc/pam.d", "--unshare-user", "--", str(repo / "target/release/ferese-lock")])
self.assertEqual(locker.wait(timeout=20), 0)
self.assertEqual(worker.wait(timeout=7), 0)
print("PASS: paused capture revoked by session lock", flush=True)
except BaseException:
for log in logs:
log.flush()
print(Path(log.name).name, Path(log.name).read_text()[-3000:])
raise
finally:
for child in reversed(processes):
try:
os.killpg(child.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
for stream in (child.stdin, child.stdout):
if stream is not None:
stream.close()
for log in logs:
log.close()
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -59,3 +59,5 @@ cargo fmt --all --check
```
The [source repository](https://github.com/ferese-wm/ferese) is the place to explore implementation details and report issues. When reporting a problem, include how to reproduce it, whether you used the nested or DRM backend, and relevant logs.
[Screen sharing](screen-sharing.md) covers display consent, PipeWire streams, and recording support.