270 lines
7.7 KiB
Rust
270 lines
7.7 KiB
Rust
use serde::Serialize;
|
|
use tauri::{AppHandle, Emitter, Runtime};
|
|
|
|
const ACCENT_COLOR_CHANGED: &str = "system-accent-color-changed";
|
|
|
|
#[cfg(target_os = "macos")]
|
|
thread_local! {
|
|
static STOP_ACCENT_COLOR_WATCHER: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
|
|
const { std::cell::RefCell::new(None) };
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct AccentColor {
|
|
hex: String,
|
|
r: u8,
|
|
g: u8,
|
|
b: u8,
|
|
}
|
|
|
|
impl AccentColor {
|
|
fn new(r: u8, g: u8, b: u8) -> Self {
|
|
Self {
|
|
hex: format!("#{r:02X}{g:02X}{b:02X}"),
|
|
r,
|
|
g,
|
|
b,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
|
fn unit_to_u8(value: f64) -> Result<u8, String> {
|
|
if !value.is_finite() || !(0.0..=1.0).contains(&value) {
|
|
return Err(format!("Invalid RGB component: {value}"));
|
|
}
|
|
|
|
Ok((value * 255.0).round() as u8)
|
|
}
|
|
|
|
fn emit_accent_color<R: Runtime>(app: &AppHandle<R>, color: AccentColor) {
|
|
if let Err(error) = app.emit(ACCENT_COLOR_CHANGED, color) {
|
|
tracing::warn!("Failed to emit system accent color: {error}");
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn read_accent_color() -> Result<AccentColor, String> {
|
|
use windows::UI::ViewManagement::{UIColorType, UISettings};
|
|
|
|
let settings = UISettings::new()
|
|
.map_err(|error| format!("Failed to initialize UISettings: {error}"))?;
|
|
let color =
|
|
settings
|
|
.GetColorValue(UIColorType::Accent)
|
|
.map_err(|error| {
|
|
format!("Failed to read Windows accent color: {error}")
|
|
})?;
|
|
|
|
Ok(AccentColor::new(color.R, color.G, color.B))
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn start_accent_color_watcher<R: Runtime>(app: AppHandle<R>) {
|
|
std::thread::spawn(move || {
|
|
use windows::{
|
|
Foundation::TypedEventHandler, UI::ViewManagement::UISettings,
|
|
core::IInspectable,
|
|
};
|
|
|
|
let settings = match UISettings::new() {
|
|
Ok(settings) => settings,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to initialize system accent color watcher: {error}"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let app_handle = app.clone();
|
|
let handler = TypedEventHandler::<UISettings, IInspectable>::new(
|
|
move |_sender, _args| {
|
|
if let Ok(color) = read_accent_color() {
|
|
emit_accent_color(&app_handle, color);
|
|
}
|
|
Ok(())
|
|
},
|
|
);
|
|
let token = match settings.ColorValuesChanged(&handler) {
|
|
Ok(token) => token,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to listen for Windows accent color changes: {error}"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
std::thread::park();
|
|
let _ = settings.RemoveColorValuesChanged(token);
|
|
});
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn read_accent_color() -> Result<AccentColor, String> {
|
|
use objc2_app_kit::{NSColor, NSColorSpace};
|
|
|
|
let color = NSColor::controlAccentColor();
|
|
let srgb = NSColorSpace::sRGBColorSpace();
|
|
let color = color.colorUsingColorSpace(&srgb).ok_or_else(|| {
|
|
"Unable to convert the macOS accent color to sRGB".to_string()
|
|
})?;
|
|
|
|
Ok(AccentColor::new(
|
|
unit_to_u8(color.redComponent() as f64)?,
|
|
unit_to_u8(color.greenComponent() as f64)?,
|
|
unit_to_u8(color.blueComponent() as f64)?,
|
|
))
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn start_accent_color_watcher<R: Runtime>(app: AppHandle<R>) {
|
|
use std::ptr::NonNull;
|
|
|
|
use block2::RcBlock;
|
|
use objc2_app_kit::NSSystemColorsDidChangeNotification;
|
|
use objc2_foundation::{
|
|
NSNotification, NSNotificationCenter, NSOperationQueue,
|
|
};
|
|
|
|
let center = NSNotificationCenter::defaultCenter();
|
|
let queue = NSOperationQueue::mainQueue();
|
|
let block: RcBlock<dyn Fn(NonNull<NSNotification>)> =
|
|
RcBlock::new(move |_notification| {
|
|
if let Ok(color) = read_accent_color() {
|
|
emit_accent_color(&app, color);
|
|
}
|
|
});
|
|
|
|
let observer = unsafe {
|
|
center.addObserverForName_object_queue_usingBlock(
|
|
Some(NSSystemColorsDidChangeNotification),
|
|
None,
|
|
Some(&queue),
|
|
&block,
|
|
)
|
|
};
|
|
let stop = Box::new(move || unsafe {
|
|
center.removeObserver((&*observer).as_ref());
|
|
});
|
|
|
|
STOP_ACCENT_COLOR_WATCHER.with(|watcher| {
|
|
if let Some(stop) = watcher.borrow_mut().replace(stop) {
|
|
stop();
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn stop_accent_color_watcher() {
|
|
STOP_ACCENT_COLOR_WATCHER.with(|watcher| {
|
|
if let Some(stop) = watcher.borrow_mut().take() {
|
|
stop();
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn stop_accent_color_watcher() {}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
async fn read_accent_color() -> Result<AccentColor, String> {
|
|
use ashpd::desktop::settings::Settings;
|
|
|
|
let settings = Settings::new().await.map_err(|error| {
|
|
format!("Unable to connect to the XDG portal: {error}")
|
|
})?;
|
|
let color = settings.accent_color().await.map_err(|error| {
|
|
format!("XDG portal does not provide accent-color: {error}")
|
|
})?;
|
|
|
|
Ok(AccentColor::new(
|
|
unit_to_u8(color.red())?,
|
|
unit_to_u8(color.green())?,
|
|
unit_to_u8(color.blue())?,
|
|
))
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn start_accent_color_watcher<R: Runtime>(app: AppHandle<R>) {
|
|
tauri::async_runtime::spawn(async move {
|
|
use ashpd::desktop::settings::Settings;
|
|
use futures::StreamExt;
|
|
|
|
let settings = match Settings::new().await {
|
|
Ok(settings) => settings,
|
|
Err(error) => {
|
|
tracing::warn!("Unable to connect to the XDG portal: {error}");
|
|
return;
|
|
}
|
|
};
|
|
let stream = match settings.receive_accent_color_changed().await {
|
|
Ok(stream) => stream,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Unable to listen for XDG accent-color changes: {error}"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
futures::pin_mut!(stream);
|
|
while let Some(color) = stream.next().await {
|
|
let color = match (
|
|
unit_to_u8(color.red()),
|
|
unit_to_u8(color.green()),
|
|
unit_to_u8(color.blue()),
|
|
) {
|
|
(Ok(r), Ok(g), Ok(b)) => AccentColor::new(r, g, b),
|
|
_ => continue,
|
|
};
|
|
emit_accent_color(&app, color);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn system_accent_color() -> Result<AccentColor, String> {
|
|
#[cfg(target_os = "windows")]
|
|
return read_accent_color();
|
|
|
|
#[cfg(target_os = "macos")]
|
|
return read_accent_color();
|
|
|
|
#[cfg(target_os = "linux")]
|
|
return read_accent_color().await;
|
|
|
|
#[allow(unreachable_code)]
|
|
Err("Unsupported operating system".to_string())
|
|
}
|
|
|
|
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|
tauri::plugin::Builder::new("system-accent")
|
|
.setup(|app, _api| {
|
|
start_accent_color_watcher(app.clone());
|
|
Ok(())
|
|
})
|
|
.on_drop(|_| stop_accent_color_watcher())
|
|
.invoke_handler(tauri::generate_handler![system_accent_color])
|
|
.build()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn normalized_rgb_components_are_rounded() {
|
|
assert_eq!(unit_to_u8(0.0).unwrap(), 0);
|
|
assert_eq!(unit_to_u8(0.5).unwrap(), 128);
|
|
assert_eq!(unit_to_u8(1.0).unwrap(), 255);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_normalized_rgb_components_are_rejected() {
|
|
for value in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
|
|
assert!(unit_to_u8(value).is_err());
|
|
}
|
|
}
|
|
}
|