From 915bc8afd684ec30c4b451d218c6dad1b57448e2 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 29 Sep 2026 22:42:27 +0100 Subject: [PATCH] Add native appearance, screenshot and wallpaper portals --- Cargo.lock | 19 +- README.md | 1 + compositor/src/ipc.rs | 69 +++ compositor/src/window_rules.rs | 8 +- docs/installation.md | 4 +- docs/portals.md | 80 +++ docs/screen-sharing.md | 4 +- feresectl/src/main.rs | 6 +- packaging/portal/ferese-portals.conf | 3 + packaging/portal/ferese.portal | 2 +- portal/Cargo.toml | 9 +- portal/src/backend.rs | 48 +- portal/src/consent.rs | 183 +++++++ portal/src/desktop.rs | 727 +++++++++++++++++++++++++ portal/src/main.rs | 5 + portal/src/parent.rs | 119 ++++ portal/src/picker.rs | 2 +- portal/src/settings.rs | 191 +++++++ scripts/tests/test_portal_contracts.py | 125 +++++ shell/ferese-lock/src/appearance.rs | 3 +- 20 files changed, 1575 insertions(+), 33 deletions(-) create mode 100644 docs/portals.md create mode 100644 portal/src/consent.rs create mode 100644 portal/src/desktop.rs create mode 100644 portal/src/parent.rs create mode 100644 portal/src/settings.rs create mode 100644 scripts/tests/test_portal_contracts.py diff --git a/Cargo.lock b/Cargo.lock index a929224..0e193be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2186,6 +2186,16 @@ dependencies = [ "weezl", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio-sys" version = "0.21.5" @@ -2921,6 +2931,8 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif 0.14.2", "image-webp", "moxcms", "num-traits", @@ -4883,7 +4895,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ - "gif", + "gif 0.13.3", "image-webp", "log", "pico-args", @@ -7402,19 +7414,24 @@ version = "0.1.0" dependencies = [ "dirs", "ferese-config", + "ferese-ipc", "ferese-theme", "futures-util", "gstreamer", + "image", "jiff", "libc", "libcosmic", "memmap2 0.9.11", "pipewire", + "png 0.18.1", "serde", "serde_json", "tempfile", "tokio", + "url", "wayland-client", + "wayland-protocols", "wayland-protocols-wlr", "zbus", ] diff --git a/README.md b/README.md index f7dc9ff..f37650a 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Settings → Shortcuts** to change them or assign gestures to other actions. - [Desktop widgets](docs/desktop-widgets.md) - [Notifications](docs/notifications.md) - [Native locker](docs/locking.md) +- [Desktop portals and compatibility](docs/portals.md) - [Screen sharing and recording](docs/screen-sharing.md) ## Development diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index f2aa9c7..344a960 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -341,6 +341,17 @@ impl Ferese { "expel" => self.expel_focused_window(), "close" => self.close_focused_window(), "get-focused-window" => return Ok(self.focused_window_json()), + "get-windows" => return Ok(self.windows_json()), + "has-client-surfaces" => { + let pid = args + .get("pid") + .and_then(Value::as_u64) + .and_then(|pid| u32::try_from(pid).ok()) + .ok_or_else(|| { + CommandError::new("invalid_argument", "pid must be an unsigned process ID") + })?; + return Ok(json!(self.has_client_surfaces(pid))); + } "get-workspaces" => return Ok(self.workspaces_json()), "get-outputs" => return Ok(self.outputs_json()), _ => { @@ -506,6 +517,64 @@ impl Ferese { .unwrap_or(Value::Null) } + fn has_client_surfaces(&self, pid: u32) -> bool { + use smithay::reexports::wayland_server::Resource; + let belongs_to = + |surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface| { + surface + .client() + .and_then(|client| client.get_credentials(&self.display_handle).ok()) + .is_some_and(|credentials| credentials.pid as u32 == pid) + }; + self.window_ids + .keys() + .filter_map(|window| window.toplevel()) + .any(|toplevel| belongs_to(toplevel.wl_surface())) + || self.space.outputs().any(|output| { + smithay::desktop::layer_map_for_output(output) + .layers() + .any(|layer| belongs_to(layer.wl_surface())) + }) + } + + fn windows_json(&self) -> Value { + use smithay::wayland::{compositor::with_states, shell::xdg::XdgToplevelSurfaceData}; + let mut windows = self + .window_ids + .iter() + .filter_map(|(window, id)| { + let toplevel = window.toplevel()?; + let (app_id, title) = with_states(toplevel.wl_surface(), |states| { + let attributes = states + .data_map + .get::() + .unwrap() + .lock() + .unwrap(); + ( + attributes.app_id.clone().unwrap_or_default(), + attributes.title.clone().unwrap_or_default(), + ) + }); + let rect = self.visual_rect_for_window(window)?; + Some(json!({ + "id": id.0, + "app_id": app_id, + "title": title, + "focused": self.focused_window == Some(*id), + "mapped": self.space.element_location(window).is_some(), + "workspace": self.workspaces.workspace_for_window(*id).map(|workspace| workspace.0), + "x": rect.loc.x, + "y": rect.loc.y, + "width": rect.size.w, + "height": rect.size.h, + })) + }) + .collect::>(); + windows.sort_by_key(|window| window["id"].as_u64()); + Value::Array(windows) + } + fn workspaces_json(&self) -> Value { let active = self.workspaces.active_id(); let workspaces = self diff --git a/compositor/src/window_rules.rs b/compositor/src/window_rules.rs index cd8b1f1..cc20188 100644 --- a/compositor/src/window_rules.rs +++ b/compositor/src/window_rules.rs @@ -73,7 +73,12 @@ pub fn resolve( let mut result = WindowRuleResult::default(); if matches!( app_id.as_deref(), - Some("dev.ferese.authentication" | "dev.ferese.screenshare" | "dev.ferese.screenshot") + Some( + "dev.ferese.authentication" + | "dev.ferese.screenshare" + | "dev.ferese.screenshot" + | "dev.ferese.portaldialog" + ) ) { result.floating = Some(true); } @@ -217,6 +222,7 @@ mod tests { #[test] fn native_dialogs_float_by_default_and_allow_user_overrides() { for app_id in [ + "dev.ferese.PortalDialog", "dev.ferese.ScreenShare", "dev.ferese.ScreenShare.desktop", "dev.ferese.Authentication", diff --git a/docs/installation.md b/docs/installation.md index f15d301..b7c1c0e 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, locker, polkit agent, and ScreenCast portal together. It does not install system +Settings, command-line tools, locker, polkit agent, and desktop portal backend together. It does not install system packages or enable background services at boot. On systemd systems, the installed session helper activates `ferese-session.target` after exporting the compositor's Wayland display, and stops it when the shell exits. This keeps @@ -105,7 +105,7 @@ The installer adds: - Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`, `feresectl`, `ferese-lock`, `ferese-polkit-agent`, `ferese-screenshot`, and `xdg-desktop-portal-ferese`, and `ferese-record`. -- D-Bus activation and Ferese-specific ScreenCast portal registration. +- D-Bus activation and Ferese-specific desktop 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. diff --git a/docs/portals.md b/docs/portals.md new file mode 100644 index 0000000..705c9fc --- /dev/null +++ b/docs/portals.md @@ -0,0 +1,80 @@ +# Desktop portals + +Applications call the standard `org.freedesktop.portal.Desktop` frontend. Ferese's +native backend implements desktop integration; GTK remains the fallback for +standard file, print and application dialogs. Install both backends. + +## Native services + +- **Settings:** Ferese's color scheme, accent color, contrast preference and + reduced-motion setting. Changes are signalled after valid KDL appearance + changes; malformed saves leave the last valid preferences intact. +- **Screenshot:** whole-screen and selected-area PNG captures, plus a screen + color picker. Every capture asks for consent. Interactive requests offer area + selection. Selection uses `slurp`; captures use Ferese's bounded screenshot IPC. + The helper must disappear from the compositor before capture starts. +- **Wallpaper:** a preview and confirmation, then an atomic configuration update. + Desktop and lock-screen wallpaper can be set independently, or together. + Ferese copies accepted local images into its own private data directory so + temporary portal files can disappear safely. Image bytes and decoded dimensions + are bounded. `theme.background.lock-path` overrides the lock-screen image; + without it the lock screen follows `theme.background.path`. +- **ScreenCast:** monitor sharing with a native picker and PipeWire streams. + See [screen sharing](screen-sharing.md) for lifetime and recording details. + +Native request dialogs use Ferese's theme and are floating windows. Screenshot, +PickColor and Wallpaper consent dialogs use Wayland parent identifiers to establish +a transient relationship with the requesting window. +Cancelling a request or losing the portal frontend terminates its pending helper. + +The packaged `ferese-portals.conf` selects these interfaces explicitly. Existing +user overrides in `~/.config/xdg-desktop-portal/` may take precedence. Install the +new compositor, backend and preference files together; screenshot synchronization +requires the updated compositor IPC. + +## GNOME compatibility audit + +The audit compares the [GNOME backend's registered interfaces](https://github.com/GNOME/xdg-desktop-portal-gnome/blob/main/data/meson.build) +with the [standard backend D-Bus contracts](https://flatpak.github.io/xdg-desktop-portal/docs/impl-dbus-interfaces.html). +Matching D-Bus signatures does not establish complete feature parity. + +| Interface | Ferese integration | +| --- | --- | +| Settings | Native, including appearance change signals | +| Screenshot | Native screen/area capture and PickColor; true window capture remains missing | +| Wallpaper | Native desktop, lock-screen and combined targets | +| ScreenCast | Native monitors; window sources, persistence and richer metadata remain missing | +| Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK | +| Background | Missing native application state and background consent | +| Usb | Missing native device consent | +| GlobalShortcuts | Missing session-scoped compositor registration and activation signals | +| RemoteDesktop | Missing authorized input injection and EIS transport | +| Clipboard | Missing remote-session clipboard transfer | +| InputCapture | Missing zones, pointer barriers and EIS transport | +| Lockdown | GTK availability depends on the installed build; no native policy provider | +| Inhibit | GTK's GNOME-session/ScreenSaver integration does not provide complete Ferese session inhibition | + +The frontend supplies additional APIs such as OpenURI, network monitoring, +document export and permission storage. Adding a similarly named backend service +is neither required nor sufficient for those APIs. + +Do not advertise window capture or input services until the compositor provides +the corresponding operation. Screen crops include occluding windows and cannot +serve as isolated window captures. GNOME's remote-input features require native +compositor/session work, rather than substituting successful empty replies. + +## Validation + +Build the backend and run unit tests, then check its contracts on a private bus: + +```sh +cargo build --locked -p xdg-desktop-portal-ferese +cargo test --locked -p xdg-desktop-portal-ferese +python3 scripts/tests/test_portal_contracts.py +``` + +The contract test compares introspection against the installed official backend +XML, verifies appearance changes and invalid-save retention, and checks that +sensitive methods reject callers outside the portal frontend. It does not open +consent dialogs or capture the host desktop. Interactive consent, fractional-scale +capture, polkit and hotplug still require a live session test. diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 1c41d55..178a573 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -47,7 +47,9 @@ 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. +manager. The GTK backend supplies standard dialogs such as file selection. Ferese also +provides native Settings, Screenshot and Wallpaper services; see +[desktop portals](portals.md). The built-in recorder also needs GStreamer’s PipeWire, base, and good plugins (`pipewiresrc`, `videoconvert`, `vp8enc`, and `webmmux`). The installation guide includes these packages. diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index d74a14e..a44d077 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -88,8 +88,8 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), }, "toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout" | "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel" - | "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "reload-config" - | "exit" | "request-logout" => { + | "close" | "get-focused-window" | "get-windows" | "get-workspaces" | "get-outputs" + | "reload-config" | "exit" | "request-logout" => { if !positional.is_empty() { return Err(format!("{command} does not accept arguments")); } @@ -118,7 +118,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] diff --git a/packaging/portal/ferese-portals.conf b/packaging/portal/ferese-portals.conf index b491201..7597f87 100644 --- a/packaging/portal/ferese-portals.conf +++ b/packaging/portal/ferese-portals.conf @@ -1,3 +1,6 @@ [preferred] default=gtk; org.freedesktop.impl.portal.ScreenCast=ferese; +org.freedesktop.impl.portal.Settings=ferese; +org.freedesktop.impl.portal.Screenshot=ferese; +org.freedesktop.impl.portal.Wallpaper=ferese; diff --git a/packaging/portal/ferese.portal b/packaging/portal/ferese.portal index 2b7414a..9b2fc6a 100644 --- a/packaging/portal/ferese.portal +++ b/packaging/portal/ferese.portal @@ -1,4 +1,4 @@ [portal] DBusName=org.freedesktop.impl.portal.desktop.ferese -Interfaces=org.freedesktop.impl.portal.ScreenCast; +Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper; UseIn=Ferese; diff --git a/portal/Cargo.toml b/portal/Cargo.toml index fa10869..a10e6b8 100644 --- a/portal/Cargo.toml +++ b/portal/Cargo.toml @@ -14,6 +14,11 @@ name = "ferese-record" path = "src/record_main.rs" [dependencies] +ferese-ipc = { path = "../crates/ferese-ipc" } +url = "2.5" +png = "0.18" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } +tempfile.workspace = true ferese-theme = { path = "../crates/ferese-theme" } gstreamer = "0.24" futures-util = "0.3" @@ -28,8 +33,6 @@ 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 = { version = "0.32", features = ["client", "unstable"] } wayland-protocols-wlr.workspace = true zbus = { version = "5.19.0", default-features = false, features = ["tokio"] } - -[dev-dependencies] -tempfile.workspace = true diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 16f82dd..f29eba5 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -25,8 +25,7 @@ use zbus::{ object_server::SignalEmitter, zvariant::{OwnedObjectPath, OwnedValue, Value}, }; -type Options = HashMap; -type Reply = (u32, Options); +pub(crate) type Options = HashMap; #[derive(Debug)] enum StartError { Cancelled, @@ -45,16 +44,16 @@ impl From<&str> for StartError { const FRONTEND: &str = "org.freedesktop.portal.Desktop"; const PATH: &str = "/org/freedesktop/portal/desktop"; #[derive(Default)] -struct Cancel { - stopped: AtomicBool, +pub(crate) struct Cancel { + pub(crate) stopped: AtomicBool, changed: Notify, } impl Cancel { - fn stop(&self) { + pub(crate) fn stop(&self) { self.stopped.store(true, Ordering::SeqCst); self.changed.notify_one(); } - async fn wait(&self) { + pub(crate) async fn wait(&self) { if !self.stopped.load(Ordering::SeqCst) { self.changed.notified().await; } @@ -147,7 +146,10 @@ fn shell_recorder(pid: u32) -> bool { parent.is_some_and(|pid| matches_binary(pid, "ferese-shell") == Some(true)) } -async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result { +pub(crate) async fn authorize( + connection: &Connection, + header: &Header<'_>, +) -> zbus::fdo::Result { let sender = header .sender() .ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?; @@ -155,7 +157,7 @@ async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::R 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(), + "Use the desktop portal".into(), )); } Ok(sender.to_string()) @@ -230,7 +232,7 @@ impl Backend { _options: Options, #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, - ) -> zbus::fdo::Result { + ) -> zbus::fdo::Result<(u32, Options)> { let owner = authorize(connection, &header).await?; if !session_handle .as_str() @@ -281,7 +283,7 @@ impl Backend { options: Options, #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, - ) -> zbus::fdo::Result { + ) -> zbus::fdo::Result<(u32, Options)> { let owner = authorize(connection, &header).await?; let settings = source_options(&options); let mut sessions = self.sessions.lock().await; @@ -318,7 +320,7 @@ impl Backend { _options: Options, #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, - ) -> zbus::fdo::Result { + ) -> zbus::fdo::Result<(u32, Options)> { let owner = authorize(connection, &header).await?; if !handle .as_str() @@ -413,9 +415,9 @@ impl Backend { } } } -struct Request { - cancel: Arc, - owner: String, +pub(crate) struct Request { + pub(crate) cancel: Arc, + pub(crate) owner: String, } #[zbus::interface(name = "org.freedesktop.impl.portal.Request")] impl Request { @@ -449,8 +451,12 @@ impl SessionObject { 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())?); +pub(crate) fn child_command() -> Result { + child_command_for(std::env::current_exe().map_err(|e| e.to_string())?) +} + +pub(crate) fn child_command_for(program: impl AsRef) -> Result { + let mut command = Command::new(program); let parent = std::process::id() as libc::pid_t; // SAFETY: only async-signal-safe libc calls run between fork and exec. unsafe { @@ -585,21 +591,24 @@ async fn supervise(mut children: Vec, cancel: Arc) { } pub async fn run() -> Result<(), Box> { let backend = Backend::default(); + let settings = crate::settings::Settings::new(); + let requests = crate::desktop::Requests::default(); let connection = zbus::connection::Builder::session()? .name("org.freedesktop.impl.portal.desktop.ferese")? .serve_at(PATH, backend.clone())? + .serve_at(PATH, settings.clone())? + .serve_at(PATH, crate::desktop::Screenshot(requests.clone()))? + .serve_at(PATH, crate::desktop::Wallpaper(requests.clone()))? .serve_at( "/org/ferese/ScreenRecorder", RecorderControl(backend.clone()), )? .build() .await?; + tokio::spawn(settings.watch(connection.clone())); // 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()) @@ -608,6 +617,7 @@ pub async fn run() -> Result<(), Box> { .map(|name| name.to_string()), Err(_) => None, }; + requests.revoke_stale(owner.as_deref()).await; let stale: Vec<_> = backend .sessions .lock() diff --git a/portal/src/consent.rs b/portal/src/consent.rs new file mode 100644 index 0000000..0dd3ccc --- /dev/null +++ b/portal/src/consent.rs @@ -0,0 +1,183 @@ +use cosmic::{ + Element, + app::{Core, Settings, Task}, + iced::{Alignment, Length}, + widget::{button, column, container, image, row, scrollable}, +}; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; + +#[derive(Clone, Serialize, Deserialize)] +pub(crate) struct Prompt { + pub title: String, + pub description: String, + pub accept: String, + pub parent: String, + pub image: Option, +} + +#[derive(Clone, Debug)] +enum Message { + Opened(cosmic::iced::window::Id), + Attached( + Result< + ( + Option>, + Result, + ), + String, + >, + ), + Accept, + Cancel, +} + +struct Consent { + core: Core, + prompt: Prompt, + material: Option, + parent: Option>, + palette: ferese_theme::Palette, + font: cosmic::font::Font, + background: cosmic::iced::Color, +} + +pub(crate) 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)?; + let (theme, font, background, _) = crate::picker::appearance(); + let height = if prompt.image.is_some() { 430. } else { 220. }; + cosmic::app::run::( + Settings::default() + .size(cosmic::iced::Size::new(440., height)) + .client_decorations(false) + .transparent(true) + .theme(theme) + .default_font(font) + .default_text_size(14.) + .is_daemon(false), + (prompt, font, background), + )?; + Ok(()) +} + +impl cosmic::Application for Consent { + type Executor = cosmic::executor::Default; + type Flags = (Prompt, cosmic::font::Font, cosmic::iced::Color); + type Message = Message; + const APP_ID: &'static str = "dev.ferese.PortalDialog"; + + fn core(&self) -> &Core { + &self.core + } + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn init(mut core: Core, (prompt, font, background): Self::Flags) -> (Self, Task) { + core.window.show_headerbar = false; + core.window.border_padding = Some(0); + core.window.content_container = false; + let app = Self { + core, + prompt, + material: None, + parent: None, + palette: { + let document = ferese_config::config_path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|source| ferese_config::Document::parse(&source).ok()); + ferese_theme::Palette::from_document(document.as_ref()) + }, + font, + background, + }; + (app, Task::none()) + } + + fn subscription(&self) -> cosmic::iced::Subscription { + cosmic::iced::event::listen_with(|event, _, id| match event { + cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => { + Some(Message::Opened(id)) + } + cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { + key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape), + .. + }) => Some(Message::Cancel), + _ => None, + }) + } + + fn update(&mut self, message: Message) -> Task { + match message { + Message::Opened(id) => { + let parent = self.prompt.parent.clone(); + return cosmic::iced::window::run(id, move |window| { + let parent = + crate::parent::Parent::attach(window, &parent)?.map(std::sync::Arc::new); + Ok(( + parent, + ferese_theme::material::ModalMaterial::attach(window), + )) + }) + .map(|result| cosmic::Action::App(Message::Attached(result))); + } + Message::Attached(Ok((parent, material))) => { + self.parent = parent; + self.material = material.ok(); + } + Message::Attached(Err(error)) => { + eprintln!("ferese portal: {error}"); + return cosmic::iced::exit(); + } + Message::Accept => { + println!("true"); + let _ = std::io::stdout().flush(); + return cosmic::iced::exit(); + } + Message::Cancel => return cosmic::iced::exit(), + } + Task::none() + } + + fn style(&self) -> Option { + Some(self.palette.application_style(if self.material.is_some() { + cosmic::iced::Color::TRANSPARENT + } else { + self.background + })) + } + + fn view(&self) -> Element<'_, Message> { + let text = |value: String| ferese_theme::text(value, self.font); + let mut content = column![ + text(self.prompt.title.clone()).size(22), + scrollable(text(self.prompt.description.clone())).height(Length::Fill) + ] + .spacing(16); + if let Some(path) = &self.prompt.image { + content = content.push( + image(image::Handle::from_path(path)) + .height(Length::Fill) + .width(Length::Fill) + .content_fit(cosmic::iced::ContentFit::Contain), + ); + } + content = content.push( + row![ + button::custom(text("Cancel".into())) + .class(cosmic::theme::Button::Text) + .on_press(Message::Cancel), + button::custom(text(self.prompt.accept.clone())) + .class(ferese_theme::accent_button()) + .on_press(Message::Accept) + ] + .spacing(12) + .align_y(Alignment::Center), + ); + container(content).padding(24).width(Length::Fill).into() + } +} diff --git a/portal/src/desktop.rs b/portal/src/desktop.rs new file mode 100644 index 0000000..828a437 --- /dev/null +++ b/portal/src/desktop.rs @@ -0,0 +1,727 @@ +use crate::{ + backend::{Cancel, Options, Request, authorize}, + consent::Prompt, +}; +use std::{ + collections::HashMap, + io::Read, + os::unix::{fs::PermissionsExt, net::UnixStream}, + path::{Path, PathBuf}, + process::Stdio, + sync::{Arc, atomic::Ordering}, + time::Duration, +}; +use tokio::{io::AsyncWriteExt, sync::Mutex}; +use zbus::{ + Connection, + message::Header, + zvariant::{OwnedObjectPath, Value}, +}; + +const PATH: &str = "/org/freedesktop/portal/desktop"; +const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024; +type Reply = (u32, Options); + +#[derive(Clone, Default)] +pub(crate) struct Requests(Arc)>>>); + +impl Requests { + pub(crate) async fn revoke_stale(&self, owner: Option<&str>) { + for (request_owner, cancel) in self.0.lock().await.values() { + if Some(request_owner.as_str()) != owner { + cancel.stop(); + } + } + } + + pub(crate) async fn begin( + &self, + connection: &Connection, + header: &Header<'_>, + handle: &OwnedObjectPath, + ) -> zbus::fdo::Result> { + let owner = authorize(connection, header).await?; + if !handle.as_str().starts_with(&format!("{PATH}/request/")) { + return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into())); + } + let mut pending = self.0.lock().await; + if pending.len() >= 8 || pending.contains_key(handle.as_str()) { + return Err(zbus::fdo::Error::LimitsExceeded( + "Request limit reached".into(), + )); + } + let cancel = Arc::new(Cancel::default()); + if !connection + .object_server() + .at( + handle, + Request { + owner: owner.clone(), + cancel: cancel.clone(), + }, + ) + .await? + { + return Err(zbus::fdo::Error::InvalidArgs( + "Request already exists".into(), + )); + } + pending.insert(handle.to_string(), (owner, cancel.clone())); + Ok(cancel) + } + + pub(crate) async fn end(&self, connection: &Connection, handle: &OwnedObjectPath) { + self.0.lock().await.remove(handle.as_str()); + let _ = connection + .object_server() + .remove::(handle) + .await; + } +} + +#[derive(Clone)] +pub(crate) struct Screenshot(pub(crate) Requests); + +#[zbus::interface(name = "org.freedesktop.impl.portal.Screenshot")] +impl Screenshot { + #[zbus(property, name = "version")] + fn version(&self) -> u32 { + 3 + } + + #[zbus(property)] + fn available_targets(&self) -> u32 { + 1 | 4 + } + + async fn screenshot( + &self, + handle: OwnedObjectPath, + app_id: String, + parent_window: String, + options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let cancel = self.0.begin(connection, &header, &handle).await?; + let result = tokio::select! { + biased; + _ = cancel.wait() => (1, Options::new()), + result = screenshot(&app_id, &parent_window, &options) => reply(result), + }; + self.0.end(connection, &handle).await; + Ok(result) + } + + async fn pick_color( + &self, + handle: OwnedObjectPath, + app_id: String, + parent_window: String, + _options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let cancel = self.0.begin(connection, &header, &handle).await?; + let result = tokio::select! { + biased; + _ = cancel.wait() => (1, Options::new()), + result = pick_color(&app_id, &parent_window) => reply(result), + }; + self.0.end(connection, &handle).await; + Ok(result) + } +} + +#[derive(Clone)] +pub(crate) struct Wallpaper(pub(crate) Requests); + +#[zbus::interface(name = "org.freedesktop.impl.portal.Wallpaper")] +impl Wallpaper { + #[zbus(name = "SetWallpaperURI")] + async fn set_wallpaper_uri( + &self, + handle: OwnedObjectPath, + app_id: String, + parent_window: String, + uri: String, + options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result { + let cancel = self.0.begin(connection, &header, &handle).await?; + let result = tokio::select! { + biased; + _ = cancel.wait() => 1, + result = wallpaper(&app_id, &parent_window, &uri, &options, cancel.clone()) => reply(result).0, + }; + self.0.end(connection, &handle).await; + Ok(result) + } +} + +pub(crate) fn reply(result: Result, String>) -> Reply { + match result { + Ok(Some(values)) => (0, values), + Ok(None) => (1, Options::new()), + Err(error) => { + eprintln!("ferese portal: {error}"); + (2, Options::new()) + } + } +} + +pub(crate) async fn consent( + app: &str, + parent: &str, + title: &str, + description: &str, + accept: &str, + image: Option, +) -> Result { + if app.len() > 512 { + return Err("Application ID is too long".into()); + } + let prompt = Prompt { + parent: parent.into(), + title: title.into(), + description: if app.is_empty() { + description.into() + } else { + format!("{app}\n{description}") + }, + accept: accept.into(), + image, + }; + let mut child = crate::backend::child_command()? + .arg("--consent") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .map_err(|error| error.to_string())?; + let pid = child.id().ok_or("Missing consent process ID")?; + let mut input = child.stdin.take().unwrap(); + input + .write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?) + .await + .map_err(|error| error.to_string())?; + drop(input); + let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output()) + .await + .map_err(|_| "Consent timed out")? + .map_err(|error| error.to_string())?; + if output.status.success() && output.stdout == b"true\n" { + wait_for_surface_removal(pid).await?; + Ok(true) + } else { + Ok(false) + } +} + +pub(crate) async fn ipc( + command: &'static str, + args: serde_json::Value, +) -> Result { + tokio::task::spawn_blocking(move || { + let path = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .ok_or("Missing runtime directory")? + .join("ferese/control.sock"); + let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(15))) + .map_err(|error| error.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + let request = ferese_ipc::Request { + version: ferese_ipc::VERSION, + id: 1, + kind: "command".into(), + command: command.into(), + args, + }; + ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?; + let response: ferese_ipc::Response = + ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?; + if let Some(error) = response.error { + return Err(error.message); + } + response.result.ok_or("Missing IPC result".into()) + }) + .await + .map_err(|error| error.to_string())? +} + +async fn select_geometry(point: bool, windows: Option) -> Result, String> { + let mut command = crate::backend::child_command_for("slurp")?; + if point { + command.arg("-p"); + } + if windows.is_some() { + command.arg("-r"); + } + command.stdin(if windows.is_some() { + Stdio::piped() + } else { + Stdio::null() + }); + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .map_err(|error| error.to_string())?; + let pid = child.id().ok_or("Missing selection process ID")?; + if let Some(windows) = windows { + let mut input = child.stdin.take().unwrap(); + input + .write_all(windows.as_bytes()) + .await + .map_err(|error| error.to_string())?; + drop(input); + } + let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output()) + .await + .map_err(|_| "Selection timed out")? + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Ok(None); + } + let geometry = String::from_utf8(output.stdout) + .map_err(|error| error.to_string())? + .trim() + .to_owned(); + if geometry.len() > 128 || geometry.is_empty() { + return Err("Invalid selection".into()); + } + wait_for_surface_removal(pid).await?; + Ok(Some(geometry)) +} + +async fn wait_for_surface_removal(pid: u32) -> Result<(), String> { + for _ in 0..100 { + if ipc("has-client-surfaces", serde_json::json!({"pid": pid})).await? == false { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err("Screenshot helper is still visible".into()) +} + +async fn capture(geometry: Option) -> Result, String> { + let result = ipc("screenshot", serde_json::json!({"geometry": geometry})).await?; + let path = result["path"] + .as_str() + .ok_or("Missing screenshot path")? + .to_owned(); + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&path).map_err(|error| error.to_string())?; + let _ = std::fs::remove_file(path); + let mut bytes = Vec::new(); + file.take(MAX_IMAGE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_IMAGE_BYTES { + return Err("Screenshot is too large".into()); + } + Ok(bytes) + }) + .await + .map_err(|error| error.to_string())? +} + +async fn screenshot(app: &str, parent: &str, options: &Options) -> Result, String> { + let target = options + .get("target") + .map(u32::try_from) + .transpose() + .map_err(|_| "Invalid screenshot target")? + .unwrap_or( + if options + .get("interactive") + .map(bool::try_from) + .transpose() + .map_err(|_| "Invalid interactive hint")? + .unwrap_or(false) + { + 4 + } else { + 1 + }, + ); + if !matches!(target, 1 | 4) { + return Err("Unsupported screenshot target".into()); + } + if !consent( + app, + parent, + "Take a screenshot?", + "Allow this app to capture the selected screen content.", + "Continue", + None, + ) + .await? + { + return Ok(None); + } + let geometry = match target { + 1 => None, + 4 => { + let Some(geometry) = select_geometry(false, None).await? else { + return Ok(None); + }; + Some(geometry) + } + _ => unreachable!(), + }; + let bytes = capture(geometry).await?; + let directory = private_directory("screenshots")?; + let mut file = tempfile::Builder::new() + .prefix("screenshot-") + .suffix(".png") + .tempfile_in(directory) + .map_err(|error| error.to_string())?; + std::io::Write::write_all(&mut file, &bytes).map_err(|error| error.to_string())?; + let (_, path) = file.keep().map_err(|error| error.to_string())?; + Ok(Some(HashMap::from([( + "uri".into(), + Value::from(file_uri(&path)?) + .try_to_owned() + .map_err(|error| error.to_string())?, + )]))) +} + +async fn pick_color(app: &str, parent: &str) -> Result, String> { + if !consent( + app, + parent, + "Pick a screen color?", + "Choose a pixel to share its color with this app.", + "Choose color", + None, + ) + .await? + { + return Ok(None); + } + let Some(geometry) = select_geometry(true, None).await? else { + return Ok(None); + }; + let bytes = capture(Some(geometry)).await?; + let color = png_color(&bytes)?; + Ok(Some(HashMap::from([( + "color".into(), + Value::from(color) + .try_to_owned() + .map_err(|error| error.to_string())?, + )]))) +} + +fn png_color(bytes: &[u8]) -> Result<(f64, f64, f64), String> { + let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + let mut reader = decoder.read_info().map_err(|error| error.to_string())?; + let size = reader + .output_buffer_size() + .filter(|size| *size <= 16 * 1024) + .ok_or("Unexpected pixel dimensions")?; + let mut pixels = vec![0; size]; + let info = reader + .next_frame(&mut pixels) + .map_err(|error| error.to_string())?; + if info.width == 0 || info.height == 0 { + return Err("Empty image".into()); + } + match info.color_type { + png::ColorType::Rgb | png::ColorType::Rgba => Ok(( + pixels[0] as f64 / 255., + pixels[1] as f64 / 255., + pixels[2] as f64 / 255., + )), + _ => Err("Unexpected screenshot pixel format".into()), + } +} + +fn private_directory(name: &str) -> Result { + let directory = dirs::data_local_dir() + .ok_or("Missing data directory")? + .join("ferese/portal") + .join(name); + std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?; + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string())?; + Ok(directory) +} + +fn file_uri(path: &Path) -> Result { + url::Url::from_file_path(path) + .map(String::from) + .map_err(|_| "Invalid file path".into()) +} + +fn local_path(uri: &str) -> Result { + url::Url::parse(uri) + .map_err(|error| error.to_string())? + .to_file_path() + .map_err(|_| "Wallpaper must be a local file URI".into()) +} + +async fn wallpaper( + app: &str, + parent: &str, + uri: &str, + options: &Options, + cancel: Arc, +) -> Result, String> { + let target = options + .get("set-on") + .map(<&str>::try_from) + .transpose() + .map_err(|_| "Invalid wallpaper target")? + .unwrap_or("background"); + if !matches!(target, "background" | "lockscreen" | "both") { + return Err("Unsupported wallpaper target".into()); + } + let input = local_path(uri)?; + let preparing = cancel.clone(); + let file = tokio::task::spawn_blocking(move || prepare_wallpaper(&input, &preparing)) + .await + .map_err(|error| error.to_string())??; + if !consent( + app, + parent, + "Change your wallpaper?", + "Use this picture as your wallpaper.", + "Set wallpaper", + Some(file.path().to_path_buf()), + ) + .await? + { + return Ok(None); + } + if cancel.stopped.load(Ordering::SeqCst) { + return Ok(None); + } + let (_, path) = file.keep().map_err(|error| error.to_string())?; + if let Err(error) = update_wallpaper(&path, target) { + let _ = std::fs::remove_file(path); + return Err(error); + } + Ok(Some(Options::new())) +} + +fn prepare_wallpaper(input: &Path, cancel: &Cancel) -> Result { + use std::os::unix::fs::OpenOptionsExt; + let input = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(input) + .map_err(|error| error.to_string())?; + if !input + .metadata() + .map_err(|error| error.to_string())? + .is_file() + { + return Err("Wallpaper must be a regular file".into()); + } + let directory = private_directory("wallpapers")?; + let mut file = tempfile::Builder::new() + .prefix("wallpaper-") + .tempfile_in(directory) + .map_err(|error| error.to_string())?; + let mut input = input.take(MAX_IMAGE_BYTES + 1); + let mut size = 0u64; + let mut buffer = [0; 64 * 1024]; + loop { + if cancel.stopped.load(Ordering::SeqCst) { + return Err("Wallpaper request cancelled".into()); + } + let bytes = input.read(&mut buffer).map_err(|error| error.to_string())?; + if bytes == 0 { + break; + } + size += bytes as u64; + if size > MAX_IMAGE_BYTES { + return Err("Wallpaper is too large".into()); + } + std::io::Write::write_all(&mut file, &buffer[..bytes]) + .map_err(|error| error.to_string())?; + } + if size == 0 { + return Err("Wallpaper is empty".into()); + } + let reader = image::ImageReader::open(file.path()) + .map_err(|error| error.to_string())? + .with_guessed_format() + .map_err(|error| error.to_string())?; + let (width, height) = reader + .into_dimensions() + .map_err(|error| error.to_string())?; + if width == 0 + || height == 0 + || width > 16384 + || height > 16384 + || u64::from(width) * u64::from(height) > MAX_IMAGE_BYTES / 4 + { + return Err("Wallpaper dimensions exceed the image limit".into()); + } + let mut reader = image::ImageReader::open(file.path()) + .map_err(|error| error.to_string())? + .with_guessed_format() + .map_err(|error| error.to_string())?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_IMAGE_BYTES); + reader.limits(limits); + reader.decode().map_err(|error| error.to_string())?; + Ok(file) +} + +fn update_wallpaper(image: &Path, target: &str) -> Result<(), String> { + let path = ferese_config::config_path().ok_or("Missing config path")?; + edit_wallpaper(&path, image, target) +} + +fn edit_wallpaper(path: &Path, image: &Path, target: &str) -> Result<(), String> { + let source = match std::fs::read_to_string(&path) { + Ok(source) => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.to_string()), + }; + let mut document = + ferese_config::Document::parse(&source).map_err(|error| error.to_string())?; + if target == "background" && document.get("theme.background.lock_path").is_none() { + let old = document + .get("theme.background.path") + .and_then(serde_json::Value::as_str) + .unwrap_or(ferese_config::default_wallpaper()) + .to_owned(); + document + .set("theme.background.lock_path", old.into()) + .map_err(|error| error.to_string())?; + } + if target != "lockscreen" { + document + .set( + "theme.background.path", + image.to_string_lossy().into_owned().into(), + ) + .map_err(|error| error.to_string())?; + } + if target != "background" { + document + .set( + "theme.background.lock_path", + image.to_string_lossy().into_owned().into(), + ) + .map_err(|error| error.to_string())?; + } + let parent = path.parent().ok_or("Invalid config path")?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|error| error.to_string())?; + std::io::Write::write_all(&mut temp, document.to_string().as_bytes()) + .map_err(|error| error.to_string())?; + temp.as_file() + .sync_all() + .map_err(|error| error.to_string())?; + if std::fs::read_to_string(&path).unwrap_or_default() != source { + return Err("Configuration changed; please try again".into()); + } + temp.persist(path).map_err(|error| error.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_uris_preserve_special_characters_and_reject_network_paths() { + let path = Path::new("/tmp/wallpaper #1_日本.png"); + assert_eq!(local_path(&file_uri(path).unwrap()).unwrap(), path); + for uri in [ + "https://example.com/a.png", + "file://example.com/a.png", + "not a URI", + ] { + assert!(local_path(uri).is_err()); + } + } + + #[test] + fn pixel_color_is_normalized_rgb() { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&[255, 128, 0, 255]) + .unwrap(); + assert_eq!(png_color(&bytes).unwrap(), (1., 128. / 255., 0.)); + } + + #[test] + fn wallpaper_targets_preserve_independent_paths_and_other_settings() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.kdl"); + std::fs::write(&path, "// Keep my settings\ntheme { background { path \"/old.png\"; }; }; commands { terminal \"foot\"; }\n").unwrap(); + edit_wallpaper(&path, Path::new("/desktop.png"), "background").unwrap(); + let document = + ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + document.get("theme.background.path").unwrap(), + "/desktop.png" + ); + assert_eq!( + document.get("theme.background.lock_path").unwrap(), + "/old.png" + ); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("Keep my settings") + ); + edit_wallpaper(&path, Path::new("/lock.png"), "lockscreen").unwrap(); + let document = + ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + document.get("theme.background.path").unwrap(), + "/desktop.png" + ); + assert_eq!( + document.get("theme.background.lock_path").unwrap(), + "/lock.png" + ); + edit_wallpaper(&path, Path::new("/both.png"), "both").unwrap(); + let document = + ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(document.get("theme.background.path").unwrap(), "/both.png"); + assert_eq!( + document.get("theme.background.lock_path").unwrap(), + "/both.png" + ); + assert_eq!( + document.get("commands.terminal").unwrap(), + &serde_json::json!(["foot"]) + ); + } + + #[test] + fn wallpaper_rejects_nonregular_files_and_cancellation_before_copy() { + let dir = tempfile::tempdir().unwrap(); + assert!(prepare_wallpaper(dir.path(), &Cancel::default()).is_err()); + let path = dir.path().join("image"); + std::fs::write(&path, [0u8; 32]).unwrap(); + let cancel = Cancel::default(); + cancel.stop(); + assert!( + prepare_wallpaper(&path, &cancel) + .unwrap_err() + .contains("cancelled") + ); + } +} diff --git a/portal/src/main.rs b/portal/src/main.rs index 9b08cf9..73eccae 100644 --- a/portal/src/main.rs +++ b/portal/src/main.rs @@ -1,6 +1,10 @@ mod backend; mod capture; +mod consent; +mod desktop; +mod parent; mod picker; +mod settings; mod stream; fn main() -> Result<(), Box> { @@ -16,6 +20,7 @@ fn main() -> Result<(), Box> { Ok(()) } [flag] if flag == "--picker" => picker::run(), + [flag] if flag == "--consent" => consent::run(), [] => tokio::runtime::Runtime::new()?.block_on(backend::run()), _ => Err("Usage: xdg-desktop-portal-ferese [--sources]".into()), } diff --git a/portal/src/parent.rs b/portal/src/parent.rs new file mode 100644 index 0000000..183026d --- /dev/null +++ b/portal/src/parent.rs @@ -0,0 +1,119 @@ +use wayland_client::{ + Connection, Dispatch, Proxy, QueueHandle, delegate_noop, + globals::{GlobalListContents, registry_queue_init}, + protocol::{wl_registry, wl_surface}, +}; +use wayland_protocols::xdg::foreign::zv2::client::{ + zxdg_imported_v2::{self, ZxdgImportedV2}, + zxdg_importer_v2::ZxdgImporterV2, +}; + +#[derive(Debug)] +pub(crate) struct Parent { + connection: Connection, + imported: ZxdgImportedV2, + importer: ZxdgImporterV2, +} + +impl Parent { + pub(crate) fn attach( + window: &dyn cosmic::iced::window::Window, + parent: &str, + ) -> Result, String> { + use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle}; + if parent.is_empty() { + return Ok(None); + } + let handle = parent + .strip_prefix("wayland:") + .filter(|handle| !handle.is_empty() && handle.len() <= 4096) + .ok_or("Unsupported portal parent window identifier")?; + let display = window.display_handle().map_err(|error| error.to_string())?; + let surface = window.window_handle().map_err(|error| error.to_string())?; + let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(surface)) = + (display.as_raw(), surface.as_raw()) + else { + return Err("Portal parenting requires Wayland".into()); + }; + // The window lends live handles; the borrowed backend never owns the display. + let backend = unsafe { + wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) + }; + let connection = Connection::from_backend(backend); + let id = unsafe { + wayland_client::backend::ObjectId::from_ptr( + wl_surface::WlSurface::interface(), + surface.surface.as_ptr().cast(), + ) + } + .map_err(|error| error.to_string())?; + let surface = + wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?; + let (globals, mut queue) = + registry_queue_init::(&connection).map_err(|error| error.to_string())?; + let qh = queue.handle(); + let importer = globals + .bind::(&qh, 1..=1, ()) + .map_err(|error| error.to_string())?; + let imported = importer.import_toplevel(handle.into(), &qh, ()); + let mut state = State::default(); + queue + .roundtrip(&mut state) + .map_err(|error| error.to_string())?; + if state.invalid { + imported.destroy(); + importer.destroy(); + let _ = connection.flush(); + return Err("Portal parent window no longer exists".into()); + } + imported.set_parent_of(&surface); + connection.flush().map_err(|error| error.to_string())?; + Ok(Some(Self { + connection, + imported, + importer, + })) + } +} + +impl Drop for Parent { + fn drop(&mut self) { + self.imported.destroy(); + self.importer.destroy(); + let _ = self.connection.flush(); + } +} + +#[derive(Default)] +struct State { + invalid: bool, +} + +impl Dispatch for State { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ZxdgImportedV2, + event: zxdg_imported_v2::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if matches!(event, zxdg_imported_v2::Event::Destroyed) { + state.invalid = true; + } + } +} + +delegate_noop!(State: ignore ZxdgImporterV2); diff --git a/portal/src/picker.rs b/portal/src/picker.rs index b168ab6..a9677ca 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -258,7 +258,7 @@ impl Picker { } } -fn appearance() -> ( +pub(crate) fn appearance() -> ( cosmic::Theme, cosmic::font::Font, cosmic::iced::Color, diff --git a/portal/src/settings.rs b/portal/src/settings.rs new file mode 100644 index 0000000..3d42214 --- /dev/null +++ b/portal/src/settings.rs @@ -0,0 +1,191 @@ +use ferese_config::Document; +use ferese_theme::Palette; +use std::{collections::HashMap, sync::Arc, time::Duration}; +use tokio::sync::RwLock; +use zbus::{ + Connection, + object_server::SignalEmitter, + zvariant::{OwnedValue, Value}, +}; + +const APPEARANCE: &str = "org.freedesktop.appearance"; +const PATH: &str = "/org/freedesktop/portal/desktop"; +type Values = HashMap>; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Appearance { + scheme: u32, + accent: (f64, f64, f64), + reduced_motion: u32, +} + +impl Appearance { + fn from_document(document: Option<&Document>) -> Self { + let palette = Palette::from_document(document); + let animations = document + .and_then(|document| document.get("animations.enabled")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + Self { + scheme: if ferese_theme::luminance(palette.sidebar) > 0.5 { + 2 + } else { + 1 + }, + accent: ( + palette.accent.r as f64, + palette.accent.g as f64, + palette.accent.b as f64, + ), + reduced_motion: u32::from( + !animations + || document + .and_then(|doc| doc.get("animations.reduced_motion")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + ), + } + } + + fn values(self) -> HashMap { + HashMap::from([ + ("color-scheme".into(), self.scheme.into()), + ( + "accent-color".into(), + Value::from(self.accent).try_to_owned().unwrap(), + ), + ("contrast".into(), 0u32.into()), + ("reduced-motion".into(), self.reduced_motion.into()), + ]) + } +} + +#[derive(Clone)] +pub(crate) struct Settings(Arc>); + +impl Settings { + pub(crate) fn new() -> Self { + Self(Arc::new(RwLock::new( + load().unwrap_or_else(|| Appearance::from_document(None)), + ))) + } + + pub(crate) async fn watch(self, connection: Connection) { + let emitter = SignalEmitter::new(&connection, PATH).unwrap(); + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let Some(next) = load() else { continue }; + let mut current = self.0.write().await; + if *current == next { + continue; + } + let previous = current.values(); + *current = next; + drop(current); + for (key, value) in next.values() { + if previous.get(&key) != Some(&value) { + let _ = Self::setting_changed(&emitter, APPEARANCE, &key, value).await; + } + } + } + } +} + +#[zbus::interface(name = "org.freedesktop.impl.portal.Settings")] +impl Settings { + #[zbus(property, name = "version")] + fn version(&self) -> u32 { + 1 + } + + async fn read_all(&self, namespaces: Vec) -> Values { + if matches_namespace(&namespaces, APPEARANCE) { + HashMap::from([(APPEARANCE.into(), self.0.read().await.values())]) + } else { + HashMap::new() + } + } + + async fn read(&self, namespace: &str, key: &str) -> zbus::fdo::Result { + if namespace == APPEARANCE { + if let Some(value) = self.0.read().await.values().remove(key) { + return Ok(value); + } + } + Err(zbus::fdo::Error::InvalidArgs("Unknown setting".into())) + } + + #[zbus(signal)] + async fn setting_changed( + emitter: &SignalEmitter<'_>, + namespace: &str, + key: &str, + value: OwnedValue, + ) -> zbus::Result<()>; +} + +fn matches_namespace(filters: &[String], namespace: &str) -> bool { + filters.is_empty() + || filters.iter().any(|filter| { + filter.is_empty() + || filter == namespace + || filter.strip_suffix(".*").is_some_and(|prefix| { + namespace + .strip_prefix(prefix) + .is_some_and(|suffix| suffix.starts_with('.')) + }) + }) +} + +fn load() -> Option { + let path = ferese_config::config_path()?; + let source = match std::fs::read_to_string(path) { + Ok(source) if source.len() <= 1024 * 1024 => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Some(Appearance::from_document(None)); + } + _ => return None, + }; + let document = Document::parse(&source).ok()?; + Some(Appearance::from_document(Some(&document))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn namespaces_support_only_trailing_section_wildcards() { + for filter in [ + "", + "org.freedesktop.appearance", + "org.freedesktop.*", + "org.*", + ] { + assert!(matches_namespace(&[filter.into()], APPEARANCE)); + } + for filter in [ + "org.freedesktop*", + "org.*.appearance", + "org.freedesktop.appearance.*", + "other", + ] { + assert!(!matches_namespace(&[filter.into()], APPEARANCE)); + } + assert!(matches_namespace(&[], APPEARANCE)); + } + + #[test] + fn reports_ferese_theme_and_reduced_motion() { + let doc = Document::parse("theme { colors { surface-base \"#ffffff\"; accent \"#ff8000\"; }; }; animations { enabled #false; }").unwrap(); + let appearance = Appearance::from_document(Some(&doc)); + assert_eq!(appearance.scheme, 2); + assert_eq!(appearance.reduced_motion, 1); + assert_eq!(appearance.accent.0, 1.); + assert_eq!(appearance.accent.2, 0.); + assert_eq!( + u32::try_from(appearance.values().remove("contrast").unwrap()).unwrap(), + 0 + ); + } +} diff --git a/scripts/tests/test_portal_contracts.py b/scripts/tests/test_portal_contracts.py new file mode 100644 index 0000000..259447f --- /dev/null +++ b/scripts/tests/test_portal_contracts.py @@ -0,0 +1,125 @@ +"""Check native portal wire contracts on a private bus without touching the desktop.""" +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +import unittest +import xml.etree.ElementTree as ET + +REPO = Path(__file__).resolve().parents[2] +BINARY = REPO / os.environ.get("FERESE_TEST_PORTAL_BINARY", "target/debug/xdg-desktop-portal-ferese") +NAME = "org.freedesktop.impl.portal.desktop.ferese" +PATH = "/org/freedesktop/portal/desktop" + + +class NativePortalContracts(unittest.TestCase): + def test_private_bus_contracts(self): + result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], + capture_output=True, text=True, timeout=45) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +def private_bus_checks(): + import gi + gi.require_version("Gio", "2.0") + from gi.repository import Gio, GLib + + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + + def call(interface, method, parameters, signature=None, destination=NAME): + return bus.call_sync(destination, PATH, interface, method, parameters, + GLib.VariantType.new(signature) if signature else None, + Gio.DBusCallFlags.NO_AUTO_START, 5000, None).unpack() + + with tempfile.TemporaryDirectory(prefix="ferese-portal-contracts-") as directory: + root = Path(directory) + config = root / "ferese/config.kdl" + config.parent.mkdir() + config.write_text('theme { colors { surface-base "#ffffff"; accent "#ff8000"; }; }; animations { reduced-motion #true; }\n') + log = (root / "backend.log").open("w") + process = subprocess.Popen([str(BINARY)], env=dict(os.environ, XDG_CONFIG_HOME=directory), + stdout=log, stderr=log, start_new_session=True) + try: + deadline = time.monotonic() + 15 + while True: + try: + xml, = call("org.freedesktop.DBus.Introspectable", "Introspect", None, "(s)") + break + except GLib.Error: + if time.monotonic() > deadline: + raise AssertionError((root / "backend.log").read_text()) + time.sleep(0.05) + node = ET.fromstring(xml) + for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper"): + interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']") + assert interface is not None, name + expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface") + for member in expected: + if member.tag not in ("method", "signal", "property"): + continue + actual = interface.find(f"{member.tag}[@name='{member.attrib['name']}']") + assert actual is not None, (name, member.tag, member.attrib["name"]) + if member.tag == "property": + assert actual.attrib["type"] == member.attrib["type"] + else: + def types(element, direction): + return [arg.attrib["type"] for arg in element.findall("arg") + if arg.attrib.get("direction", "out" if element.tag == "signal" else "in") == direction] + assert types(actual, "in") == types(member, "in"), (name, member.attrib["name"], "in") + assert types(actual, "out") == types(member, "out"), (name, member.attrib["name"], types(actual, "out"), types(member, "out")) + values, = call("org.freedesktop.impl.portal.Settings", "ReadAll", GLib.Variant("(as)", ([],)), "(a{sa{sv}})") + appearance = values["org.freedesktop.appearance"] + assert appearance["color-scheme"] == 2 and appearance["reduced-motion"] == 1, appearance + assert appearance["accent-color"][0] == 1. and appearance["accent-color"][2] == 0., appearance + result, = call("org.freedesktop.impl.portal.Settings", "ReadAll", GLib.Variant("(as)", (["other.*"],)), "(a{sa{sv}})") + assert result == {} + try: + call("org.freedesktop.impl.portal.Settings", "Read", GLib.Variant("(ss)", ("org.freedesktop.appearance", "missing"))) + raise AssertionError("Unknown Settings key succeeded") + except GLib.Error as error: + assert "InvalidArgs" in str(error) + + params = GLib.Variant("(ossa{sv})", (PATH + "/request/test/screenshot", "org.test.App", "", {"target": GLib.Variant("u", 99)})) + try: + call("org.freedesktop.impl.portal.Screenshot", "Screenshot", params) + raise AssertionError("Untrusted backend caller succeeded") + except GLib.Error: + pass + request_name = bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", + GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType.new("(u)"), + Gio.DBusCallFlags.NONE, 5000, None).unpack()[0] + assert request_name == 1 + response, values = call("org.freedesktop.impl.portal.Screenshot", "Screenshot", params, "(ua{sv})") + assert response == 2 and values == {} + + signals = [] + subscription = bus.signal_subscribe(NAME, "org.freedesktop.impl.portal.Settings", "SettingChanged", PATH, + None, Gio.DBusSignalFlags.NONE, lambda *args: signals.append(args[-1].unpack())) + temporary = config.with_suffix(".tmp") + temporary.write_text('theme { colors { surface-base "#111111"; accent "#0080ff"; }; }; animations { enabled #false; }\n') + temporary.replace(config) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not any(signal[1] == "color-scheme" for signal in signals): + GLib.MainContext.default().iteration(False) + time.sleep(0.02) + assert any(signal[1] == "color-scheme" and signal[2] == 1 for signal in signals), signals + assert any(signal[1] == "accent-color" for signal in signals), signals + config.write_text('theme { broken') + time.sleep(1.2) + scheme, = call("org.freedesktop.impl.portal.Settings", "Read", GLib.Variant("(ss)", ("org.freedesktop.appearance", "color-scheme"))) + assert scheme == 1, scheme + bus.signal_unsubscribe(subscription) + finally: + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=5) + log.close() + + +if __name__ == "__main__": + if sys.argv[1:] == ["--private"]: + private_bus_checks() + else: + unittest.main() diff --git a/shell/ferese-lock/src/appearance.rs b/shell/ferese-lock/src/appearance.rs index 8d0df76..7a0ed12 100644 --- a/shell/ferese-lock/src/appearance.rs +++ b/shell/ferese-lock/src/appearance.rs @@ -30,7 +30,8 @@ impl Appearance { }; let palette = ferese_theme::Palette::from_document(doc.as_ref()); let font = string("theme.typography.font_family", "Inter"); - let path = string("theme.background.path", ferese_config::default_wallpaper()); + let background = string("theme.background.path", ferese_config::default_wallpaper()); + let path = string("theme.background.lock_path", &background); let path = if let Some(tail) = path.strip_prefix("~/") { std::env::var("HOME") .map(|home| format!("{home}/{tail}"))