Skip to content

Commit e65d993

Browse files
committed
Linux: accent color via XDG Desktop Portal D-Bus
1 parent 9c51fa9 commit e65d993

8 files changed

Lines changed: 235 additions & 46 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ tauri-plugin-window-state = "2"
8787
trash = "5.2"
8888
# Credential storage via Linux secret service (GNOME Keyring / KDE Wallet)
8989
keyring = "3"
90+
# D-Bus client for XDG Desktop Portal (accent color, appearance settings)
91+
zbus = "5"
9092

9193
[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies]
9294
# MTP (Android device) support via pure Rust implementation
Lines changed: 189 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,73 @@
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).
1015
const 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.
1471
fn 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)]
66172
mod 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
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ pub fn run() {
247247
// Observe system accent color changes and emit events to frontend
248248
#[cfg(target_os = "macos")]
249249
accent_color::observe_accent_color_changes(app.handle().clone());
250+
#[cfg(target_os = "linux")]
251+
accent_color_linux::observe_accent_color_changes(app.handle().clone());
250252

251253
// Initialize font metrics for default font (system font at 12px)
252254
font_metrics::init_font_metrics(app.handle(), "system-400-12");

apps/desktop/src-tauri/src/permissions_linux.rs

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,46 @@ pub fn open_privacy_settings() -> Result<(), String> {
2222

2323
/// Opens the system appearance settings via the desktop environment.
2424
/// Detects the DE from `$XDG_CURRENT_DESKTOP` and launches the appropriate settings app.
25+
/// When the env var is empty (common when launching via SSH), tries commands in order.
26+
///
27+
/// Some settings apps (notably `gnome-control-center`) refuse to launch unless
28+
/// `XDG_CURRENT_DESKTOP` is set. We pass the expected value to the child process
29+
/// so it works even from SSH sessions where the variable isn't inherited.
30+
///
31+
/// Panel name note: the `background` panel contains style, accent color, and wallpaper
32+
/// settings on both Ubuntu and vanilla GNOME.
2533
#[tauri::command]
2634
pub fn open_appearance_settings() -> Result<(), String> {
2735
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().to_uppercase();
36+
log::debug!("open_appearance_settings: XDG_CURRENT_DESKTOP='{desktop}'");
2837

29-
let (cmd, args): (&str, &[&str]) = if desktop.contains("GNOME") {
30-
("gnome-control-center", &["appearance"])
38+
// (command, args, XDG_CURRENT_DESKTOP value the command expects)
39+
let candidates: &[(&str, &[&str], &str)] = if desktop.contains("GNOME") {
40+
&[("gnome-control-center", &["background"] as &[&str], "GNOME")]
3141
} else if desktop.contains("KDE") {
32-
("systemsettings", &["kcm_lookandfeel"])
42+
&[("systemsettings", &["kcm_lookandfeel"] as &[&str], "KDE")]
3343
} else if desktop.contains("XFCE") {
34-
("xfce4-appearance-settings", &[])
44+
&[("xfce4-appearance-settings", &[] as &[&str], "XFCE")]
3545
} else {
36-
return Err("Appearance settings are not available for your desktop environment.".to_string());
46+
// Unknown or empty DE (common via SSH) — try all in order
47+
&[
48+
("gnome-control-center", &["background"] as &[&str], "GNOME"),
49+
("systemsettings", &["kcm_lookandfeel"], "KDE"),
50+
("xfce4-appearance-settings", &[], "XFCE"),
51+
]
3752
};
3853

39-
std::process::Command::new(cmd)
40-
.args(args)
41-
.spawn()
42-
.map_err(|e| format!("Failed to open appearance settings: {e}"))?;
43-
Ok(())
54+
for (cmd, args, de_value) in candidates {
55+
log::debug!("open_appearance_settings: trying {cmd} {}", args.join(" "));
56+
match std::process::Command::new(cmd)
57+
.args(*args)
58+
.env("XDG_CURRENT_DESKTOP", de_value)
59+
.spawn()
60+
{
61+
Ok(_) => return Ok(()),
62+
Err(e) => log::debug!("open_appearance_settings: {cmd} failed: {e}"),
63+
}
64+
}
65+
66+
Err("Could not open appearance settings. No supported settings app found.".to_string())
4467
}

apps/desktop/src/lib/tauri-commands/storage.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import { invoke } from '@tauri-apps/api/core'
44
import type { VolumeInfo } from '../file-explorer/types'
5+
import { getAppLogger } from '$lib/logging/logger'
6+
7+
const log = getAppLogger('storage')
58

69
/** Default volume ID for the root filesystem */
710
export const DEFAULT_VOLUME_ID = 'root'
@@ -102,7 +105,7 @@ export async function openPrivacySettings(): Promise<void> {
102105
export async function openAppearanceSettings(): Promise<void> {
103106
try {
104107
await invoke('open_appearance_settings')
105-
} catch {
106-
// Command not available (non-macOS) - silently fail
108+
} catch (error) {
109+
log.warn('Failed to open appearance settings: {error}', { error })
107110
}
108111
}

docs/specs/linux-remaining-gaps.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Each section is self-contained and can be handed to an agent independently.
99
## Task list
1010

1111
- [x] 1. Quick Look and Get Info (small) — unbind shortcuts, no-op error paths
12-
- [ ] 2. Accent colors via XDG Desktop Portal (medium) — D-Bus call + live updates + fallback chain
12+
- [x] 2. Accent colors via XDG Desktop Portal (medium) — D-Bus call + live updates + fallback chain
1313
- [x] 3. Appearance settings opener (tiny) — DE-specific commands replacing broken `xdg-open`
1414
- [x] 4. Volume chooser shortcuts and key naming (small) — Alt+F1/F2, F2 rename fix, Super label
1515
- [x] 5. GTK menu mnemonics (small) — add `&` prefixes to `build_menu_linux()` labels
@@ -109,7 +109,7 @@ for Linux. Detect which is running via the `$XDG_CURRENT_DESKTOP` environment va
109109

110110
| DE | `$XDG_CURRENT_DESKTOP` | Open appearance settings |
111111
|----|----------------------|------------------------|
112-
| GNOME | `GNOME` or `ubuntu:GNOME` | `gnome-control-center appearance` |
112+
| GNOME | `GNOME` or `ubuntu:GNOME` | `gnome-control-center background` |
113113
| KDE | `KDE` | `systemsettings kcm_lookandfeel` |
114114
| XFCE | `XFCE` | `xfce4-appearance-settings` |
115115
| Sway | `sway` | No GUI — return descriptive error |

0 commit comments

Comments
 (0)