Settings and Inhibit portal backends
git clone https://git.lucas.co/cce-desktop-portal.git
src/settings.rs (11.2K)
1 //! `org.freedesktop.impl.portal.Settings`: the `org.freedesktop.appearance`
2 //! namespace, taken from cce's own config instead of GNOME's.
3 //!
4 //! Before this backend the namespace came from xdg-desktop-portal-gtk, which
5 //! reads GSettings — `org.gnome.desktop.interface color-scheme`, a key nothing
6 //! in cce writes, set once by hand. Now:
7 //!
8 //! - **color-scheme** — `style { color_scheme "dark" | "light" | "default" }`
9 //! in config.kdl. cce is a dark desktop, so an absent key means dark.
10 //! - **accent-color** — `style { highlight primary=(rgb)"#rrggbb" }`, the
11 //! colour cce-ui already highlights with.
12 //! - **reduced-motion** — the DE's animations switch, `/run/cce/animations`
13 //! (`off` = reduce), which `cce-power-apply` flips per power mode.
14 //! - **contrast** — always normal: cce has no high-contrast mode.
15 //!
16 //! Only this namespace is served. The portal frontend merges Settings from
17 //! every backend `cce-portals.conf` lists (`cce-desktop;gtk`), first one
18 //! wins per key, so the `org.gnome.*` keys sandboxed GTK apps read still come
19 //! from gtk.
20 //!
21 //! Changes are pushed, not polled: inotify on the config directory and on
22 //! `/run/cce` re-reads both, and only keys whose value moved are signalled.
23
24 use std::collections::HashMap;
25 use std::path::{Path, PathBuf};
26 use std::sync::Arc;
27
28 use futures_util::StreamExt;
29 use inotify::{Inotify, WatchMask};
30 use tokio::sync::RwLock;
31 use zbus::object_server::SignalEmitter;
32 use zbus::zvariant::{OwnedValue, Value};
33 use zbus::{interface, Connection};
34
35 pub const APPEARANCE: &str = "org.freedesktop.appearance";
36
37 /// The values `org.freedesktop.appearance` publishes.
38 #[derive(Clone, Copy, Debug, PartialEq)]
39 pub struct Appearance {
40 /// 0 no preference, 1 prefer dark, 2 prefer light.
41 pub color_scheme: u32,
42 /// sRGB, each channel 0..=1. `None` publishes no accent at all (the
43 /// spec's "unset"), rather than an invented one.
44 pub accent: Option<(f64, f64, f64)>,
45 /// 0 normal, 1 high.
46 pub contrast: u32,
47 /// 0 no preference, 1 reduce.
48 pub reduced_motion: u32,
49 }
50
51 impl Default for Appearance {
52 fn default() -> Self {
53 Appearance { color_scheme: 1, accent: None, contrast: 0, reduced_motion: 0 }
54 }
55 }
56
57 impl Appearance {
58 /// The namespace's keys and values. The accent is left out while unset.
59 pub fn entries(&self) -> Vec<(&'static str, OwnedValue)> {
60 let mut out = vec![
61 ("color-scheme", OwnedValue::from(self.color_scheme)),
62 ("contrast", OwnedValue::from(self.contrast)),
63 ("reduced-motion", OwnedValue::from(self.reduced_motion)),
64 ];
65 if let Some(rgb) = self.accent {
66 let v = Value::from(rgb);
67 out.push(("accent-color", v.try_to_owned().expect("a (ddd) has no fds")));
68 }
69 out
70 }
71 }
72
73 pub fn config_path() -> PathBuf {
74 let base = std::env::var_os("XDG_CONFIG_HOME")
75 .filter(|v| !v.is_empty())
76 .map(PathBuf::from)
77 .unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".config"));
78 base.join("cce").join("config.kdl")
79 }
80
81 /// The animations switch's state file (cce-ui `motion`, written as root by
82 /// cce-power-apply). Missing means on.
83 pub const ANIMATIONS_FILE: &str = "/run/cce/animations";
84
85 /// `color-scheme` and `accent-color` from config.kdl's `style { }` block.
86 /// Anything unreadable falls back to the defaults, never to an error: a
87 /// half-saved config must not take the setting away from every app.
88 pub fn parse_config(text: &str) -> (u32, Option<(f64, f64, f64)>) {
89 let mut scheme = Appearance::default().color_scheme;
90 let mut accent = None;
91 let Ok(doc) = text.parse::<kdl::KdlDocument>() else {
92 return (scheme, accent);
93 };
94 let Some(style) = doc.get("style").and_then(|n| n.children()) else {
95 return (scheme, accent);
96 };
97 if let Some(v) = style.get_arg("color_scheme").and_then(|v| v.as_string()) {
98 scheme = match v {
99 "light" | "prefer-light" => 2,
100 "default" | "none" => 0,
101 _ => 1,
102 };
103 }
104 if let Some(hex) = style.get("highlight").and_then(|n| n.get("primary")).and_then(|e| e.value().as_string()) {
105 accent = parse_hex_rgb(hex);
106 }
107 (scheme, accent)
108 }
109
110 /// `#rgb`, `#rrggbb` or `#rrggbbaa` (alpha ignored) as 0..=1 channels.
111 pub fn parse_hex_rgb(hex: &str) -> Option<(f64, f64, f64)> {
112 let h = hex.trim().strip_prefix('#')?;
113 let channel = |s: &str| u8::from_str_radix(s, 16).ok().map(|v| v as f64 / 255.0);
114 match h.len() {
115 3 => {
116 let d = |i: usize| channel(&h[i..i + 1].repeat(2));
117 Some((d(0)?, d(1)?, d(2)?))
118 }
119 6 | 8 => Some((channel(&h[0..2])?, channel(&h[2..4])?, channel(&h[4..6])?)),
120 _ => None,
121 }
122 }
123
124 /// `reduced-motion` from the animations file's contents (None = absent).
125 pub fn reduced_motion(animations: Option<&str>) -> u32 {
126 match animations.map(str::trim) {
127 Some("off") | Some("0") | Some("false") => 1,
128 _ => 0,
129 }
130 }
131
132 pub fn load(config: &Path) -> Appearance {
133 let (color_scheme, accent) = std::fs::read_to_string(config).map(|t| parse_config(&t)).unwrap_or((1, None));
134 let animations = std::fs::read_to_string(ANIMATIONS_FILE).ok();
135 Appearance { color_scheme, accent, contrast: 0, reduced_motion: reduced_motion(animations.as_deref()) }
136 }
137
138 /// Does `namespaces` (ReadAll's argument) ask for `ns`? Empty asks for
139 /// everything; an entry may end in `*` to match a prefix.
140 pub fn namespace_wanted(namespaces: &[String], ns: &str) -> bool {
141 namespaces.is_empty()
142 || namespaces.iter().any(|want| match want.strip_suffix('*') {
143 Some(prefix) => ns.starts_with(prefix),
144 None => want == ns,
145 })
146 }
147
148 #[derive(Debug, zbus::DBusError)]
149 #[zbus(prefix = "org.freedesktop.portal.Error")]
150 pub enum PortalError {
151 #[zbus(error)]
152 ZBus(zbus::Error),
153 NotFound(String),
154 }
155
156 pub struct SettingsPortal {
157 pub state: Arc<RwLock<Appearance>>,
158 }
159
160 #[interface(name = "org.freedesktop.impl.portal.Settings")]
161 impl SettingsPortal {
162 #[zbus(property, name = "version")]
163 fn version(&self) -> u32 {
164 2
165 }
166
167 async fn read_all(&self, namespaces: Vec<String>) -> HashMap<String, HashMap<String, OwnedValue>> {
168 let mut out = HashMap::new();
169 if namespace_wanted(&namespaces, APPEARANCE) {
170 let entries = self.state.read().await.entries();
171 out.insert(APPEARANCE.to_string(), entries.into_iter().map(|(k, v)| (k.to_string(), v)).collect());
172 }
173 out
174 }
175
176 async fn read(&self, namespace: &str, key: &str) -> Result<OwnedValue, PortalError> {
177 if namespace == APPEARANCE {
178 if let Some((_, v)) = self.state.read().await.entries().into_iter().find(|(k, _)| *k == key) {
179 return Ok(v);
180 }
181 }
182 // The frontend moves on to the next backend (gtk) on NotFound.
183 Err(PortalError::NotFound(format!("{namespace} {key}")))
184 }
185
186 #[zbus(signal)]
187 pub async fn setting_changed(emitter: &SignalEmitter<'_>, namespace: &str, key: &str, value: Value<'_>) -> zbus::Result<()>;
188 }
189
190 /// Watch the config directory and `/run/cce`, re-read on any change to the
191 /// two files, and signal each key whose value moved.
192 pub async fn watch(conn: Connection, path: &str, state: Arc<RwLock<Appearance>>) -> std::io::Result<()> {
193 let config = config_path();
194 let inotify = Inotify::init()?;
195 let mask = WatchMask::CLOSE_WRITE | WatchMask::MOVED_TO | WatchMask::CREATE | WatchMask::DELETE;
196 // Directories, not the files: editors and cce-ui replace config.kdl by
197 // rename, which a watch on the old inode would never see.
198 if let Some(dir) = config.parent() {
199 inotify.watches().add(dir, mask)?;
200 }
201 let anim = Path::new(ANIMATIONS_FILE);
202 if let Some(dir) = anim.parent() {
203 if let Err(e) = inotify.watches().add(dir, mask) {
204 log::warn!("not watching {}: {e} (reduced-motion is read once)", dir.display());
205 }
206 }
207 let config_name = config.file_name().map(|n| n.to_os_string());
208 let anim_name = anim.file_name().map(|n| n.to_os_string());
209 let emitter = SignalEmitter::new(&conn, path.to_string()).map_err(std::io::Error::other)?;
210 let mut events = inotify.into_event_stream([0u8; 4096])?;
211 while let Some(event) = events.next().await {
212 let event = event?;
213 let relevant = event.name.as_ref().is_some_and(|n| Some(n) == config_name.as_ref() || Some(n) == anim_name.as_ref());
214 if !relevant {
215 continue;
216 }
217 let fresh = load(&config);
218 let old = std::mem::replace(&mut *state.write().await, fresh);
219 if old == fresh {
220 continue;
221 }
222 log::info!("appearance changed: {:?}", fresh);
223 let before: HashMap<_, _> = old.entries().into_iter().collect();
224 for (key, value) in fresh.entries() {
225 if before.get(key) != Some(&value) {
226 if let Err(e) = SettingsPortal::setting_changed(&emitter, APPEARANCE, key, Value::from(value)).await {
227 log::warn!("signalling {key}: {e}");
228 }
229 }
230 }
231 }
232 Ok(())
233 }
234
235 #[cfg(test)]
236 mod tests {
237 use super::*;
238
239 const LIVE_SHAPE: &str = r##"
240 bell "dialog"
241 style {
242 list corner_radius=(i64)12 font="Berkeley Mono 14"
243 highlight primary=(rgb)"#7dffff"
244 layout {
245 column gap=(i64)16
246 }
247 }
248 "##;
249
250 #[test]
251 fn the_live_config_shape_is_dark_with_the_highlight_accent() {
252 let (scheme, accent) = parse_config(LIVE_SHAPE);
253 assert_eq!(scheme, 1, "no color_scheme key means dark");
254 let (r, g, b) = accent.unwrap();
255 assert!((r - 125.0 / 255.0).abs() < 1e-9 && g == 1.0 && b == 1.0);
256 }
257
258 #[test]
259 fn color_scheme_key_and_fallbacks() {
260 let with = |v: &str| parse_config(&format!("style {{\n color_scheme \"{v}\"\n}}\n")).0;
261 assert_eq!(with("light"), 2);
262 assert_eq!(with("dark"), 1);
263 assert_eq!(with("default"), 0);
264 assert_eq!(parse_config("style { this is not { kdl").0, 1, "a broken config keeps the default");
265 assert_eq!(parse_config("").1, None);
266 }
267
268 #[test]
269 fn hex_forms() {
270 assert_eq!(parse_hex_rgb("#fff"), Some((1.0, 1.0, 1.0)));
271 assert_eq!(parse_hex_rgb("#00000080"), Some((0.0, 0.0, 0.0)));
272 assert_eq!(parse_hex_rgb("7dffff"), None);
273 assert_eq!(parse_hex_rgb("#12345"), None);
274 }
275
276 #[test]
277 fn reduced_motion_follows_the_animations_switch() {
278 assert_eq!(reduced_motion(Some("off\n")), 1);
279 assert_eq!(reduced_motion(Some("on")), 0);
280 assert_eq!(reduced_motion(None), 0, "missing means animations on");
281 }
282
283 #[test]
284 fn namespace_globs() {
285 assert!(namespace_wanted(&[], APPEARANCE));
286 assert!(namespace_wanted(&["org.freedesktop.*".into()], APPEARANCE));
287 assert!(namespace_wanted(&[APPEARANCE.into()], APPEARANCE));
288 assert!(!namespace_wanted(&["org.gnome.*".into()], APPEARANCE));
289 }
290
291 #[test]
292 fn an_unset_accent_is_left_out() {
293 let keys = |a: Appearance| a.entries().into_iter().map(|(k, _)| k).collect::<Vec<_>>();
294 assert!(!keys(Appearance::default()).contains(&"accent-color"));
295 assert!(keys(Appearance { accent: Some((1.0, 0.0, 0.0)), ..Default::default() }).contains(&"accent-color"));
296 }
297 }