From e7d92280d6f9d3341a11ea29d10e255183d508e3 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 28 Sep 2026 07:54:22 +0100 Subject: [PATCH] Add native ScreenCast portal with PipeWire monitor streams --- Cargo.lock | 263 +++++++- Cargo.toml | 1 + docs/installation.md | 26 +- docs/screen-sharing.md | 78 +++ packaging/config.kdl | 1 + packaging/ferese-session | 5 +- packaging/ferese-session-shell | 12 + packaging/portal/ferese-portals.conf | 3 + packaging/portal/ferese.portal | 4 + ...desktop.impl.portal.desktop.ferese.service | 3 + portal/Cargo.toml | 19 + portal/src/backend.rs | 597 ++++++++++++++++++ portal/src/capture.rs | 425 +++++++++++++ portal/src/main.rs | 22 + portal/src/picker.rs | 264 ++++++++ portal/src/stream.rs | 289 +++++++++ scripts/build-site.py | 1 + scripts/install-session.sh | 27 +- scripts/install.sh | 2 +- scripts/tests/test_portal_isolated.py | 199 ++++++ site/content/index.md | 2 + 21 files changed, 2228 insertions(+), 15 deletions(-) create mode 100644 docs/screen-sharing.md create mode 100755 packaging/ferese-session-shell create mode 100644 packaging/portal/ferese-portals.conf create mode 100644 packaging/portal/ferese.portal create mode 100644 packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service create mode 100644 portal/Cargo.toml create mode 100644 portal/src/backend.rs create mode 100644 portal/src/capture.rs create mode 100644 portal/src/main.rs create mode 100644 portal/src/picker.rs create mode 100644 portal/src/stream.rs create mode 100644 scripts/tests/test_portal_isolated.py diff --git a/Cargo.lock b/Cargo.lock index f748f0e..f0d844d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 05a5f2d..93f0e3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "shell/ferese-shell", "shell/ferese-settings", "shell/ferese-lock", + "portal", ] resolver = "3" diff --git a/docs/installation.md b/docs/installation.md index 98c5b7c..38bb037 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md new file mode 100644 index 0000000..be77645 --- /dev/null +++ b/docs/screen-sharing.md @@ -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. diff --git a/packaging/config.kdl b/packaging/config.kdl index e3530cb..3eeda1e 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -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 { diff --git a/packaging/ferese-session b/packaging/ferese-session index a598362..ee89a26 100644 --- a/packaging/ferese-session +++ b/packaging/ferese-session @@ -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 diff --git a/packaging/ferese-session-shell b/packaging/ferese-session-shell new file mode 100755 index 0000000..b7c5df6 --- /dev/null +++ b/packaging/ferese-session-shell @@ -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" diff --git a/packaging/portal/ferese-portals.conf b/packaging/portal/ferese-portals.conf new file mode 100644 index 0000000..b491201 --- /dev/null +++ b/packaging/portal/ferese-portals.conf @@ -0,0 +1,3 @@ +[preferred] +default=gtk; +org.freedesktop.impl.portal.ScreenCast=ferese; diff --git a/packaging/portal/ferese.portal b/packaging/portal/ferese.portal new file mode 100644 index 0000000..2b7414a --- /dev/null +++ b/packaging/portal/ferese.portal @@ -0,0 +1,4 @@ +[portal] +DBusName=org.freedesktop.impl.portal.desktop.ferese +Interfaces=org.freedesktop.impl.portal.ScreenCast; +UseIn=Ferese; diff --git a/packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service b/packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service new file mode 100644 index 0000000..b4bf12c --- /dev/null +++ b/packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=org.freedesktop.impl.portal.desktop.ferese +Exec=/usr/local/lib/ferese/current/xdg-desktop-portal-ferese diff --git a/portal/Cargo.toml b/portal/Cargo.toml new file mode 100644 index 0000000..b1c941a --- /dev/null +++ b/portal/Cargo.toml @@ -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"] } diff --git a/portal/src/backend.rs b/portal/src/backend.rs new file mode 100644 index 0000000..a2db2a5 --- /dev/null +++ b/portal/src/backend.rs @@ -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; +type Reply = (u32, Options); +#[derive(Debug)] +enum StartError { + Cancelled, + Failed(String), +} +impl From 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, +} +#[derive(Clone, Default)] +pub struct Backend { + sessions: Arc>>, +} +async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result { + 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::(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 { + 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 { + 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 { + 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::(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, + 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 { + 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 { + 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, Vec), 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 = + 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, 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, mut indicator: Child, cancel: Arc) { + 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> { + 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 + ); + } +} diff --git a/portal/src/capture.rs b/portal/src/capture.rs new file mode 100644 index 0000000..d7d1e4e --- /dev/null +++ b/portal/src/capture.rs @@ -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, +} + +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, + manager: Option, + shm: Option, + buffer: Option, + result: Option>, + flipped: bool, +} + +pub struct Capture { + connection: Connection, + queue: EventQueue, + state: State, +} + +impl Capture { + pub fn connect(stop: &AtomicBool) -> Result { + 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 { + self.state + .outputs + .iter() + .map(|o| o.source.clone()) + .collect() + } + + pub fn frame( + &mut self, + name: &str, + cursor: bool, + stop: &AtomicBool, + mut pixels: Vec, + ) -> Result { + 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 for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + 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 for State { + fn event( + state: &mut Self, + _: &wl_output::WlOutput, + event: wl_output::Event, + name: &u32, + _: &Connection, + _: &QueueHandle, + ) { + 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 for State { + fn event( + state: &mut Self, + frame: &frame::ZwlrScreencopyFrameV1, + event: frame::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + 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 { + 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> for State { + fn event( + _: &mut Self, + _: &wayland_client::protocol::wl_callback::WlCallback, + _: wayland_client::protocol::wl_callback::Event, + done: &Arc, + _: &Connection, + _: &QueueHandle, + ) { + 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()); + } +} diff --git a/portal/src/main.rs b/portal/src/main.rs new file mode 100644 index 0000000..9b08cf9 --- /dev/null +++ b/portal/src/main.rs @@ -0,0 +1,22 @@ +mod backend; +mod capture; +mod picker; +mod stream; + +fn main() -> Result<(), Box> { + 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()), + } +} diff --git a/portal/src/picker.rs b/portal/src/picker.rs new file mode 100644 index 0000000..607c443 --- /dev/null +++ b/portal/src/picker.rs @@ -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, + pub multiple: bool, + pub indicator: bool, +} + +#[derive(Clone, Debug)] +enum Message { + Select(usize), + Share, + Cancel, +} + +struct Picker { + core: Core, + prompt: Prompt, + selected: Vec, +} + +pub fn run() -> Result<(), Box> { + 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::( + 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) { + // 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 { + 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() + }, + ) +} diff --git a/portal/src/stream.rs b/portal/src/stream.rs new file mode 100644 index 0000000..0e1144d --- /dev/null +++ b/portal/src/stream.rs @@ -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>, + stop: Arc, + streaming: Arc, + fresh: Arc, + 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 { + 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> { + 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(()) +} diff --git a/scripts/build-site.py b/scripts/build-site.py index 9278497..52afc4c 100644 --- a/scripts/build-site.py +++ b/scripts/build-site.py @@ -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) diff --git a/scripts/install-session.sh b/scripts/install-session.sh index d6303df..be738ea 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop [[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; } # Refuse to overwrite unrelated installations. -for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot; 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." diff --git a/scripts/install.sh b/scripts/install.sh index e5225e6..9e16995 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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)' diff --git a/scripts/tests/test_portal_isolated.py b/scripts/tests/test_portal_isolated.py new file mode 100644 index 0000000..7372337 --- /dev/null +++ b/scripts/tests/test_portal_isolated.py @@ -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() diff --git a/site/content/index.md b/site/content/index.md index d84ac73..499bce4 100644 --- a/site/content/index.md +++ b/site/content/index.md @@ -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.