git.lucas.co / cce-desktop-portal
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 }