Add native appearance, screenshot and wallpaper portals

This commit is contained in:
Samuel Onoja committed 2026-09-29 22:42:27 +01:00
1 parent d8a23748c8
commit 915bc8afd6
20 files changed
+1575 -33

No files matched your search

Generated
+18 -1
View File
@@ -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",
]
+1
View File
@@ -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
+69
View File
@@ -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::<XdgToplevelSurfaceData>()
.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::<Vec<_>>();
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
+7 -1
View File
@@ -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",
+2 -2
View File
@@ -13,7 +13,7 @@ desktop instead of direct access to the graphics device.
Build with **Rust 1.95 or newer**, a C/C++ toolchain, CMake, pkg-config, and the
native development libraries below. The installer builds Ferese, its shell,
Settings, command-line tools, 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.
+80
View File
@@ -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.
+3 -1
View File
@@ -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.
+3 -3
View File
@@ -88,8 +88,8 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> 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<PathBuf, io::Error> {
}
fn usage() -> String {
"usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-workspaces|get-outputs|reload-config>".to_owned()
"usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
}
#[cfg(test)]
+3
View File
@@ -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;
+1 -1
View File
@@ -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;
+6 -3
View File
@@ -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
+29 -19
View File
@@ -25,8 +25,7 @@ use zbus::{
object_server::SignalEmitter,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
type Options = HashMap<String, OwnedValue>;
type Reply = (u32, Options);
pub(crate) type Options = HashMap<String, OwnedValue>;
#[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<String> {
pub(crate) async fn authorize(
connection: &Connection,
header: &Header<'_>,
) -> zbus::fdo::Result<String> {
let sender = header
.sender()
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?;
@@ -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<Reply> {
) -> 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<Reply> {
) -> 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<Reply> {
) -> 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<Cancel>,
owner: String,
pub(crate) struct Request {
pub(crate) cancel: Arc<Cancel>,
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<Command, String> {
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
pub(crate) fn child_command() -> Result<Command, String> {
child_command_for(std::env::current_exe().map_err(|e| e.to_string())?)
}
pub(crate) fn child_command_for(program: impl AsRef<std::ffi::OsStr>) -> Result<Command, String> {
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<Child>, cancel: Arc<Cancel>) {
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
.map(|name| name.to_string()),
Err(_) => None,
};
requests.revoke_stale(owner.as_deref()).await;
let stale: Vec<_> = backend
.sessions
.lock()
+183
View File
@@ -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<std::path::PathBuf>,
}
#[derive(Clone, Debug)]
enum Message {
Opened(cosmic::iced::window::Id),
Attached(
Result<
(
Option<std::sync::Arc<crate::parent::Parent>>,
Result<ferese_theme::material::ModalMaterial, String>,
),
String,
>,
),
Accept,
Cancel,
}
struct Consent {
core: Core,
prompt: Prompt,
material: Option<ferese_theme::material::ModalMaterial>,
parent: Option<std::sync::Arc<crate::parent::Parent>>,
palette: ferese_theme::Palette,
font: cosmic::font::Font,
background: cosmic::iced::Color,
}
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
std::io::stdin()
.take(64 * 1024)
.read_to_string(&mut input)?;
let prompt: Prompt = serde_json::from_str(&input)?;
let (theme, font, background, _) = crate::picker::appearance();
let height = if prompt.image.is_some() { 430. } else { 220. };
cosmic::app::run::<Consent>(
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<Message>) {
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<Message> {
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<Message> {
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<cosmic::iced::theme::Style> {
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()
}
}
+727
View File
@@ -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<Mutex<HashMap<String, (String, Arc<Cancel>)>>>);
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<Arc<Cancel>> {
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::<Request, _>(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<u32> {
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<Option<Options>, 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<PathBuf>,
) -> Result<bool, String> {
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<serde_json::Value, String> {
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<String>) -> Result<Option<String>, 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<String>) -> Result<Vec<u8>, 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<Option<Options>, 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<Option<Options>, 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<PathBuf, String> {
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<String, String> {
url::Url::from_file_path(path)
.map(String::from)
.map_err(|_| "Invalid file path".into())
}
fn local_path(uri: &str) -> Result<PathBuf, String> {
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<Cancel>,
) -> Result<Option<Options>, 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<tempfile::NamedTempFile, String> {
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")
);
}
}
+5
View File
@@ -1,6 +1,10 @@
mod backend;
mod capture;
mod consent;
mod desktop;
mod parent;
mod picker;
mod settings;
mod stream;
fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -16,6 +20,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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()),
}
+119
View File
@@ -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<Option<Self>, 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::<State>(&connection).map_err(|error| error.to_string())?;
let qh = queue.handle();
let importer = globals
.bind::<ZxdgImporterV2, _, _>(&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<wl_registry::WlRegistry, GlobalListContents> for State {
fn event(
_: &mut Self,
_: &wl_registry::WlRegistry,
_: wl_registry::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<ZxdgImportedV2, ()> for State {
fn event(
state: &mut Self,
_: &ZxdgImportedV2,
event: zxdg_imported_v2::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if matches!(event, zxdg_imported_v2::Event::Destroyed) {
state.invalid = true;
}
}
}
delegate_noop!(State: ignore ZxdgImporterV2);
+1 -1
View File
@@ -258,7 +258,7 @@ impl Picker {
}
}
fn appearance() -> (
pub(crate) fn appearance() -> (
cosmic::Theme,
cosmic::font::Font,
cosmic::iced::Color,
+191
View File
@@ -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<String, HashMap<String, OwnedValue>>;
#[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<String, OwnedValue> {
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<RwLock<Appearance>>);
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<String>) -> 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<OwnedValue> {
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<Appearance> {
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
);
}
}
+125
View File
@@ -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()
+2 -1
View File
@@ -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}"))