Add native appearance, screenshot and wallpaper portals
This commit is contained in:
1 parent
d8a23748c8
commit
915bc8afd6
20 files changed
+1575
-33
No files matched your search
Generated
+18
-1
@@ -2186,6 +2186,16 @@ dependencies = [
|
|||||||
"weezl",
|
"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]]
|
[[package]]
|
||||||
name = "gio-sys"
|
name = "gio-sys"
|
||||||
version = "0.21.5"
|
version = "0.21.5"
|
||||||
@@ -2921,6 +2931,8 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder-lite",
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"gif 0.14.2",
|
||||||
"image-webp",
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
@@ -4883,7 +4895,7 @@ version = "0.45.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
|
checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gif",
|
"gif 0.13.3",
|
||||||
"image-webp",
|
"image-webp",
|
||||||
"log",
|
"log",
|
||||||
"pico-args",
|
"pico-args",
|
||||||
@@ -7402,19 +7414,24 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"ferese-config",
|
"ferese-config",
|
||||||
|
"ferese-ipc",
|
||||||
"ferese-theme",
|
"ferese-theme",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"gstreamer",
|
"gstreamer",
|
||||||
|
"image",
|
||||||
"jiff",
|
"jiff",
|
||||||
"libc",
|
"libc",
|
||||||
"libcosmic",
|
"libcosmic",
|
||||||
"memmap2 0.9.11",
|
"memmap2 0.9.11",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"url",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
"wayland-protocols-wlr",
|
"wayland-protocols-wlr",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ Settings → Shortcuts** to change them or assign gestures to other actions.
|
|||||||
- [Desktop widgets](docs/desktop-widgets.md)
|
- [Desktop widgets](docs/desktop-widgets.md)
|
||||||
- [Notifications](docs/notifications.md)
|
- [Notifications](docs/notifications.md)
|
||||||
- [Native locker](docs/locking.md)
|
- [Native locker](docs/locking.md)
|
||||||
|
- [Desktop portals and compatibility](docs/portals.md)
|
||||||
- [Screen sharing and recording](docs/screen-sharing.md)
|
- [Screen sharing and recording](docs/screen-sharing.md)
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -341,6 +341,17 @@ impl Ferese {
|
|||||||
"expel" => self.expel_focused_window(),
|
"expel" => self.expel_focused_window(),
|
||||||
"close" => self.close_focused_window(),
|
"close" => self.close_focused_window(),
|
||||||
"get-focused-window" => return Ok(self.focused_window_json()),
|
"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-workspaces" => return Ok(self.workspaces_json()),
|
||||||
"get-outputs" => return Ok(self.outputs_json()),
|
"get-outputs" => return Ok(self.outputs_json()),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -506,6 +517,64 @@ impl Ferese {
|
|||||||
.unwrap_or(Value::Null)
|
.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 {
|
fn workspaces_json(&self) -> Value {
|
||||||
let active = self.workspaces.active_id();
|
let active = self.workspaces.active_id();
|
||||||
let workspaces = self
|
let workspaces = self
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ pub fn resolve(
|
|||||||
let mut result = WindowRuleResult::default();
|
let mut result = WindowRuleResult::default();
|
||||||
if matches!(
|
if matches!(
|
||||||
app_id.as_deref(),
|
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);
|
result.floating = Some(true);
|
||||||
}
|
}
|
||||||
@@ -217,6 +222,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn native_dialogs_float_by_default_and_allow_user_overrides() {
|
fn native_dialogs_float_by_default_and_allow_user_overrides() {
|
||||||
for app_id in [
|
for app_id in [
|
||||||
|
"dev.ferese.PortalDialog",
|
||||||
"dev.ferese.ScreenShare",
|
"dev.ferese.ScreenShare",
|
||||||
"dev.ferese.ScreenShare.desktop",
|
"dev.ferese.ScreenShare.desktop",
|
||||||
"dev.ferese.Authentication",
|
"dev.ferese.Authentication",
|
||||||
|
|||||||
@@ -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
|
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,
|
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
|
packages or enable background services at boot. On systemd systems, the installed
|
||||||
session helper activates `ferese-session.target` after exporting the compositor's
|
session helper activates `ferese-session.target` after exporting the compositor's
|
||||||
Wayland display, and stops it when the shell exits. This keeps
|
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`,
|
- Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`,
|
||||||
`feresectl`, `ferese-lock`, `ferese-polkit-agent`, `ferese-screenshot`, and
|
`feresectl`, `ferese-lock`, `ferese-polkit-agent`, `ferese-screenshot`, and
|
||||||
`xdg-desktop-portal-ferese`, and `ferese-record`.
|
`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 Wayland session entry at `/usr/share/wayland-sessions/ferese.desktop`.
|
||||||
- A Settings application entry, icons, the default wallpaper, and an example config.
|
- A Settings application entry, icons, the default wallpaper, and an example config.
|
||||||
- `/etc/pam.d/ferese-lock`, using your distribution's authentication stack.
|
- `/etc/pam.d/ferese-lock`, using your distribution's authentication stack.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
D-Bus activation entry, and a Ferese-specific portal preference file. Install
|
||||||
`pipewire`, a PipeWire session manager such as `wireplumber`,
|
`pipewire`, a PipeWire session manager such as `wireplumber`,
|
||||||
`xdg-desktop-portal`, and `xdg-desktop-portal-gtk` using your distribution's package
|
`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
|
The built-in recorder also needs GStreamer’s PipeWire, base, and good plugins
|
||||||
(`pipewiresrc`, `videoconvert`, `vp8enc`, and `webmmux`). The installation guide
|
(`pipewiresrc`, `videoconvert`, `vp8enc`, and `webmmux`). The installation guide
|
||||||
includes these packages.
|
includes these packages.
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
|
|||||||
},
|
},
|
||||||
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
|
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
|
||||||
| "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel"
|
| "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel"
|
||||||
| "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "reload-config"
|
| "close" | "get-focused-window" | "get-windows" | "get-workspaces" | "get-outputs"
|
||||||
| "exit" | "request-logout" => {
|
| "reload-config" | "exit" | "request-logout" => {
|
||||||
if !positional.is_empty() {
|
if !positional.is_empty() {
|
||||||
return Err(format!("{command} does not accept arguments"));
|
return Err(format!("{command} does not accept arguments"));
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
[preferred]
|
[preferred]
|
||||||
default=gtk;
|
default=gtk;
|
||||||
org.freedesktop.impl.portal.ScreenCast=ferese;
|
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,4 +1,4 @@
|
|||||||
[portal]
|
[portal]
|
||||||
DBusName=org.freedesktop.impl.portal.desktop.ferese
|
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;
|
UseIn=Ferese;
|
||||||
+6
-3
@@ -14,6 +14,11 @@ name = "ferese-record"
|
|||||||
path = "src/record_main.rs"
|
path = "src/record_main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[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" }
|
ferese-theme = { path = "../crates/ferese-theme" }
|
||||||
gstreamer = "0.24"
|
gstreamer = "0.24"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
@@ -28,8 +33,6 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util"] }
|
||||||
wayland-client.workspace = true
|
wayland-client.workspace = true
|
||||||
|
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
||||||
wayland-protocols-wlr.workspace = true
|
wayland-protocols-wlr.workspace = true
|
||||||
zbus = { version = "5.19.0", default-features = false, features = ["tokio"] }
|
zbus = { version = "5.19.0", default-features = false, features = ["tokio"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile.workspace = true
|
|
||||||
+29
-19
@@ -25,8 +25,7 @@ use zbus::{
|
|||||||
object_server::SignalEmitter,
|
object_server::SignalEmitter,
|
||||||
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
||||||
};
|
};
|
||||||
type Options = HashMap<String, OwnedValue>;
|
pub(crate) type Options = HashMap<String, OwnedValue>;
|
||||||
type Reply = (u32, Options);
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum StartError {
|
enum StartError {
|
||||||
Cancelled,
|
Cancelled,
|
||||||
@@ -45,16 +44,16 @@ impl From<&str> for StartError {
|
|||||||
const FRONTEND: &str = "org.freedesktop.portal.Desktop";
|
const FRONTEND: &str = "org.freedesktop.portal.Desktop";
|
||||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Cancel {
|
pub(crate) struct Cancel {
|
||||||
stopped: AtomicBool,
|
pub(crate) stopped: AtomicBool,
|
||||||
changed: Notify,
|
changed: Notify,
|
||||||
}
|
}
|
||||||
impl Cancel {
|
impl Cancel {
|
||||||
fn stop(&self) {
|
pub(crate) fn stop(&self) {
|
||||||
self.stopped.store(true, Ordering::SeqCst);
|
self.stopped.store(true, Ordering::SeqCst);
|
||||||
self.changed.notify_one();
|
self.changed.notify_one();
|
||||||
}
|
}
|
||||||
async fn wait(&self) {
|
pub(crate) async fn wait(&self) {
|
||||||
if !self.stopped.load(Ordering::SeqCst) {
|
if !self.stopped.load(Ordering::SeqCst) {
|
||||||
self.changed.notified().await;
|
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))
|
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
|
let sender = header
|
||||||
.sender()
|
.sender()
|
||||||
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?;
|
.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?;
|
let owner = dbus.get_name_owner(FRONTEND.try_into().unwrap()).await?;
|
||||||
if sender.as_str() != owner.as_str() {
|
if sender.as_str() != owner.as_str() {
|
||||||
return Err(zbus::fdo::Error::AccessDenied(
|
return Err(zbus::fdo::Error::AccessDenied(
|
||||||
"Use the ScreenCast desktop portal".into(),
|
"Use the desktop portal".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(sender.to_string())
|
Ok(sender.to_string())
|
||||||
@@ -230,7 +232,7 @@ impl Backend {
|
|||||||
_options: Options,
|
_options: Options,
|
||||||
#[zbus(connection)] connection: &Connection,
|
#[zbus(connection)] connection: &Connection,
|
||||||
#[zbus(header)] header: Header<'_>,
|
#[zbus(header)] header: Header<'_>,
|
||||||
) -> zbus::fdo::Result<Reply> {
|
) -> zbus::fdo::Result<(u32, Options)> {
|
||||||
let owner = authorize(connection, &header).await?;
|
let owner = authorize(connection, &header).await?;
|
||||||
if !session_handle
|
if !session_handle
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -281,7 +283,7 @@ impl Backend {
|
|||||||
options: Options,
|
options: Options,
|
||||||
#[zbus(connection)] connection: &Connection,
|
#[zbus(connection)] connection: &Connection,
|
||||||
#[zbus(header)] header: Header<'_>,
|
#[zbus(header)] header: Header<'_>,
|
||||||
) -> zbus::fdo::Result<Reply> {
|
) -> zbus::fdo::Result<(u32, Options)> {
|
||||||
let owner = authorize(connection, &header).await?;
|
let owner = authorize(connection, &header).await?;
|
||||||
let settings = source_options(&options);
|
let settings = source_options(&options);
|
||||||
let mut sessions = self.sessions.lock().await;
|
let mut sessions = self.sessions.lock().await;
|
||||||
@@ -318,7 +320,7 @@ impl Backend {
|
|||||||
_options: Options,
|
_options: Options,
|
||||||
#[zbus(connection)] connection: &Connection,
|
#[zbus(connection)] connection: &Connection,
|
||||||
#[zbus(header)] header: Header<'_>,
|
#[zbus(header)] header: Header<'_>,
|
||||||
) -> zbus::fdo::Result<Reply> {
|
) -> zbus::fdo::Result<(u32, Options)> {
|
||||||
let owner = authorize(connection, &header).await?;
|
let owner = authorize(connection, &header).await?;
|
||||||
if !handle
|
if !handle
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -413,9 +415,9 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct Request {
|
pub(crate) struct Request {
|
||||||
cancel: Arc<Cancel>,
|
pub(crate) cancel: Arc<Cancel>,
|
||||||
owner: String,
|
pub(crate) owner: String,
|
||||||
}
|
}
|
||||||
#[zbus::interface(name = "org.freedesktop.impl.portal.Request")]
|
#[zbus::interface(name = "org.freedesktop.impl.portal.Request")]
|
||||||
impl Request {
|
impl Request {
|
||||||
@@ -449,8 +451,12 @@ impl SessionObject {
|
|||||||
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
|
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
|
||||||
}
|
}
|
||||||
// All helpers die with the service, including a picker awaiting user input.
|
// All helpers die with the service, including a picker awaiting user input.
|
||||||
fn child_command() -> Result<Command, String> {
|
pub(crate) fn child_command() -> Result<Command, String> {
|
||||||
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_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;
|
let parent = std::process::id() as libc::pid_t;
|
||||||
// SAFETY: only async-signal-safe libc calls run between fork and exec.
|
// SAFETY: only async-signal-safe libc calls run between fork and exec.
|
||||||
unsafe {
|
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>> {
|
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let backend = Backend::default();
|
let backend = Backend::default();
|
||||||
|
let settings = crate::settings::Settings::new();
|
||||||
|
let requests = crate::desktop::Requests::default();
|
||||||
let connection = zbus::connection::Builder::session()?
|
let connection = zbus::connection::Builder::session()?
|
||||||
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
||||||
.serve_at(PATH, backend.clone())?
|
.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(
|
.serve_at(
|
||||||
"/org/ferese/ScreenRecorder",
|
"/org/ferese/ScreenRecorder",
|
||||||
RecorderControl(backend.clone()),
|
RecorderControl(backend.clone()),
|
||||||
)?
|
)?
|
||||||
.build()
|
.build()
|
||||||
.await?;
|
.await?;
|
||||||
|
tokio::spawn(settings.watch(connection.clone()));
|
||||||
// Frontend death must revoke every stream, even if Session.Close never arrives.
|
// Frontend death must revoke every stream, even if Session.Close never arrives.
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
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 {
|
let owner = match zbus::fdo::DBusProxy::new(&connection).await {
|
||||||
Ok(proxy) => proxy
|
Ok(proxy) => proxy
|
||||||
.get_name_owner(FRONTEND.try_into().unwrap())
|
.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()),
|
.map(|name| name.to_string()),
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
|
requests.revoke_stale(owner.as_deref()).await;
|
||||||
let stale: Vec<_> = backend
|
let stale: Vec<_> = backend
|
||||||
.sessions
|
.sessions
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
mod capture;
|
mod capture;
|
||||||
|
mod consent;
|
||||||
|
mod desktop;
|
||||||
|
mod parent;
|
||||||
mod picker;
|
mod picker;
|
||||||
|
mod settings;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -16,6 +20,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
[flag] if flag == "--picker" => picker::run(),
|
[flag] if flag == "--picker" => picker::run(),
|
||||||
|
[flag] if flag == "--consent" => consent::run(),
|
||||||
[] => tokio::runtime::Runtime::new()?.block_on(backend::run()),
|
[] => tokio::runtime::Runtime::new()?.block_on(backend::run()),
|
||||||
_ => Err("Usage: xdg-desktop-portal-ferese [--sources]".into()),
|
_ => Err("Usage: xdg-desktop-portal-ferese [--sources]".into()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -258,7 +258,7 @@ impl Picker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn appearance() -> (
|
pub(crate) fn appearance() -> (
|
||||||
cosmic::Theme,
|
cosmic::Theme,
|
||||||
cosmic::font::Font,
|
cosmic::font::Font,
|
||||||
cosmic::iced::Color,
|
cosmic::iced::Color,
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -30,7 +30,8 @@ impl Appearance {
|
|||||||
};
|
};
|
||||||
let palette = ferese_theme::Palette::from_document(doc.as_ref());
|
let palette = ferese_theme::Palette::from_document(doc.as_ref());
|
||||||
let font = string("theme.typography.font_family", "Inter");
|
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("~/") {
|
let path = if let Some(tail) = path.strip_prefix("~/") {
|
||||||
std::env::var("HOME")
|
std::env::var("HOME")
|
||||||
.map(|home| format!("{home}/{tail}"))
|
.map(|home| format!("{home}/{tail}"))
|
||||||
|
|||||||
Reference in new issue
Block a user