Settings and Inhibit portal backends
git clone https://git.lucas.co/cce-desktop-portal.git
Add the Settings and Inhibit portal backends and org.freedesktop.ScreenSaver
Both portal interfaces fell to xdg-desktop-portal-gtk. Its Settings
published GNOME's GSettings rather than cce's config, and its Inhibit
forwarded to org.gnome.SessionManager / org.freedesktop.ScreenSaver,
neither of which a cce session runs, so every inhibit request silently
did nothing.
Settings serves org.freedesktop.appearance from config.kdl (color_scheme,
the highlight colour as accent) and the animations switch (reduced-motion),
pushed by inotify; the frontend merges it ahead of gtk's org.gnome.* keys.
Inhibit and ScreenSaver hold renewed leases in the compositor's idle
manager (cce-compositor 84fa49a9), released on close, UnInhibit, or the
caller leaving the bus, and lapsing within a minute if this process dies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.gitignore | 1 +
CLAUDE.md | 81 ++
Cargo.lock | 1114 ++++++++++++++++++++
Cargo.toml | 15 +
Makefile | 16 +
dbus/org.freedesktop.ScreenSaver.service | 7 +
...desktop.impl.portal.desktop.cce-desktop.service | 6 +
portals/cce-desktop.portal | 4 +
src/inhibit.rs | 363 +++++++
src/main.rs | 101 ++
src/settings.rs | 297 ++++++
11 files changed, 2005 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..f5f6f24
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,81 @@
+# CLAUDE.md
+
+Read `../cce-compositor/WORKSPACE.md` first: this crate is one member of the
+cce workspace and follows its multi-repo, `ccebuild` and concurrent-session
+rules.
+
+## What this is
+
+`cce-desktop-portal` is cce's own backend for two portal interfaces that
+used to fall to xdg-desktop-portal-gtk through `default=gtk`, plus the
+`org.freedesktop.ScreenSaver` service apps call directly:
+
+- **`org.freedesktop.impl.portal.Settings`** (`src/settings.rs`) — the
+ `org.freedesktop.appearance` namespace from cce's config instead of GNOME's
+ GSettings: `color-scheme` from `style { color_scheme }` (absent = dark),
+ `accent-color` from `style { highlight primary }`, `reduced-motion` from the
+ animations switch `/run/cce/animations`, `contrast` always normal. Only that
+ namespace: the frontend merges every backend `cce-portals.conf` lists for
+ Settings (`cce-desktop;gtk`), first answer per key wins, and an unknown key
+ must fail with `org.freedesktop.portal.Error.NotFound` so the frontend
+ moves on to gtk for the `org.gnome.*` keys sandboxed GTK apps read.
+ Changes are pushed by inotify on the two directories (not the files —
+ config.kdl is replaced by rename), and only keys whose value moved signal.
+- **`org.freedesktop.impl.portal.Inhibit`** and **`org.freedesktop.ScreenSaver`**
+ (`src/inhibit.rs`) — keeping the display awake for apps that ask over
+ D-Bus. gtk's Inhibit forwarded to `org.gnome.SessionManager` /
+ `org.freedesktop.ScreenSaver`, neither of which a cce session runs, so every
+ request was accepted and did nothing. Each holder here becomes a **lease**
+ in the compositor: `idle inhibit <token> <ttl_s> <who>` on the control
+ socket (`src/server/idle.rs` in cce-compositor is the other half). Leases
+ lapse unless renewed, so the backend renews every live one every 20 s
+ against a 60 s ttl; if it dies, the compositor drops them within a minute.
+ A hold ends when the portal request is closed (the frontend does that when
+ the app exits), on `UnInhibit`, or — ScreenSaver — when the caller's bus
+ connection goes away. Only the portal's idle (8) and suspend (4) flags
+ hold anything; logout and user-switch have nothing to hold in cce.
+
+`idle status` names holders as `portal:<who>`.
+
+## Lifecycle
+
+One process, bus-activated by either name (two files in `dbus/`); whichever
+is asked for first starts it and it claims both. A racing second activation
+finds the portal name taken and exits. It begins with `idle inhibit-clear`
+(a predecessor's leases die now rather than at their ttl; an older
+compositor answers an error, and leases then wait for a newer one — they
+are still tracked and sent on every renew). It lives for the session:
+Settings must be there to signal changes. At rest it does nothing — inotify
+for the config, and the renew loop sleeps while nothing is held.
+
+Reads `WAYLAND_DISPLAY` for the control socket path exactly as `ccectl`
+does, so the bus activation environment must carry it (startcce imports it).
+
+## Wiring
+
+- `portals/cce-desktop.portal` → `$XDG_DATA_HOME/xdg-desktop-portal/portals/`
+- `dbus/*.service` → `~/.local/share/dbus-1/services/` (absolute `Exec`)
+- **`~/.config/xdg-desktop-portal/cce-portals.conf`** (user config, not
+ versioned) must say `org.freedesktop.impl.portal.Settings=cce-desktop;gtk`
+ and `org.freedesktop.impl.portal.Inhibit=cce-desktop`.
+
+The frontend reads portal files and the conf at startup. Restarting it
+(`systemctl --user restart xdg-desktop-portal`) drops every app's portal
+sessions — 1Password's global shortcut among them, until it is restarted —
+so prefer letting the next login pick changes up.
+
+## Verifying
+
+The session bus is shared with the live session (a shadow has none of its
+own), so check first that `org.freedesktop.ScreenSaver` and the portal name
+are unowned, run the backend through `cce-shadow run` so its control socket
+is the shadow's, and kill it after. Leases can be driven directly with
+`cce-shadow ctl idle inhibit t1 2 tester` (watch it lapse in `idle status`).
+A ScreenSaver holder that must outlive one call needs a client that keeps
+its connection (`busctl call` exits at once and its hold is released with
+it — correctly). To test the frontend's Settings merge without restarting
+the live one, run a private `dbus-run-session` with this backend,
+`/usr/lib/xdg-desktop-portal-gtk` and `xdg-desktop-portal -r` under an
+`XDG_CONFIG_HOME` holding a test `cce-portals.conf`, and kill everything
+the private bus activated afterwards (cce-shortcuts-portal attaches to the
+shadow and keeps `dbus-run-session` alive).
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..75d2984
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1114 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cce-desktop-portal"
+version = "0.1.0"
+dependencies = [
+ "env_logger",
+ "futures-util",
+ "inotify",
+ "kdl",
+ "log",
+ "tokio",
+ "zbus",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "env_filter"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "inotify"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
+dependencies = [
+ "bitflags 2.13.2",
+ "futures-util",
+ "inotify-sys",
+ "libc",
+ "tokio",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "jiff"
+version = "0.2.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
+dependencies = [
+ "defmt",
+ "log",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kdl"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e03e2e96c5926fe761088d66c8c2aee3a4352a2573f4eaca50043ad130af9117"
+dependencies = [
+ "miette",
+ "nom",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miette"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
+dependencies = [
+ "miette-derive",
+ "once_cell",
+ "thiserror 1.0.69",
+ "unicode-width",
+]
+
+[[package]]
+name = "miette-derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "tracing",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.15+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-recursion",
+ "async-trait",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tokio",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zvariant"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.6",
+ "winnow",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..46aa376
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "cce-desktop-portal"
+version = "0.1.0"
+edition = "2021"
+description = "Settings and Inhibit portal backends, and org.freedesktop.ScreenSaver, for the cce desktop"
+license = "GPL-3.0-only"
+
+[dependencies]
+zbus = { version = "5", default-features = false, features = ["tokio"] }
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
+futures-util = { version = "0.3", default-features = false }
+inotify = "0.11"
+kdl = "4.6"
+log = "0.4"
+env_logger = "0.11"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6353746
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,16 @@
+.PHONY: build install run clean
+
+build:
+ cargo build --release
+
+# Binaries, the dbus/ activation files and the portals/ declaration are all
+# enumerated by ccebuild, so nothing is named here.
+install: build
+ @command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
+ ccebuild install --no-build cce-desktop-portal
+
+run:
+ cargo run
+
+clean:
+ cargo clean
diff --git a/dbus/org.freedesktop.ScreenSaver.service b/dbus/org.freedesktop.ScreenSaver.service
new file mode 100644
index 0000000..62bfdaf
--- /dev/null
+++ b/dbus/org.freedesktop.ScreenSaver.service
@@ -0,0 +1,7 @@
+# Bus activation for org.freedesktop.ScreenSaver, which apps (Steam, browsers,
+# video players) call directly to keep the display awake. The same process
+# serves the portal backend; whichever name is asked for first starts it, and
+# it claims both.
+[D-BUS Service]
+Name=org.freedesktop.ScreenSaver
+Exec=/home/lsgalante/.local/bin/cce-desktop-portal
diff --git a/dbus/org.freedesktop.impl.portal.desktop.cce-desktop.service b/dbus/org.freedesktop.impl.portal.desktop.cce-desktop.service
new file mode 100644
index 0000000..00c380f
--- /dev/null
+++ b/dbus/org.freedesktop.impl.portal.desktop.cce-desktop.service
@@ -0,0 +1,6 @@
+# Bus activation for the Settings and Inhibit portal backends: the portal
+# frontend reads Settings from every backend at startup, which starts this.
+# Exec must be absolute (D-Bus expands nothing).
+[D-BUS Service]
+Name=org.freedesktop.impl.portal.desktop.cce-desktop
+Exec=/home/lsgalante/.local/bin/cce-desktop-portal
diff --git a/portals/cce-desktop.portal b/portals/cce-desktop.portal
new file mode 100644
index 0000000..7a0e919
--- /dev/null
+++ b/portals/cce-desktop.portal
@@ -0,0 +1,4 @@
+[portal]
+DBusName=org.freedesktop.impl.portal.desktop.cce-desktop
+Interfaces=org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Inhibit;
+UseIn=cce;
diff --git a/src/inhibit.rs b/src/inhibit.rs
new file mode 100644
index 0000000..94f9bd1
--- /dev/null
+++ b/src/inhibit.rs
@@ -0,0 +1,363 @@
+//! Keeping the display awake for apps that ask over D-Bus rather than with a
+//! Wayland `idle-inhibit` surface: the portal's `Inhibit` interface (what
+//! sandboxed and portal-aware apps call) and `org.freedesktop.ScreenSaver`
+//! (what Steam, browsers and video players call directly).
+//!
+//! Before this, the portal's Inhibit went to xdg-desktop-portal-gtk, which
+//! hands it to `org.gnome.SessionManager` or `org.freedesktop.ScreenSaver` —
+//! and nothing on a cce session bus owns either, so every request was
+//! accepted and did nothing.
+//!
+//! Every holder becomes a **lease** in the compositor (`idle inhibit <token>
+//! <ttl_s> <who>` on the control socket; `idle.rs` in cce-compositor). Leases
+//! lapse unless renewed, so this process renews every live one well inside
+//! the ttl; if it dies, the compositor drops them within [`LEASE_TTL_S`]
+//! instead of keeping the display on for good. A holder ends with its request
+//! being closed (portal), `UnInhibit` (ScreenSaver), or its D-Bus connection
+//! going away (both — a crashed player releases what it held).
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::sync::{Mutex, Notify};
+use zbus::message::Header;
+use zbus::names::{OwnedUniqueName, UniqueName};
+use zbus::object_server::SignalEmitter;
+use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, Value};
+use zbus::{interface, Connection, ObjectServer};
+
+/// A lease's lifetime in the compositor, and how often live ones are renewed.
+pub const LEASE_TTL_S: u64 = 60;
+const RENEW_EVERY: std::time::Duration = std::time::Duration::from_secs(20);
+
+/// Portal Inhibit flags (the spec's bitmask). Only these two are about the
+/// idle timers; logout and user-switch have nothing to hold in cce.
+const FLAG_SUSPEND: u32 = 4;
+const FLAG_IDLE: u32 = 8;
+
+// ── compositor control socket ──────────────────────────────────────────────
+
+fn ctl_socket_path() -> String {
+ match std::env::var("WAYLAND_DISPLAY").ok().filter(|d| !d.is_empty()) {
+ Some(d) => format!("/tmp/cce-{d}.sock"),
+ None => "/tmp/cce.sock".to_string(),
+ }
+}
+
+/// One request/reply round; the compositor answers one line and closes.
+pub async fn ctl(cmd: &str) -> std::io::Result<String> {
+ let mut stream = tokio::net::UnixStream::connect(ctl_socket_path()).await?;
+ stream.write_all(format!("{cmd}\n").as_bytes()).await?;
+ let mut reply = String::new();
+ stream.read_to_string(&mut reply).await?;
+ Ok(reply)
+}
+
+/// The control socket splits on whitespace and reads one line: a holder's
+/// name keeps its words but loses anything that could end the line.
+pub fn sanitize_who(who: &str) -> String {
+ let cleaned: String = who.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
+ let cleaned = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
+ if cleaned.is_empty() { "unknown".to_string() } else { cleaned }
+}
+
+// ── the registry ───────────────────────────────────────────────────────────
+
+#[derive(Clone, Debug)]
+struct Holder {
+ who: String,
+ /// The D-Bus connection that asked; its disappearance ends the hold.
+ owner: Option<OwnedUniqueName>,
+}
+
+#[derive(Default)]
+pub struct Registry {
+ holders: HashMap<String, Holder>,
+ next_cookie: u32,
+ /// The compositor refused `idle inhibit` (too old): logged once, and
+ /// every hold is still tracked so a newer compositor gets them on renew.
+ unsupported_logged: bool,
+}
+
+#[derive(Clone)]
+pub struct Inhibitor {
+ reg: Arc<Mutex<Registry>>,
+ wake: Arc<Notify>,
+}
+
+impl Inhibitor {
+ pub fn new() -> Self {
+ Inhibitor { reg: Arc::new(Mutex::new(Registry::default())), wake: Arc::new(Notify::new()) }
+ }
+
+ async fn grant(&self, token: String, who: String, owner: Option<OwnedUniqueName>) {
+ let who = sanitize_who(&who);
+ log::info!("inhibit {token} for {who}");
+ self.reg.lock().await.holders.insert(token.clone(), Holder { who: who.clone(), owner });
+ self.send_lease(&token, &who).await;
+ self.wake.notify_one();
+ }
+
+ async fn release(&self, token: &str) {
+ if self.reg.lock().await.holders.remove(token).is_some() {
+ log::info!("release {token}");
+ if let Err(e) = ctl(&format!("idle uninhibit {token}")).await {
+ log::warn!("control socket: {e}");
+ }
+ }
+ }
+
+ async fn send_lease(&self, token: &str, who: &str) {
+ match ctl(&format!("idle inhibit {token} {LEASE_TTL_S} {who}")).await {
+ Ok(reply) if reply.starts_with("ok") => {}
+ Ok(reply) => {
+ let mut reg = self.reg.lock().await;
+ if !reg.unsupported_logged {
+ reg.unsupported_logged = true;
+ log::warn!("compositor refused the lease ({}); it predates external inhibitors — they take effect after a restart into a newer cce-fx", reply.trim());
+ }
+ }
+ Err(e) => log::warn!("control socket {}: {e}", ctl_socket_path()),
+ }
+ }
+
+ /// Renew every live lease well inside its ttl; sleep while there are none.
+ pub async fn renew_loop(self) {
+ loop {
+ let live: Vec<(String, String)> =
+ self.reg.lock().await.holders.iter().map(|(t, h)| (t.clone(), h.who.clone())).collect();
+ if live.is_empty() {
+ self.wake.notified().await;
+ continue;
+ }
+ tokio::time::sleep(RENEW_EVERY).await;
+ for (token, who) in live {
+ // Released while we slept: renewing would resurrect it.
+ if self.reg.lock().await.holders.contains_key(&token) {
+ self.send_lease(&token, &who).await;
+ }
+ }
+ }
+ }
+
+ /// Drop every hold a vanished D-Bus connection left behind.
+ pub async fn owner_gone(&self, name: &UniqueName<'_>) {
+ let tokens: Vec<String> = self
+ .reg
+ .lock()
+ .await
+ .holders
+ .iter()
+ .filter(|(_, h)| h.owner.as_ref().is_some_and(|o| o.as_str() == name.as_str()))
+ .map(|(t, _)| t.clone())
+ .collect();
+ for token in tokens {
+ log::info!("{name} left the bus");
+ self.release(&token).await;
+ }
+ }
+
+ async fn next_cookie(&self) -> u32 {
+ let mut reg = self.reg.lock().await;
+ reg.next_cookie = reg.next_cookie.wrapping_add(1).max(1);
+ reg.next_cookie
+ }
+}
+
+/// The portal request path is unique per request; a lease token must be one
+/// whitespace-free word, so it is the path with its slashes swapped.
+pub fn portal_token(handle: &str) -> String {
+ format!("portal{}", handle.replace('/', "-"))
+}
+
+// ── org.freedesktop.impl.portal.Inhibit ────────────────────────────────────
+
+pub struct InhibitPortal {
+ pub inhibitor: Inhibitor,
+}
+
+#[interface(name = "org.freedesktop.impl.portal.Inhibit")]
+impl InhibitPortal {
+ #[zbus(property, name = "version")]
+ fn version(&self) -> u32 {
+ 3
+ }
+
+ async fn inhibit(
+ &self,
+ #[zbus(object_server)] server: &ObjectServer,
+ handle: ObjectPath<'_>,
+ app_id: String,
+ _window: String,
+ flags: u32,
+ options: HashMap<String, OwnedValue>,
+ ) {
+ let reason = match options.get("reason").map(|v| &**v) {
+ Some(Value::Str(s)) => s.to_string(),
+ _ => String::new(),
+ };
+ let token = portal_token(handle.as_str());
+ log::info!("portal Inhibit {handle} app={app_id:?} flags={flags} reason={reason:?}");
+ if flags & (FLAG_IDLE | FLAG_SUSPEND) != 0 {
+ let who = if app_id.is_empty() { "portal-app".to_string() } else { app_id.clone() };
+ // No owner to watch: the caller is the portal frontend, not the
+ // app, and the frontend closes the request when the app goes.
+ self.inhibitor.grant(token.clone(), who, None).await;
+ }
+ let request = RequestObj { token, inhibitor: self.inhibitor.clone(), path: handle.clone().into() };
+ if let Err(e) = server.at(&handle, request).await {
+ log::warn!("exporting request {handle}: {e}");
+ }
+ }
+
+ async fn create_monitor(
+ &self,
+ #[zbus(object_server)] server: &ObjectServer,
+ #[zbus(connection)] conn: &Connection,
+ _handle: ObjectPath<'_>,
+ session_handle: ObjectPath<'_>,
+ app_id: String,
+ _window: String,
+ ) -> u32 {
+ log::info!("CreateMonitor {session_handle} for {app_id:?}");
+ let path: OwnedObjectPath = session_handle.clone().into();
+ if let Err(e) = server.at(&path, MonitorSession { path: path.clone() }).await {
+ log::warn!("exporting monitor {path}: {e}");
+ return 2;
+ }
+ // The initial state: running, screensaver off. cce has no
+ // query-end, so it never changes from here.
+ let conn = conn.clone();
+ tokio::spawn(async move {
+ if let Ok(emitter) = SignalEmitter::new(&conn, "/org/freedesktop/portal/desktop") {
+ let state = HashMap::from([
+ ("screensaver-active", Value::from(false)),
+ ("session-state", Value::from(1u32)),
+ ]);
+ let _ = InhibitPortal::state_changed(&emitter, path.as_ref(), state).await;
+ }
+ });
+ 0
+ }
+
+ async fn query_end_response(&self, _session_handle: ObjectPath<'_>) {}
+
+ #[zbus(signal)]
+ async fn state_changed(emitter: &SignalEmitter<'_>, session_handle: ObjectPath<'_>, state: HashMap<&str, Value<'_>>) -> zbus::Result<()>;
+}
+
+/// `org.freedesktop.impl.portal.Request` at the handle the frontend chose:
+/// its `Close` is how the app (or the frontend, when the app exits) ends the
+/// inhibition.
+struct RequestObj {
+ token: String,
+ inhibitor: Inhibitor,
+ path: OwnedObjectPath,
+}
+
+#[interface(name = "org.freedesktop.impl.portal.Request")]
+impl RequestObj {
+ async fn close(&self, #[zbus(connection)] conn: &Connection) {
+ self.inhibitor.release(&self.token).await;
+ remove_later::<RequestObj>(conn, self.path.clone());
+ }
+}
+
+struct MonitorSession {
+ path: OwnedObjectPath,
+}
+
+#[interface(name = "org.freedesktop.impl.portal.Session")]
+impl MonitorSession {
+ #[zbus(property, name = "version")]
+ fn version(&self) -> u32 {
+ 1
+ }
+
+ async fn close(&self, #[zbus(connection)] conn: &Connection) {
+ remove_later::<MonitorSession>(conn, self.path.clone());
+ }
+
+ #[zbus(signal)]
+ async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
+}
+
+/// An object cannot remove itself inside its own method call (the server
+/// holds it for the call), so it goes on the next turn of the loop.
+fn remove_later<I: zbus::object_server::Interface>(conn: &Connection, path: OwnedObjectPath) {
+ let conn = conn.clone();
+ tokio::spawn(async move {
+ let _ = conn.object_server().remove::<I, _>(&path).await;
+ });
+}
+
+// ── org.freedesktop.ScreenSaver ────────────────────────────────────────────
+
+pub struct ScreenSaver {
+ pub inhibitor: Inhibitor,
+}
+
+#[interface(name = "org.freedesktop.ScreenSaver")]
+impl ScreenSaver {
+ async fn inhibit(&self, #[zbus(header)] header: Header<'_>, application_name: String, reason_for_inhibit: String) -> u32 {
+ let cookie = self.inhibitor.next_cookie().await;
+ let owner = header.sender().map(|s| OwnedUniqueName::from(s.to_owned()));
+ log::info!("ScreenSaver.Inhibit {cookie} app={application_name:?} reason={reason_for_inhibit:?} from {owner:?}");
+ self.inhibitor.grant(format!("screensaver-{cookie}"), application_name, owner).await;
+ cookie
+ }
+
+ async fn un_inhibit(&self, cookie: u32) {
+ self.inhibitor.release(&format!("screensaver-{cookie}")).await;
+ }
+
+ /// Activity, as if the user touched the input: resets both idle timers
+ /// and wakes darkened displays. Older players call this on a timer
+ /// instead of inhibiting.
+ async fn simulate_user_activity(&self) {
+ if let Err(e) = ctl("idle wake").await {
+ log::warn!("control socket: {e}");
+ }
+ }
+
+ async fn lock(&self) {
+ if let Err(e) = ctl("lock").await {
+ log::warn!("control socket: {e}");
+ }
+ }
+
+ fn get_active(&self) -> bool {
+ false
+ }
+
+ fn get_active_time(&self) -> u32 {
+ 0
+ }
+
+ fn get_session_idle_time(&self) -> u32 {
+ 0
+ }
+
+ #[zbus(signal)]
+ async fn active_changed(emitter: &SignalEmitter<'_>, active: bool) -> zbus::Result<()>;
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn who_is_one_line_of_words() {
+ assert_eq!(sanitize_who("Steam"), "Steam");
+ assert_eq!(sanitize_who(" Firefox \n video\tplayback "), "Firefox video playback");
+ assert_eq!(sanitize_who("\n\t"), "unknown");
+ }
+
+ #[test]
+ fn portal_tokens_are_one_word() {
+ let t = portal_token("/org/freedesktop/portal/desktop/request/1_42/t0k3n");
+ assert!(!t.contains(char::is_whitespace) && !t.contains('/'));
+ assert_eq!(t, "portal-org-freedesktop-portal-desktop-request-1_42-t0k3n");
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..fc1ebd6
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,101 @@
+//! cce-desktop-portal — the cce desktop's own `Settings` and `Inhibit` portal
+//! backends, and `org.freedesktop.ScreenSaver`.
+//!
+//! Both interfaces used to fall to xdg-desktop-portal-gtk (`default=gtk` in
+//! `cce-portals.conf`): Settings published GNOME's GSettings rather than cce's
+//! config, and Inhibit forwarded to session services no cce session runs, so
+//! it silently did nothing. See `settings.rs` and `inhibit.rs`.
+//!
+//! One bus-activated process owns both names; whichever is asked for first
+//! starts it. It lives for the session — Settings has to be there to signal
+//! changes — and costs nothing at rest: config changes arrive by inotify,
+//! and the lease-renewal loop sleeps while nothing is held.
+
+mod inhibit;
+mod settings;
+
+use std::sync::Arc;
+
+use futures_util::StreamExt;
+use tokio::sync::RwLock;
+use zbus::fdo::{RequestNameFlags, RequestNameReply};
+use zbus::names::BusName;
+
+const PORTAL_NAME: &str = "org.freedesktop.impl.portal.desktop.cce-desktop";
+const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
+const SCREENSAVER_NAME: &str = "org.freedesktop.ScreenSaver";
+
+#[tokio::main]
+async fn main() {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+ if let Err(e) = run().await {
+ log::error!("{e}");
+ std::process::exit(1);
+ }
+}
+
+async fn run() -> Result<(), Box<dyn std::error::Error>> {
+ let appearance = Arc::new(RwLock::new(settings::load(&settings::config_path())));
+ log::info!("appearance: {:?}", *appearance.read().await);
+ let inhibitor = inhibit::Inhibitor::new();
+
+ // Leases a predecessor held die with it here rather than after their
+ // ttl. An older compositor answers an error; external inhibitors then
+ // wait for a newer one (see `Inhibitor::send_lease`).
+ match inhibit::ctl("idle inhibit-clear").await {
+ Ok(r) if r.starts_with("ok") => {}
+ Ok(r) => log::warn!("compositor has no external inhibitors yet: {}", r.trim()),
+ Err(e) => log::warn!("compositor control socket: {e}"),
+ }
+
+ let screensaver = |i: &inhibit::Inhibitor| inhibit::ScreenSaver { inhibitor: i.clone() };
+ let conn = zbus::connection::Builder::session()?
+ .serve_at(PORTAL_PATH, settings::SettingsPortal { state: appearance.clone() })?
+ .serve_at(PORTAL_PATH, inhibit::InhibitPortal { inhibitor: inhibitor.clone() })?
+ // Both paths are in use in the wild (KDE answered at /ScreenSaver).
+ .serve_at("/org/freedesktop/ScreenSaver", screensaver(&inhibitor))?
+ .serve_at("/ScreenSaver", screensaver(&inhibitor))?
+ .build()
+ .await?;
+
+ // Objects first, names second: a call can only arrive once a name is
+ // owned, and by then every interface is there to answer it.
+ let flags = RequestNameFlags::DoNotQueue.into();
+ match conn.request_name_with_flags(PORTAL_NAME, flags).await? {
+ RequestNameReply::PrimaryOwner | RequestNameReply::AlreadyOwner => {}
+ // A racing activation won; it serves both names.
+ _ => {
+ log::info!("{PORTAL_NAME} is already owned; exiting");
+ return Ok(());
+ }
+ }
+ match conn.request_name_with_flags(SCREENSAVER_NAME, flags).await {
+ Ok(RequestNameReply::PrimaryOwner | RequestNameReply::AlreadyOwner) => {}
+ other => log::warn!("{SCREENSAVER_NAME} not taken ({other:?}); serving the portal only"),
+ }
+ log::info!("serving {PORTAL_NAME} and {SCREENSAVER_NAME}");
+
+ tokio::spawn(inhibitor.clone().renew_loop());
+ {
+ let (conn, appearance) = (conn.clone(), appearance.clone());
+ tokio::spawn(async move {
+ if let Err(e) = settings::watch(conn, PORTAL_PATH, appearance).await {
+ log::error!("appearance watch stopped: {e}");
+ }
+ });
+ }
+
+ // A ScreenSaver caller that leaves the bus without UnInhibit (a crash,
+ // a kill) releases what it held.
+ let dbus = zbus::fdo::DBusProxy::new(&conn).await?;
+ let mut owners = dbus.receive_name_owner_changed().await?;
+ while let Some(signal) = owners.next().await {
+ let Ok(args) = signal.args() else { continue };
+ if args.new_owner().is_none() {
+ if let BusName::Unique(name) = args.name() {
+ inhibitor.owner_gone(name).await;
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/src/settings.rs b/src/settings.rs
new file mode 100644
index 0000000..ea1764f
--- /dev/null
+++ b/src/settings.rs
@@ -0,0 +1,297 @@
+//! `org.freedesktop.impl.portal.Settings`: the `org.freedesktop.appearance`
+//! namespace, taken from cce's own config instead of GNOME's.
+//!
+//! Before this backend the namespace came from xdg-desktop-portal-gtk, which
+//! reads GSettings — `org.gnome.desktop.interface color-scheme`, a key nothing
+//! in cce writes, set once by hand. Now:
+//!
+//! - **color-scheme** — `style { color_scheme "dark" | "light" | "default" }`
+//! in config.kdl. cce is a dark desktop, so an absent key means dark.
+//! - **accent-color** — `style { highlight primary=(rgb)"#rrggbb" }`, the
+//! colour cce-ui already highlights with.
+//! - **reduced-motion** — the DE's animations switch, `/run/cce/animations`
+//! (`off` = reduce), which `cce-power-apply` flips per power mode.
+//! - **contrast** — always normal: cce has no high-contrast mode.
+//!
+//! Only this namespace is served. The portal frontend merges Settings from
+//! every backend `cce-portals.conf` lists (`cce-desktop;gtk`), first one
+//! wins per key, so the `org.gnome.*` keys sandboxed GTK apps read still come
+//! from gtk.
+//!
+//! Changes are pushed, not polled: inotify on the config directory and on
+//! `/run/cce` re-reads both, and only keys whose value moved are signalled.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use futures_util::StreamExt;
+use inotify::{Inotify, WatchMask};
+use tokio::sync::RwLock;
+use zbus::object_server::SignalEmitter;
+use zbus::zvariant::{OwnedValue, Value};
+use zbus::{interface, Connection};
+
+pub const APPEARANCE: &str = "org.freedesktop.appearance";
+
+/// The values `org.freedesktop.appearance` publishes.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Appearance {
+ /// 0 no preference, 1 prefer dark, 2 prefer light.
+ pub color_scheme: u32,
+ /// sRGB, each channel 0..=1. `None` publishes no accent at all (the
+ /// spec's "unset"), rather than an invented one.
+ pub accent: Option<(f64, f64, f64)>,
+ /// 0 normal, 1 high.
+ pub contrast: u32,
+ /// 0 no preference, 1 reduce.
+ pub reduced_motion: u32,
+}
+
+impl Default for Appearance {
+ fn default() -> Self {
+ Appearance { color_scheme: 1, accent: None, contrast: 0, reduced_motion: 0 }
+ }
+}
+
+impl Appearance {
+ /// The namespace's keys and values. The accent is left out while unset.
+ pub fn entries(&self) -> Vec<(&'static str, OwnedValue)> {
+ let mut out = vec![
+ ("color-scheme", OwnedValue::from(self.color_scheme)),
+ ("contrast", OwnedValue::from(self.contrast)),
+ ("reduced-motion", OwnedValue::from(self.reduced_motion)),
+ ];
+ if let Some(rgb) = self.accent {
+ let v = Value::from(rgb);
+ out.push(("accent-color", v.try_to_owned().expect("a (ddd) has no fds")));
+ }
+ out
+ }
+}
+
+pub fn config_path() -> PathBuf {
+ let base = std::env::var_os("XDG_CONFIG_HOME")
+ .filter(|v| !v.is_empty())
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".config"));
+ base.join("cce").join("config.kdl")
+}
+
+/// The animations switch's state file (cce-ui `motion`, written as root by
+/// cce-power-apply). Missing means on.
+pub const ANIMATIONS_FILE: &str = "/run/cce/animations";
+
+/// `color-scheme` and `accent-color` from config.kdl's `style { }` block.
+/// Anything unreadable falls back to the defaults, never to an error: a
+/// half-saved config must not take the setting away from every app.
+pub fn parse_config(text: &str) -> (u32, Option<(f64, f64, f64)>) {
+ let mut scheme = Appearance::default().color_scheme;
+ let mut accent = None;
+ let Ok(doc) = text.parse::<kdl::KdlDocument>() else {
+ return (scheme, accent);
+ };
+ let Some(style) = doc.get("style").and_then(|n| n.children()) else {
+ return (scheme, accent);
+ };
+ if let Some(v) = style.get_arg("color_scheme").and_then(|v| v.as_string()) {
+ scheme = match v {
+ "light" | "prefer-light" => 2,
+ "default" | "none" => 0,
+ _ => 1,
+ };
+ }
+ if let Some(hex) = style.get("highlight").and_then(|n| n.get("primary")).and_then(|e| e.value().as_string()) {
+ accent = parse_hex_rgb(hex);
+ }
+ (scheme, accent)
+}
+
+/// `#rgb`, `#rrggbb` or `#rrggbbaa` (alpha ignored) as 0..=1 channels.
+pub fn parse_hex_rgb(hex: &str) -> Option<(f64, f64, f64)> {
+ let h = hex.trim().strip_prefix('#')?;
+ let channel = |s: &str| u8::from_str_radix(s, 16).ok().map(|v| v as f64 / 255.0);
+ match h.len() {
+ 3 => {
+ let d = |i: usize| channel(&h[i..i + 1].repeat(2));
+ Some((d(0)?, d(1)?, d(2)?))
+ }
+ 6 | 8 => Some((channel(&h[0..2])?, channel(&h[2..4])?, channel(&h[4..6])?)),
+ _ => None,
+ }
+}
+
+/// `reduced-motion` from the animations file's contents (None = absent).
+pub fn reduced_motion(animations: Option<&str>) -> u32 {
+ match animations.map(str::trim) {
+ Some("off") | Some("0") | Some("false") => 1,
+ _ => 0,
+ }
+}
+
+pub fn load(config: &Path) -> Appearance {
+ let (color_scheme, accent) = std::fs::read_to_string(config).map(|t| parse_config(&t)).unwrap_or((1, None));
+ let animations = std::fs::read_to_string(ANIMATIONS_FILE).ok();
+ Appearance { color_scheme, accent, contrast: 0, reduced_motion: reduced_motion(animations.as_deref()) }
+}
+
+/// Does `namespaces` (ReadAll's argument) ask for `ns`? Empty asks for
+/// everything; an entry may end in `*` to match a prefix.
+pub fn namespace_wanted(namespaces: &[String], ns: &str) -> bool {
+ namespaces.is_empty()
+ || namespaces.iter().any(|want| match want.strip_suffix('*') {
+ Some(prefix) => ns.starts_with(prefix),
+ None => want == ns,
+ })
+}
+
+#[derive(Debug, zbus::DBusError)]
+#[zbus(prefix = "org.freedesktop.portal.Error")]
+pub enum PortalError {
+ #[zbus(error)]
+ ZBus(zbus::Error),
+ NotFound(String),
+}
+
+pub struct SettingsPortal {
+ pub state: Arc<RwLock<Appearance>>,
+}
+
+#[interface(name = "org.freedesktop.impl.portal.Settings")]
+impl SettingsPortal {
+ #[zbus(property, name = "version")]
+ fn version(&self) -> u32 {
+ 2
+ }
+
+ async fn read_all(&self, namespaces: Vec<String>) -> HashMap<String, HashMap<String, OwnedValue>> {
+ let mut out = HashMap::new();
+ if namespace_wanted(&namespaces, APPEARANCE) {
+ let entries = self.state.read().await.entries();
+ out.insert(APPEARANCE.to_string(), entries.into_iter().map(|(k, v)| (k.to_string(), v)).collect());
+ }
+ out
+ }
+
+ async fn read(&self, namespace: &str, key: &str) -> Result<OwnedValue, PortalError> {
+ if namespace == APPEARANCE {
+ if let Some((_, v)) = self.state.read().await.entries().into_iter().find(|(k, _)| *k == key) {
+ return Ok(v);
+ }
+ }
+ // The frontend moves on to the next backend (gtk) on NotFound.
+ Err(PortalError::NotFound(format!("{namespace} {key}")))
+ }
+
+ #[zbus(signal)]
+ pub async fn setting_changed(emitter: &SignalEmitter<'_>, namespace: &str, key: &str, value: Value<'_>) -> zbus::Result<()>;
+}
+
+/// Watch the config directory and `/run/cce`, re-read on any change to the
+/// two files, and signal each key whose value moved.
+pub async fn watch(conn: Connection, path: &str, state: Arc<RwLock<Appearance>>) -> std::io::Result<()> {
+ let config = config_path();
+ let inotify = Inotify::init()?;
+ let mask = WatchMask::CLOSE_WRITE | WatchMask::MOVED_TO | WatchMask::CREATE | WatchMask::DELETE;
+ // Directories, not the files: editors and cce-ui replace config.kdl by
+ // rename, which a watch on the old inode would never see.
+ if let Some(dir) = config.parent() {
+ inotify.watches().add(dir, mask)?;
+ }
+ let anim = Path::new(ANIMATIONS_FILE);
+ if let Some(dir) = anim.parent() {
+ if let Err(e) = inotify.watches().add(dir, mask) {
+ log::warn!("not watching {}: {e} (reduced-motion is read once)", dir.display());
+ }
+ }
+ let config_name = config.file_name().map(|n| n.to_os_string());
+ let anim_name = anim.file_name().map(|n| n.to_os_string());
+ let emitter = SignalEmitter::new(&conn, path.to_string()).map_err(std::io::Error::other)?;
+ let mut events = inotify.into_event_stream([0u8; 4096])?;
+ while let Some(event) = events.next().await {
+ let event = event?;
+ let relevant = event.name.as_ref().is_some_and(|n| Some(n) == config_name.as_ref() || Some(n) == anim_name.as_ref());
+ if !relevant {
+ continue;
+ }
+ let fresh = load(&config);
+ let old = std::mem::replace(&mut *state.write().await, fresh);
+ if old == fresh {
+ continue;
+ }
+ log::info!("appearance changed: {:?}", fresh);
+ let before: HashMap<_, _> = old.entries().into_iter().collect();
+ for (key, value) in fresh.entries() {
+ if before.get(key) != Some(&value) {
+ if let Err(e) = SettingsPortal::setting_changed(&emitter, APPEARANCE, key, Value::from(value)).await {
+ log::warn!("signalling {key}: {e}");
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const LIVE_SHAPE: &str = r##"
+bell "dialog"
+style {
+ list corner_radius=(i64)12 font="Berkeley Mono 14"
+ highlight primary=(rgb)"#7dffff"
+ layout {
+ column gap=(i64)16
+ }
+}
+"##;
+
+ #[test]
+ fn the_live_config_shape_is_dark_with_the_highlight_accent() {
+ let (scheme, accent) = parse_config(LIVE_SHAPE);
+ assert_eq!(scheme, 1, "no color_scheme key means dark");
+ let (r, g, b) = accent.unwrap();
+ assert!((r - 125.0 / 255.0).abs() < 1e-9 && g == 1.0 && b == 1.0);
+ }
+
+ #[test]
+ fn color_scheme_key_and_fallbacks() {
+ let with = |v: &str| parse_config(&format!("style {{\n color_scheme \"{v}\"\n}}\n")).0;
+ assert_eq!(with("light"), 2);
+ assert_eq!(with("dark"), 1);
+ assert_eq!(with("default"), 0);
+ assert_eq!(parse_config("style { this is not { kdl").0, 1, "a broken config keeps the default");
+ assert_eq!(parse_config("").1, None);
+ }
+
+ #[test]
+ fn hex_forms() {
+ assert_eq!(parse_hex_rgb("#fff"), Some((1.0, 1.0, 1.0)));
+ assert_eq!(parse_hex_rgb("#00000080"), Some((0.0, 0.0, 0.0)));
+ assert_eq!(parse_hex_rgb("7dffff"), None);
+ assert_eq!(parse_hex_rgb("#12345"), None);
+ }
+
+ #[test]
+ fn reduced_motion_follows_the_animations_switch() {
+ assert_eq!(reduced_motion(Some("off\n")), 1);
+ assert_eq!(reduced_motion(Some("on")), 0);
+ assert_eq!(reduced_motion(None), 0, "missing means animations on");
+ }
+
+ #[test]
+ fn namespace_globs() {
+ assert!(namespace_wanted(&[], APPEARANCE));
+ assert!(namespace_wanted(&["org.freedesktop.*".into()], APPEARANCE));
+ assert!(namespace_wanted(&[APPEARANCE.into()], APPEARANCE));
+ assert!(!namespace_wanted(&["org.gnome.*".into()], APPEARANCE));
+ }
+
+ #[test]
+ fn an_unset_accent_is_left_out() {
+ let keys = |a: Appearance| a.entries().into_iter().map(|(k, _)| k).collect::<Vec<_>>();
+ assert!(!keys(Appearance::default()).contains(&"accent-color"));
+ assert!(keys(Appearance { accent: Some((1.0, 0.0, 0.0)), ..Default::default() }).contains(&"accent-color"));
+ }
+}