77//! Also observes the portal's `SettingChanged` signal for live accent color
88//! updates, matching the macOS `NSSystemColorsDidChangeNotification` behavior.
99
10+ use std:: time:: Duration ;
11+
1012use log:: { debug, info, warn} ;
1113use tauri:: { AppHandle , Emitter , Runtime } ;
1214use zbus:: zvariant:: { OwnedValue , Value } ;
@@ -20,6 +22,13 @@ const PORTAL_IFACE: &str = "org.freedesktop.portal.Settings";
2022const APPEARANCE_NS : & str = "org.freedesktop.appearance" ;
2123const ACCENT_KEY : & str = "accent-color" ;
2224
25+ /// Hard cap on each probe. A healthy local session-bus / gsettings responds in milliseconds;
26+ /// anything slower than this means a misconfigured environment (orphan socket with no daemon,
27+ /// stalled subprocess) and we should fall through to the next tier instead of blocking app
28+ /// startup. Without this cap, `zbus::Connection::session()` can hang indefinitely on a
29+ /// half-configured D-Bus (observed at 120 s+ on the GitHub Actions Ubuntu runner).
30+ const PROBE_TIMEOUT : Duration = Duration :: from_millis ( 500 ) ;
31+
2332/// Converts sRGB floats in [0, 1] to a `#rrggbb` hex string.
2433fn rgb_floats_to_hex ( r : f64 , g : f64 , b : f64 ) -> String {
2534 let r8 = ( r. clamp ( 0.0 , 1.0 ) * 255.0 ) . round ( ) as u8 ;
@@ -55,13 +64,18 @@ fn extract_rgb(value: &Value<'_>) -> Option<(f64, f64, f64)> {
5564}
5665
5766/// 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) ?;
67+ /// Bounded by `PROBE_TIMEOUT` so a stalled session bus can't hang the caller.
68+ async fn read_accent_color_portal ( ) -> Option < String > {
69+ let probe = async {
70+ let conn = zbus:: Connection :: session ( ) . await . ok ( ) ?;
71+ let proxy = zbus:: Proxy :: new ( & conn, PORTAL_DEST , PORTAL_PATH , PORTAL_IFACE )
72+ . await
73+ . ok ( ) ?;
74+ let reply: OwnedValue = proxy. call ( "ReadOne" , & ( APPEARANCE_NS , ACCENT_KEY ) ) . await . ok ( ) ?;
75+ let ( r, g, b) = extract_rgb ( & reply) ?;
76+ Some ( ( r, g, b) )
77+ } ;
78+ let ( r, g, b) = tokio:: time:: timeout ( PROBE_TIMEOUT , probe) . await . ok ( ) . flatten ( ) ?;
6579 let hex = rgb_floats_to_hex ( r, g, b) ;
6680 debug ! ( "XDG Portal accent color: ({r:.3}, {g:.3}, {b:.3}) -> {hex}" ) ;
6781 Some ( hex)
@@ -84,11 +98,12 @@ fn gnome_accent_name_to_hex(name: &str) -> Option<&'static str> {
8498}
8599
86100/// Reads accent color via `gsettings` (older GNOME without portal support).
87- fn read_accent_color_gsettings ( ) -> Option < String > {
88- let output = std:: process:: Command :: new ( "gsettings" )
101+ /// Bounded by `PROBE_TIMEOUT` so a hung gsettings subprocess can't block startup.
102+ async fn read_accent_color_gsettings ( ) -> Option < String > {
103+ let probe = tokio:: process:: Command :: new ( "gsettings" )
89104 . args ( [ "get" , "org.gnome.desktop.interface" , "accent-color" ] )
90- . output ( )
91- . ok ( ) ?;
105+ . output ( ) ;
106+ let output = tokio :: time :: timeout ( PROBE_TIMEOUT , probe ) . await . ok ( ) ? . ok ( ) ?;
92107
93108 if !output. status . success ( ) {
94109 let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
@@ -107,13 +122,16 @@ fn read_accent_color_gsettings() -> Option<String> {
107122}
108123
109124/// 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 ( ) {
125+ /// Each tier is wrapped in `PROBE_TIMEOUT` (500 ms) so a half-configured session
126+ /// bus or a stalled `gsettings` subprocess can't wedge the caller — at worst we
127+ /// pay ~1 s total in the pathological case before settling on the brand fallback.
128+ async fn read_accent_color ( ) -> String {
129+ if let Some ( hex) = read_accent_color_portal ( ) . await {
112130 return hex;
113131 }
114132 debug ! ( "XDG Portal accent color not available, trying gsettings" ) ;
115133
116- if let Some ( hex) = read_accent_color_gsettings ( ) {
134+ if let Some ( hex) = read_accent_color_gsettings ( ) . await {
117135 return hex;
118136 }
119137 debug ! ( "gsettings accent color not available, using Cmdr brand gold" ) ;
@@ -124,17 +142,16 @@ fn read_accent_color() -> String {
124142/// Tauri command: returns the current Linux accent color as a hex string.
125143#[ tauri:: command]
126144#[ specta:: specta]
127- pub fn get_accent_color ( ) -> String {
128- read_accent_color ( )
145+ pub async fn get_accent_color ( ) -> String {
146+ read_accent_color ( ) . await
129147}
130148
131149/// Starts observing XDG Portal `SettingChanged` signal for live accent color updates.
132150/// Emits `accent-color-changed` events to the frontend, matching macOS behavior.
133151pub fn observe_accent_color_changes < R : Runtime > ( app_handle : AppHandle < R > ) {
134- let initial = read_accent_color ( ) ;
135- debug ! ( "Linux accent color: {initial}" ) ;
136-
137152 tauri:: async_runtime:: spawn ( async move {
153+ let initial = read_accent_color ( ) . await ;
154+ debug ! ( "Linux accent color: {initial}" ) ;
138155 if let Err ( e) = watch_portal_signal ( app_handle) . await {
139156 debug ! ( "Portal accent color watcher not available: {e}" ) ;
140157 }
@@ -201,11 +218,40 @@ mod tests {
201218 assert_eq ! ( gnome_accent_name_to_hex( "" ) , None ) ;
202219 }
203220
204- #[ test]
205- fn read_accent_color_returns_valid_hex ( ) {
206- let color = read_accent_color ( ) ;
207- assert ! ( color. starts_with( '#' ) ) ;
208- assert_eq ! ( color. len( ) , 7 ) ;
221+ /// Verifies the two contracts that matter:
222+ /// 1. `read_accent_color` always returns within the combined `PROBE_TIMEOUT`
223+ /// budget (`portal` + `gsettings` ≤ 2 × 500 ms = 1 s), so a flaky
224+ /// session-bus or hung subprocess can never block app startup.
225+ /// 2. The result is a valid `#rrggbb` hex string, regardless of which
226+ /// tier produced it (portal / gsettings / brand fallback).
227+ ///
228+ /// Replaces the older `read_accent_color_returns_valid_hex`, which had the
229+ /// same shape assertions but **no timeout assertion** and called the
230+ /// unbounded blocking zbus connect. That hung CI for 120 s when the
231+ /// GitHub Actions Ubuntu runner shipped an orphan session-bus socket
232+ /// (path set, no daemon serving). The new timeout in production code makes
233+ /// the function honest, and this test pins it down so the regression can't
234+ /// come back.
235+ ///
236+ /// Asserting a `<2 s` wall-clock with a `500 ms` budget gives plenty of
237+ /// slack for slow CI runners / heavy parallelism while still catching the
238+ /// "blocking forever" regression — anything close to 2 s means a probe
239+ /// stopped honoring its timeout.
240+ #[ tokio:: test]
241+ async fn read_accent_color_is_bounded_and_returns_valid_hex ( ) {
242+ let start = std:: time:: Instant :: now ( ) ;
243+ let color = read_accent_color ( ) . await ;
244+ let elapsed = start. elapsed ( ) ;
245+
246+ assert ! (
247+ elapsed < std:: time:: Duration :: from_secs( 2 ) ,
248+ "read_accent_color took {elapsed:?}, expected < 2 s (PROBE_TIMEOUT={PROBE_TIMEOUT:?})" ,
249+ ) ;
250+ assert ! ( color. starts_with( '#' ) , "expected #rrggbb, got {color}" ) ;
251+ assert_eq ! ( color. len( ) , 7 , "expected #rrggbb (7 chars), got {color}" ) ;
252+ for c in color. chars ( ) . skip ( 1 ) {
253+ assert ! ( c. is_ascii_hexdigit( ) , "invalid hex digit in {color}" ) ;
254+ }
209255 }
210256
211257 #[ test]
0 commit comments