From aa76d501f09d44de4a2c6b0d1f3968e3242bcfc0 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 21 Aug 2026 00:04:26 -0700 Subject: [PATCH] fix(appearance): detect the macOS system appearance via NSApp.effectiveAppearance (#603) Co-authored-by: Claude Opus 5 --- Cargo.lock | 3 + crates-tauri/yaak-app-client/src/lib.rs | 10 +++ .../yaak-system-appearance/Cargo.toml | 7 +- .../yaak-system-appearance/src/lib.rs | 86 ++++++++++++++++--- 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c12ef146..e3f6dbff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11723,7 +11723,10 @@ name = "yaak-system-appearance" version = "0.1.0" dependencies = [ "dark-light", + "dispatch2", "log 0.4.29", + "objc2-app-kit", + "objc2-foundation 0.3.1", "tauri", ] diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 952b348a..ebfbcc2a 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -1367,6 +1367,16 @@ pub fn run() { debug!("Launched Yaak {:?}", info); }); } + RunEvent::WindowEvent { event: WindowEvent::ThemeChanged(_), .. } => { + // On macOS this is how OS appearance changes arrive: tao observes + // AppleInterfaceThemeChangedNotification and emits it for every window + #[cfg(any(target_os = "linux", target_os = "macos"))] + if let Some(state) = + app_handle.try_state::() + { + yaak_system_appearance::emit_change(app_handle, &state); + } + } RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => { #[cfg(any(target_os = "linux", target_os = "macos"))] if let Some(state) = diff --git a/crates-tauri/yaak-system-appearance/Cargo.toml b/crates-tauri/yaak-system-appearance/Cargo.toml index 031c96f4..9bc646c3 100644 --- a/crates-tauri/yaak-system-appearance/Cargo.toml +++ b/crates-tauri/yaak-system-appearance/Cargo.toml @@ -4,9 +4,14 @@ version = "0.1.0" edition = "2024" publish = false -[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] +[target.'cfg(target_os = "linux")'.dependencies] dark-light = "2.0.0" +[target.'cfg(target_os = "macos")'.dependencies] +dispatch2 = "0.3.0" +objc2-app-kit = { version = "0.3.1", features = ["NSAppearance", "NSApplication", "NSResponder"] } +objc2-foundation = { version = "0.3.1", features = ["NSArray", "NSString", "NSUserDefaults"] } + [dependencies] log = { workspace = true } tauri = { workspace = true } diff --git a/crates-tauri/yaak-system-appearance/src/lib.rs b/crates-tauri/yaak-system-appearance/src/lib.rs index 0b68880a..7aa094a9 100644 --- a/crates-tauri/yaak-system-appearance/src/lib.rs +++ b/crates-tauri/yaak-system-appearance/src/lib.rs @@ -1,5 +1,5 @@ use std::sync::{Arc, Mutex}; -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(target_os = "linux")] use std::time::Duration; #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -11,7 +11,7 @@ use tauri::{AppHandle, Runtime}; pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__"; pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change"; -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(target_os = "linux")] const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -47,14 +47,12 @@ pub fn initialization_script(appearance: Appearance) -> String { /// Detect the appearance the OS prefers, independent of any appearance that has /// been forced onto app windows (which is what the webview itself reports). -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(target_os = "linux")] pub fn system_appearance() -> Option { - #[cfg(target_os = "linux")] if let Some(appearance) = gsettings_system_appearance() { return Some(appearance); } - // On macOS this reads AppleInterfaceStyle from the global user defaults match dark_light::detect() { Ok(dark_light::Mode::Dark) => Some(Appearance::Dark), Ok(dark_light::Mode::Light) => Some(Appearance::Light), @@ -66,11 +64,69 @@ pub fn system_appearance() -> Option { } } +/// Detect the appearance the OS prefers, independent of any appearance that has +/// been forced onto app windows (which is what the webview itself reports). +/// +/// This asks AppKit for the application's effective appearance, the same source tauri +/// uses for `window.theme()`, instead of reading `AppleInterfaceStyle` from the user +/// defaults: macOS 27 no longer reliably writes that key when dark mode is on, so anything +/// reading it sees light mode. Appearances forced per window (yaak-mac-window) don't reach +/// `NSApp`, so this is the OS preference. +#[cfg(target_os = "macos")] +pub fn system_appearance() -> Option { + use objc2_app_kit::{NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication}; + use objc2_foundation::NSArray; + + // AppKit is main-thread only. Every caller runs there today; this keeps it correct if + // one ever doesn't. + dispatch2::run_on_main(|mtm| { + let app = NSApplication::sharedApplication(mtm); + + // An appearance forced on the whole app (tauri's `set_theme` does this) would make + // the effective appearance report the override instead of the OS preference. Nothing + // in Yaak does that, but fall back to the user defaults if something ever does. + // + // SAFETY: Called on the main thread with the shared application + if unsafe { app.appearance() }.is_some() { + return defaults_appearance(); + } + + // SAFETY: The appearance names are AppKit constants that live for the whole process + let (dark, light) = unsafe { (NSAppearanceNameDarkAqua, NSAppearanceNameAqua) }; + let names = NSArray::from_slice(&[dark, light]); + let best = app.effectiveAppearance().bestMatchFromAppearancesWithNames(&names)?; + + // SAFETY: Both are valid strings + let is_dark = unsafe { best.isEqualToString(dark) }; + Some(if is_dark { Appearance::Dark } else { Appearance::Light }) + }) +} + +/// The appearance macOS persists to the global user defaults. Absent means light, except +/// on macOS 27, which stopped reliably writing the key. Only used when the effective +/// appearance is forced and can't be trusted. +#[cfg(target_os = "macos")] +fn defaults_appearance() -> Option { + use objc2_foundation::{NSUserDefaults, ns_string}; + + // SAFETY: The standard defaults are a process-wide singleton and the key is a valid string + let style = unsafe { + NSUserDefaults::standardUserDefaults().stringForKey(ns_string!("AppleInterfaceStyle")) + }; + + // SAFETY: Both are valid strings + let is_dark = style.is_some_and(|style| unsafe { style.isEqualToString(ns_string!("Dark")) }); + Some(if is_dark { Appearance::Dark } else { Appearance::Light }) +} + #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn system_appearance() -> Option { None } +/// Start tracking the OS appearance. Linux polls for changes. macOS gets them from tauri's +/// `WindowEvent::ThemeChanged` (tao observes `AppleInterfaceThemeChangedNotification`), which +/// the app forwards to [`emit_change`], so no thread is needed there. #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn watch(app_handle: AppHandle) -> Option { let last_appearance = system_appearance(); @@ -80,13 +136,19 @@ pub fn watch(app_handle: AppHandle) -> Option