11//! Linux accent color reader.
22//!
3- //! Reads the user's desktop accent color via `gsettings` (GNOME 47+).
4- //! Falls back to the Cmdr brand gold if gsettings is unavailable or
5- //! returns an unrecognized value.
3+ //! Reads the user's desktop accent color via the XDG Desktop Portal D-Bus API
4+ //! (works on GNOME 47+ and KDE Plasma 5.23+), falling back to `gsettings`
5+ //! (older GNOME), then to the Cmdr brand gold.
6+ //!
7+ //! Also observes the portal's `SettingChanged` signal for live accent color
8+ //! updates, matching the macOS `NSSystemColorsDidChangeNotification` behavior.
69
7- use log:: { debug, warn} ;
10+ use log:: { debug, info, warn} ;
11+ use tauri:: { AppHandle , Emitter , Runtime } ;
12+ use zbus:: zvariant:: { OwnedValue , Value } ;
813
914/// Brand fallback accent (mustard gold from getcmdr.com).
1015const FALLBACK_ACCENT_HEX : & str = "#d4a006" ;
1116
17+ const PORTAL_DEST : & str = "org.freedesktop.portal.Desktop" ;
18+ const PORTAL_PATH : & str = "/org/freedesktop/portal/desktop" ;
19+ const PORTAL_IFACE : & str = "org.freedesktop.portal.Settings" ;
20+ const APPEARANCE_NS : & str = "org.freedesktop.appearance" ;
21+ const ACCENT_KEY : & str = "accent-color" ;
22+
23+ /// Converts sRGB floats in [0, 1] to a `#rrggbb` hex string.
24+ fn rgb_floats_to_hex ( r : f64 , g : f64 , b : f64 ) -> String {
25+ let r8 = ( r. clamp ( 0.0 , 1.0 ) * 255.0 ) . round ( ) as u8 ;
26+ let g8 = ( g. clamp ( 0.0 , 1.0 ) * 255.0 ) . round ( ) as u8 ;
27+ let b8 = ( b. clamp ( 0.0 , 1.0 ) * 255.0 ) . round ( ) as u8 ;
28+ format ! ( "#{r8:02x}{g8:02x}{b8:02x}" )
29+ }
30+
31+ /// Extracts (r, g, b) floats from a D-Bus variant value.
32+ /// The portal wraps the color in nested variants: `Variant(Variant((r, g, b)))`.
33+ fn extract_rgb ( value : & Value < ' _ > ) -> Option < ( f64 , f64 , f64 ) > {
34+ // Unwrap up to two levels of Variant nesting
35+ let inner = match value {
36+ Value :: Value ( v) => match v. as_ref ( ) {
37+ Value :: Value ( v2) => v2. as_ref ( ) ,
38+ other => other,
39+ } ,
40+ other => other,
41+ } ;
42+
43+ match inner {
44+ Value :: Structure ( s) => {
45+ let fields = s. fields ( ) ;
46+ if fields. len ( ) == 3 {
47+ if let ( Value :: F64 ( r) , Value :: F64 ( g) , Value :: F64 ( b) ) = ( & fields[ 0 ] , & fields[ 1 ] , & fields[ 2 ] ) {
48+ return Some ( ( * r, * g, * b) ) ;
49+ }
50+ }
51+ None
52+ }
53+ _ => None ,
54+ }
55+ }
56+
57+ /// Reads accent color via XDG Desktop Portal D-Bus (GNOME 47+, KDE Plasma 5.23+).
58+ fn read_accent_color_portal ( ) -> Option < String > {
59+ let conn = zbus:: blocking:: Connection :: session ( ) . ok ( ) ?;
60+ let proxy = zbus:: blocking:: Proxy :: new ( & conn, PORTAL_DEST , PORTAL_PATH , PORTAL_IFACE ) . ok ( ) ?;
61+
62+ let reply: OwnedValue = proxy. call ( "ReadOne" , & ( APPEARANCE_NS , ACCENT_KEY ) ) . ok ( ) ?;
63+
64+ let ( r, g, b) = extract_rgb ( & reply) ?;
65+ let hex = rgb_floats_to_hex ( r, g, b) ;
66+ debug ! ( "XDG Portal accent color: ({r:.3}, {g:.3}, {b:.3}) -> {hex}" ) ;
67+ Some ( hex)
68+ }
69+
1270/// Maps GNOME 47+ accent-color names to hex values.
13- /// These are the standard GNOME accent colors as of GNOME 47.
1471fn gnome_accent_name_to_hex ( name : & str ) -> Option < & ' static str > {
1572 match name {
1673 "blue" => Some ( "#3584e4" ) ,
@@ -26,34 +83,42 @@ fn gnome_accent_name_to_hex(name: &str) -> Option<&'static str> {
2683 }
2784}
2885
29- /// Reads the GNOME accent color via `gsettings`.
30- fn read_accent_color ( ) -> String {
86+ /// Reads accent color via `gsettings` (older GNOME without portal support) .
87+ fn read_accent_color_gsettings ( ) -> Option < String > {
3188 let output = std:: process:: Command :: new ( "gsettings" )
3289 . args ( [ "get" , "org.gnome.desktop.interface" , "accent-color" ] )
33- . output ( ) ;
34-
35- match output {
36- Ok ( out) if out. status . success ( ) => {
37- // gsettings returns values like 'blue' (with quotes)
38- let raw = String :: from_utf8_lossy ( & out. stdout ) ;
39- let name = raw. trim ( ) . trim_matches ( '\'' ) ;
40- if let Some ( hex) = gnome_accent_name_to_hex ( name) {
41- debug ! ( "GNOME accent color: {name} -> {hex}" ) ;
42- return hex. to_owned ( ) ;
43- }
44- warn ! ( "Unrecognized GNOME accent color '{name}', using fallback" ) ;
45- FALLBACK_ACCENT_HEX . to_owned ( )
46- }
47- Ok ( out) => {
48- let stderr = String :: from_utf8_lossy ( & out. stderr ) ;
49- debug ! ( "gsettings failed (not GNOME?): {}" , stderr. trim( ) ) ;
50- FALLBACK_ACCENT_HEX . to_owned ( )
51- }
52- Err ( e) => {
53- debug ! ( "gsettings not available: {e}" ) ;
54- FALLBACK_ACCENT_HEX . to_owned ( )
55- }
90+ . output ( )
91+ . ok ( ) ?;
92+
93+ if !output. status . success ( ) {
94+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
95+ debug ! ( "gsettings failed (not GNOME?): {}" , stderr. trim( ) ) ;
96+ return None ;
97+ }
98+
99+ let raw = String :: from_utf8_lossy ( & output. stdout ) ;
100+ let name = raw. trim ( ) . trim_matches ( '\'' ) ;
101+ if let Some ( hex) = gnome_accent_name_to_hex ( name) {
102+ debug ! ( "GNOME accent color: {name} -> {hex}" ) ;
103+ return Some ( hex. to_owned ( ) ) ;
56104 }
105+ warn ! ( "Unrecognized GNOME accent color '{name}'" ) ;
106+ None
107+ }
108+
109+ /// Reads accent color with fallback chain: XDG Portal → gsettings → brand gold.
110+ fn read_accent_color ( ) -> String {
111+ if let Some ( hex) = read_accent_color_portal ( ) {
112+ return hex;
113+ }
114+ debug ! ( "XDG Portal accent color not available, trying gsettings" ) ;
115+
116+ if let Some ( hex) = read_accent_color_gsettings ( ) {
117+ return hex;
118+ }
119+ debug ! ( "gsettings accent color not available, using Cmdr brand gold" ) ;
120+
121+ FALLBACK_ACCENT_HEX . to_owned ( )
57122}
58123
59124/// Tauri command: returns the current Linux accent color as a hex string.
@@ -62,10 +127,65 @@ pub fn get_accent_color() -> String {
62127 read_accent_color ( )
63128}
64129
130+ /// Starts observing XDG Portal `SettingChanged` signal for live accent color updates.
131+ /// Emits `accent-color-changed` events to the frontend, matching macOS behavior.
132+ pub fn observe_accent_color_changes < R : Runtime > ( app_handle : AppHandle < R > ) {
133+ let initial = read_accent_color ( ) ;
134+ debug ! ( "Linux accent color: {initial}" ) ;
135+
136+ tauri:: async_runtime:: spawn ( async move {
137+ if let Err ( e) = watch_portal_signal ( app_handle) . await {
138+ debug ! ( "Portal accent color watcher not available: {e}" ) ;
139+ }
140+ } ) ;
141+ }
142+
143+ /// Subscribes to the portal's `SettingChanged` D-Bus signal and emits Tauri events.
144+ async fn watch_portal_signal < R : Runtime > ( app_handle : AppHandle < R > ) -> zbus:: Result < ( ) > {
145+ let conn = zbus:: Connection :: session ( ) . await ?;
146+ let proxy = zbus:: Proxy :: new ( & conn, PORTAL_DEST , PORTAL_PATH , PORTAL_IFACE ) . await ?;
147+
148+ use futures_util:: StreamExt ;
149+ let mut signals = proxy. receive_signal ( "SettingChanged" ) . await ?;
150+
151+ while let Some ( signal) = signals. next ( ) . await {
152+ let body = signal. body ( ) ;
153+ let Ok ( ( namespace, key, value) ) = body. deserialize :: < ( String , String , OwnedValue ) > ( ) else {
154+ continue ;
155+ } ;
156+
157+ if namespace == APPEARANCE_NS && key == ACCENT_KEY {
158+ if let Some ( ( r, g, b) ) = extract_rgb ( & value) {
159+ let hex = rgb_floats_to_hex ( r, g, b) ;
160+ info ! ( "Accent color changed: {hex}" ) ;
161+ if let Err ( e) = app_handle. emit ( "accent-color-changed" , & hex) {
162+ warn ! ( "Failed to emit accent-color-changed: {e}" ) ;
163+ }
164+ }
165+ }
166+ }
167+
168+ Ok ( ( ) )
169+ }
170+
65171#[ cfg( test) ]
66172mod tests {
67173 use super :: * ;
68174
175+ #[ test]
176+ fn rgb_floats_basic_colors ( ) {
177+ assert_eq ! ( rgb_floats_to_hex( 1.0 , 0.0 , 0.0 ) , "#ff0000" ) ;
178+ assert_eq ! ( rgb_floats_to_hex( 0.0 , 1.0 , 0.0 ) , "#00ff00" ) ;
179+ assert_eq ! ( rgb_floats_to_hex( 0.0 , 0.0 , 1.0 ) , "#0000ff" ) ;
180+ assert_eq ! ( rgb_floats_to_hex( 0.0 , 0.0 , 0.0 ) , "#000000" ) ;
181+ assert_eq ! ( rgb_floats_to_hex( 1.0 , 1.0 , 1.0 ) , "#ffffff" ) ;
182+ }
183+
184+ #[ test]
185+ fn rgb_floats_clamps_out_of_range ( ) {
186+ assert_eq ! ( rgb_floats_to_hex( -0.5 , 1.5 , 0.5 ) , "#00ff80" ) ;
187+ }
188+
69189 #[ test]
70190 fn gnome_accent_names_resolve ( ) {
71191 assert_eq ! ( gnome_accent_name_to_hex( "blue" ) , Some ( "#3584e4" ) ) ;
@@ -80,9 +200,46 @@ mod tests {
80200 }
81201
82202 #[ test]
83- fn read_accent_color_returns_hex ( ) {
203+ fn read_accent_color_returns_valid_hex ( ) {
84204 let color = read_accent_color ( ) ;
85205 assert ! ( color. starts_with( '#' ) ) ;
86- assert ! ( color. len( ) == 7 ) ;
206+ assert_eq ! ( color. len( ) , 7 ) ;
207+ }
208+
209+ #[ test]
210+ fn extract_rgb_from_nested_variants ( ) {
211+ // Simulate portal response: Variant(Variant((0.5, 0.5, 0.5)))
212+ let structure = zbus:: zvariant:: StructureBuilder :: new ( )
213+ . add_field ( 0.5_f64 )
214+ . add_field ( 0.5_f64 )
215+ . add_field ( 0.5_f64 )
216+ . build ( )
217+ . unwrap ( ) ;
218+ let inner = Value :: Structure ( structure) ;
219+ let wrapped = Value :: Value ( Box :: new ( Value :: Value ( Box :: new ( inner) ) ) ) ;
220+
221+ let result = extract_rgb ( & wrapped) ;
222+ assert_eq ! ( result, Some ( ( 0.5 , 0.5 , 0.5 ) ) ) ;
223+ }
224+
225+ #[ test]
226+ fn extract_rgb_wrong_field_count_returns_none ( ) {
227+ let structure = zbus:: zvariant:: StructureBuilder :: new ( )
228+ . add_field ( 0.5_f64 )
229+ . add_field ( 0.5_f64 )
230+ . build ( )
231+ . unwrap ( ) ;
232+ assert_eq ! ( extract_rgb( & Value :: Structure ( structure) ) , None ) ;
233+ }
234+
235+ #[ test]
236+ fn extract_rgb_wrong_type_returns_none ( ) {
237+ let structure = zbus:: zvariant:: StructureBuilder :: new ( )
238+ . add_field ( "not a float" )
239+ . add_field ( 0.5_f64 )
240+ . add_field ( 0.5_f64 )
241+ . build ( )
242+ . unwrap ( ) ;
243+ assert_eq ! ( extract_rgb( & Value :: Structure ( structure) ) , None ) ;
87244 }
88245}
0 commit comments