system_theme/platform/
xdg.rs

1use std::{
2    sync::{Arc, LazyLock, OnceLock},
3    thread::{self, JoinHandle},
4};
5use tokio::sync::Notify;
6use zbus::{
7    blocking::{fdo::DBusProxy, Connection, Proxy},
8    names::BusName,
9    zvariant::OwnedValue,
10};
11
12use crate::{error::Error, ThemeColor, ThemeContrast, ThemeKind, ThemeScheme};
13
14const DESKTOP_PORTAL_DEST: &str = "org.freedesktop.portal.Desktop";
15const DESKTOP_PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
16const SETTINGS_INTERFACE: &str = "org.freedesktop.portal.Settings";
17const READ_METHOD: &str = "ReadOne";
18const CHANGE_SIGNAL: &str = "SettingChanged";
19const APPERANCE_NAMESPACE: &str = "org.freedesktop.appearance";
20
21const COLOR_SCHEME_KEY: &str = "color-scheme";
22const CONTRAST_KEY: &str = "contrast";
23const ACCENT_COLOR_KEY: &str = "accent-color";
24
25const PORTAL_NOT_FOUND: &str = "org.freedesktop.portal.Error.NotFound";
26const DBUS_UNKNOWN_SERVICE: &str = "org.freedesktop.DBus.Error.ServiceUnknown";
27const DBUS_UNKNOWN_METHOD: &str = "org.freedesktop.DBus.Error.UnknownMethod";
28
29const GTK_PORTAL_IMPL: &str = "org.freedesktop.impl.portal.desktop.gtk";
30
31static WATCHER_NOTIFY: LazyLock<Arc<Notify>> = LazyLock::new(|| Arc::new(Notify::new()));
32static WATCHER_HANDLE: OnceLock<JoinHandle<()>> = OnceLock::new();
33
34impl From<zbus::Error> for Error {
35    fn from(value: zbus::Error) -> Self {
36        match &value {
37            zbus::Error::InterfaceNotFound => Error::Unsupported,
38            zbus::Error::Unsupported => Error::Unsupported,
39            zbus::Error::MethodError(name, _, _) => {
40                let name_str = name.as_str();
41                // Errors that can be returned if not supported by the platform
42                if name_str == PORTAL_NOT_FOUND
43                    || name_str == DBUS_UNKNOWN_SERVICE
44                    || name_str == DBUS_UNKNOWN_METHOD
45                {
46                    Error::Unsupported
47                } else {
48                    Error::from_platform(value)
49                }
50            }
51            _ => Error::from_platform(value),
52        }
53    }
54}
55
56/// Check if color component is valid
57fn check_color_component(component: f64) -> bool {
58    (0.0..=1.0).contains(&component)
59}
60
61pub struct Platform {
62    conn: Connection,
63}
64
65impl Platform {
66    pub fn new() -> Result<Self, Error> {
67        let conn = Connection::session()?;
68
69        // Create change watcher (ignore errors, not that important)
70        let conn_cloned = conn.clone();
71        if let Ok(proxy) = Proxy::new(
72            &conn_cloned,
73            DESKTOP_PORTAL_DEST,
74            DESKTOP_PORTAL_PATH,
75            SETTINGS_INTERFACE,
76        ) {
77            if let Ok(signal) = proxy.receive_signal(CHANGE_SIGNAL) {
78                // Create background thread a single time
79                WATCHER_HANDLE.get_or_init(|| {
80                    thread::spawn(move || {
81                        for _ in signal {
82                            (*WATCHER_NOTIFY).notify_waiters();
83                        }
84                    })
85                });
86            }
87        }
88
89        Ok(Self { conn })
90    }
91
92    pub fn theme_kind(&self) -> Result<ThemeKind, Error> {
93        if self.check_has_owner(
94            GTK_PORTAL_IMPL
95                .try_into()
96                .expect("Failed to convert GTK_PORTAL_IMPL"),
97        )? {
98            // If we have GTK Portal, we're using GTK
99            Ok(ThemeKind::Gtk)
100        } else {
101            // Anything else should be Qt
102            Ok(ThemeKind::Qt)
103        }
104    }
105
106    pub fn theme_scheme(&self) -> Result<ThemeScheme, Error> {
107        let scheme: u32 = self.get_settings_apperance(COLOR_SCHEME_KEY)?;
108
109        // 1 = dark
110        if scheme == 1 {
111            Ok(ThemeScheme::Dark)
112        } else {
113            Ok(ThemeScheme::Light)
114        }
115    }
116
117    pub fn theme_contrast(&self) -> Result<ThemeContrast, Error> {
118        let contrast: u32 = self.get_settings_apperance(CONTRAST_KEY)?;
119
120        // 1 = high
121        if contrast == 1 {
122            Ok(ThemeContrast::High)
123        } else {
124            Ok(ThemeContrast::Normal)
125        }
126    }
127
128    pub fn theme_accent(&self) -> Result<ThemeColor, Error> {
129        let accent: (f64, f64, f64) = self.get_settings_apperance(ACCENT_COLOR_KEY)?;
130
131        // Check color components range (invalid -> not configured)
132        if !check_color_component(accent.0)
133            || !check_color_component(accent.1)
134            || !check_color_component(accent.2)
135        {
136            return Err(Error::Unavailable);
137        }
138
139        Ok(ThemeColor {
140            red: accent.0 as f32,
141            green: accent.1 as f32,
142            blue: accent.2 as f32,
143        })
144    }
145
146    pub fn get_notify(&self) -> Arc<Notify> {
147        (*WATCHER_NOTIFY).clone()
148    }
149
150    fn check_has_owner(&self, name: BusName<'_>) -> Result<bool, Error> {
151        let proxy = DBusProxy::new(&self.conn)?;
152
153        match proxy.get_name_owner(name) {
154            Ok(_) => Ok(true),
155            Err(zbus::fdo::Error::NameHasNoOwner(_)) => Ok(false),
156            Err(e) => Err(Error::from_platform(e)),
157        }
158    }
159
160    fn get_settings_apperance<T: TryFrom<OwnedValue>>(&self, key: &str) -> Result<T, Error> {
161        // Call method to read a settings appearance
162        let response = self.conn.call_method(
163            Some(DESKTOP_PORTAL_DEST),
164            DESKTOP_PORTAL_PATH,
165            Some(SETTINGS_INTERFACE),
166            READ_METHOD,
167            &(APPERANCE_NAMESPACE, key),
168        )?;
169
170        // As the result is a variant, convert it to a value first
171        let value = response
172            .body()
173            .deserialize::<OwnedValue>()
174            .map_err(Error::from_platform)?;
175
176        // Now try to convert it to the desired type (invalid -> not configured)
177        value.try_into().map_err(|_| Error::Unavailable)
178    }
179}