// SPDX-License-Identifier: AGPL-3.0-or-later #[cfg(target_os = "linux")] use std::{ sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, mpsc, }, thread::{self, JoinHandle}, time::Duration, }; #[cfg(target_os = "linux")] use futures_lite::{FutureExt, StreamExt, future}; #[cfg(target_os = "linux")] use zbus::{ MatchRule, MessageStream, blocking::{Connection as BlockingConnection, Proxy as BlockingProxy}, message::Type as MessageType, zvariant::{OwnedValue, Value}, }; pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop"; pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop"; pub const SETTINGS_INTERFACE: &str = "org.freedesktop.portal.Settings"; pub const APPEARANCE_NAMESPACE: &str = "org.freedesktop.appearance"; #[cfg(target_os = "linux")] pub const READ_TIMEOUT: Duration = Duration::from_millis(1_500); #[cfg(target_os = "linux")] pub const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200); #[cfg(target_os = "linux")] pub const SIGNAL_THREAD_START_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColorScheme { NoPreference, PreferDark, PreferLight, } impl ColorScheme { pub fn from_u32(value: u32) -> Self { match value { 1 => Self::PreferDark, 2 => Self::PreferLight, _ => Self::NoPreference, } } pub fn as_str(self) -> &'static str { match self { Self::NoPreference => "no-preference", Self::PreferDark => "prefer-dark", Self::PreferLight => "prefer-light", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Contrast { NoPreference, High, } impl Contrast { pub fn from_u32(value: u32) -> Self { match value { 1 => Self::High, _ => Self::NoPreference, } } pub fn as_str(self) -> &'static str { match self { Self::NoPreference => "no-preference", Self::High => "high", } } } #[derive(Debug, Clone, Copy, PartialEq)] pub struct AccentColor { pub r: f64, pub g: f64, pub b: f64, } pub fn classify_accent_color(r: f64, g: f64, b: f64) -> Option { if r < 0.0 || g < 0.0 || b < 0.0 { return None; } Some(AccentColor { r, g, b }) } #[derive(Debug, Clone, PartialEq)] pub struct ChangeEvent { pub namespace: String, pub key: String, pub payload: ChangePayload, } #[derive(Debug, Clone, PartialEq)] pub enum ChangePayload { Uint32(u32), Accent(AccentColor), Unknown, } #[cfg(target_os = "linux")] fn open_blocking_connection() -> zbus::Result { zbus::blocking::connection::Builder::session()? .method_timeout(READ_TIMEOUT) .build() } #[cfg(target_os = "linux")] fn read_uint32_setting(key: &str) -> Option { let conn = open_blocking_connection().ok()?; let proxy = BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, SETTINGS_INTERFACE).ok()?; let value: OwnedValue = proxy.call("Read", &(APPEARANCE_NAMESPACE, key)).ok()?; crate::kwin::integer_from_variant(crate::kwin::value_of_owned(&value)) } #[cfg(target_os = "linux")] pub fn read_color_scheme() -> ColorScheme { read_uint32_setting("color-scheme") .map(ColorScheme::from_u32) .unwrap_or(ColorScheme::NoPreference) } #[cfg(target_os = "linux")] pub fn read_contrast() -> Contrast { read_uint32_setting("contrast") .map(Contrast::from_u32) .unwrap_or(Contrast::NoPreference) } #[cfg(target_os = "linux")] pub fn read_accent_color() -> Option { let conn = open_blocking_connection().ok()?; let proxy = BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, SETTINGS_INTERFACE).ok()?; let value: OwnedValue = proxy .call("Read", &(APPEARANCE_NAMESPACE, "accent-color")) .ok()?; extract_accent_from_variant(crate::kwin::value_of_owned(&value)) } #[cfg(target_os = "linux")] fn extract_accent_from_variant(value: &Value<'_>) -> Option { let inner: &Value<'_> = match value { Value::Value(b) => b.as_ref(), other => other, }; let Value::Structure(structure) = inner else { return None; }; let fields = structure.fields(); if fields.len() < 3 { return None; } let r = double_from_value(&fields[0])?; let g = double_from_value(&fields[1])?; let b = double_from_value(&fields[2])?; classify_accent_color(r, g, b) } #[cfg(target_os = "linux")] fn double_from_value(value: &Value<'_>) -> Option { match value { Value::F64(v) => Some(*v), Value::Value(b) => double_from_value(b.as_ref()), _ => None, } } #[cfg(not(target_os = "linux"))] pub fn read_color_scheme() -> ColorScheme { ColorScheme::NoPreference } #[cfg(not(target_os = "linux"))] pub fn read_contrast() -> Contrast { Contrast::NoPreference } #[cfg(not(target_os = "linux"))] pub fn read_accent_color() -> Option { None } #[cfg(target_os = "linux")] type ChangeCallback = Arc; #[cfg(target_os = "linux")] pub struct Subscription { stop_flag: Arc, thread: Mutex>>, } #[cfg(target_os = "linux")] impl Subscription { pub fn new(callback: ChangeCallback) -> Result { let stop_flag = Arc::new(AtomicBool::new(false)); let (ready_tx, ready_rx) = mpsc::sync_channel(1); let stop_for_thread = stop_flag.clone(); let thread = thread::Builder::new() .name("fluxer-linux-portals-settings".to_string()) .spawn(move || { let setup = future::block_on(async { let conn = zbus::Connection::session().await?; let rule = MatchRule::builder() .msg_type(MessageType::Signal) .interface(SETTINGS_INTERFACE)? .member("SettingChanged")? .build(); let stream = MessageStream::for_match_rule(rule, &conn, Some(32)).await?; zbus::Result::Ok((conn, stream)) }); let (_conn, mut stream) = match setup { Ok(parts) => { let _ = ready_tx.send(Ok(())); parts } Err(err) => { let _ = ready_tx.send(Err(err.to_string())); return; } }; while !stop_for_thread.load(Ordering::Acquire) { let timeout = async { async_io::Timer::after(SIGNAL_POLL_INTERVAL).await; None::> }; match future::block_on(stream.next().or(timeout)) { Some(Ok(message)) => { if let Some(event) = parse_setting_changed(&message) && event.namespace == APPEARANCE_NAMESPACE { callback(event); } } Some(Err(_)) => break, None => {} } } }) .map_err(|err| err.to_string())?; match ready_rx.recv_timeout(SIGNAL_THREAD_START_TIMEOUT) { Ok(Ok(())) => Ok(Self { stop_flag, thread: Mutex::new(Some(thread)), }), Ok(Err(err)) => { let _ = thread.join(); Err(err) } Err(err) => { stop_flag.store(true, Ordering::Release); if matches!(err, mpsc::RecvTimeoutError::Disconnected) { let _ = thread.join(); } Err(err.to_string()) } } } pub fn close(&self) { self.stop_flag.store(true, Ordering::Release); if let Ok(mut thread) = self.thread.lock() && let Some(t) = thread.take() { let _ = t.join(); } } } #[cfg(target_os = "linux")] impl Drop for Subscription { fn drop(&mut self) { self.close(); } } #[cfg(target_os = "linux")] fn parse_setting_changed(message: &zbus::Message) -> Option { let body = message.body(); let (namespace, key, value): (String, String, OwnedValue) = body.deserialize().ok()?; let value_ref = crate::kwin::value_of_owned(&value); let payload = classify_payload(&key, value_ref); Some(ChangeEvent { namespace, key, payload, }) } #[cfg(target_os = "linux")] fn classify_payload(_key: &str, value: &Value<'_>) -> ChangePayload { if let Some(n) = crate::kwin::integer_from_variant(value) { return ChangePayload::Uint32(n); } if let Some(accent) = extract_accent_from_variant(value) { return ChangePayload::Accent(accent); } ChangePayload::Unknown } #[cfg(test)] mod tests { use super::*; #[test] fn color_scheme_maps_to_strings_per_typescript_union() { assert_eq!(ColorScheme::from_u32(0).as_str(), "no-preference"); assert_eq!(ColorScheme::from_u32(1).as_str(), "prefer-dark"); assert_eq!(ColorScheme::from_u32(2).as_str(), "prefer-light"); assert_eq!(ColorScheme::from_u32(99).as_str(), "no-preference"); } #[test] fn contrast_maps_to_strings_per_typescript_union() { assert_eq!(Contrast::from_u32(0).as_str(), "no-preference"); assert_eq!(Contrast::from_u32(1).as_str(), "high"); assert_eq!(Contrast::from_u32(99).as_str(), "no-preference"); } #[test] fn classify_accent_color_treats_negative_as_no_preference() { assert_eq!(classify_accent_color(-1.0, -1.0, -1.0), None); assert_eq!(classify_accent_color(-0.0001, 0.5, 0.5), None); let accent = classify_accent_color(0.1, 0.2, 0.3).unwrap(); assert_eq!(accent.r, 0.1); assert_eq!(accent.g, 0.2); assert_eq!(accent.b, 0.3); } }