Skip to content

Commit aa76d50

Browse files
gschierclaude
andauthored
fix(appearance): detect the macOS system appearance via NSApp.effectiveAppearance (#603)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ce8cb5e commit aa76d50

4 files changed

Lines changed: 93 additions & 13 deletions

File tree

Cargo.lock

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

crates-tauri/yaak-app-client/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,16 @@ pub fn run() {
13671367
debug!("Launched Yaak {:?}", info);
13681368
});
13691369
}
1370+
RunEvent::WindowEvent { event: WindowEvent::ThemeChanged(_), .. } => {
1371+
// On macOS this is how OS appearance changes arrive: tao observes
1372+
// AppleInterfaceThemeChangedNotification and emits it for every window
1373+
#[cfg(any(target_os = "linux", target_os = "macos"))]
1374+
if let Some(state) =
1375+
app_handle.try_state::<yaak_system_appearance::SystemAppearanceState>()
1376+
{
1377+
yaak_system_appearance::emit_change(app_handle, &state);
1378+
}
1379+
}
13701380
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
13711381
#[cfg(any(target_os = "linux", target_os = "macos"))]
13721382
if let Some(state) =

crates-tauri/yaak-system-appearance/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ version = "0.1.0"
44
edition = "2024"
55
publish = false
66

7-
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
7+
[target.'cfg(target_os = "linux")'.dependencies]
88
dark-light = "2.0.0"
99

10+
[target.'cfg(target_os = "macos")'.dependencies]
11+
dispatch2 = "0.3.0"
12+
objc2-app-kit = { version = "0.3.1", features = ["NSAppearance", "NSApplication", "NSResponder"] }
13+
objc2-foundation = { version = "0.3.1", features = ["NSArray", "NSString", "NSUserDefaults"] }
14+
1015
[dependencies]
1116
log = { workspace = true }
1217
tauri = { workspace = true }

crates-tauri/yaak-system-appearance/src/lib.rs

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::sync::{Arc, Mutex};
2-
#[cfg(any(target_os = "linux", target_os = "macos"))]
2+
#[cfg(target_os = "linux")]
33
use std::time::Duration;
44

55
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -11,7 +11,7 @@ use tauri::{AppHandle, Runtime};
1111
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
1212
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
1313

14-
#[cfg(any(target_os = "linux", target_os = "macos"))]
14+
#[cfg(target_os = "linux")]
1515
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
1616

1717
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -47,14 +47,12 @@ pub fn initialization_script(appearance: Appearance) -> String {
4747

4848
/// Detect the appearance the OS prefers, independent of any appearance that has
4949
/// been forced onto app windows (which is what the webview itself reports).
50-
#[cfg(any(target_os = "linux", target_os = "macos"))]
50+
#[cfg(target_os = "linux")]
5151
pub fn system_appearance() -> Option<Appearance> {
52-
#[cfg(target_os = "linux")]
5352
if let Some(appearance) = gsettings_system_appearance() {
5453
return Some(appearance);
5554
}
5655

57-
// On macOS this reads AppleInterfaceStyle from the global user defaults
5856
match dark_light::detect() {
5957
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
6058
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
@@ -66,11 +64,69 @@ pub fn system_appearance() -> Option<Appearance> {
6664
}
6765
}
6866

67+
/// Detect the appearance the OS prefers, independent of any appearance that has
68+
/// been forced onto app windows (which is what the webview itself reports).
69+
///
70+
/// This asks AppKit for the application's effective appearance, the same source tauri
71+
/// uses for `window.theme()`, instead of reading `AppleInterfaceStyle` from the user
72+
/// defaults: macOS 27 no longer reliably writes that key when dark mode is on, so anything
73+
/// reading it sees light mode. Appearances forced per window (yaak-mac-window) don't reach
74+
/// `NSApp`, so this is the OS preference.
75+
#[cfg(target_os = "macos")]
76+
pub fn system_appearance() -> Option<Appearance> {
77+
use objc2_app_kit::{NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication};
78+
use objc2_foundation::NSArray;
79+
80+
// AppKit is main-thread only. Every caller runs there today; this keeps it correct if
81+
// one ever doesn't.
82+
dispatch2::run_on_main(|mtm| {
83+
let app = NSApplication::sharedApplication(mtm);
84+
85+
// An appearance forced on the whole app (tauri's `set_theme` does this) would make
86+
// the effective appearance report the override instead of the OS preference. Nothing
87+
// in Yaak does that, but fall back to the user defaults if something ever does.
88+
//
89+
// SAFETY: Called on the main thread with the shared application
90+
if unsafe { app.appearance() }.is_some() {
91+
return defaults_appearance();
92+
}
93+
94+
// SAFETY: The appearance names are AppKit constants that live for the whole process
95+
let (dark, light) = unsafe { (NSAppearanceNameDarkAqua, NSAppearanceNameAqua) };
96+
let names = NSArray::from_slice(&[dark, light]);
97+
let best = app.effectiveAppearance().bestMatchFromAppearancesWithNames(&names)?;
98+
99+
// SAFETY: Both are valid strings
100+
let is_dark = unsafe { best.isEqualToString(dark) };
101+
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
102+
})
103+
}
104+
105+
/// The appearance macOS persists to the global user defaults. Absent means light, except
106+
/// on macOS 27, which stopped reliably writing the key. Only used when the effective
107+
/// appearance is forced and can't be trusted.
108+
#[cfg(target_os = "macos")]
109+
fn defaults_appearance() -> Option<Appearance> {
110+
use objc2_foundation::{NSUserDefaults, ns_string};
111+
112+
// SAFETY: The standard defaults are a process-wide singleton and the key is a valid string
113+
let style = unsafe {
114+
NSUserDefaults::standardUserDefaults().stringForKey(ns_string!("AppleInterfaceStyle"))
115+
};
116+
117+
// SAFETY: Both are valid strings
118+
let is_dark = style.is_some_and(|style| unsafe { style.isEqualToString(ns_string!("Dark")) });
119+
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
120+
}
121+
69122
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
70123
pub fn system_appearance() -> Option<Appearance> {
71124
None
72125
}
73126

127+
/// Start tracking the OS appearance. Linux polls for changes. macOS gets them from tauri's
128+
/// `WindowEvent::ThemeChanged` (tao observes `AppleInterfaceThemeChangedNotification`), which
129+
/// the app forwards to [`emit_change`], so no thread is needed there.
74130
#[cfg(any(target_os = "linux", target_os = "macos"))]
75131
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
76132
let last_appearance = system_appearance();
@@ -80,13 +136,19 @@ pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceSta
80136
}
81137

82138
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
83-
let thread_state = state.clone();
84-
let _ = std::thread::spawn(move || {
85-
loop {
86-
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
87-
emit_change(&app_handle, &thread_state);
88-
}
89-
});
139+
140+
#[cfg(target_os = "linux")]
141+
{
142+
let thread_state = state.clone();
143+
let _ = std::thread::spawn(move || {
144+
loop {
145+
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
146+
emit_change(&app_handle, &thread_state);
147+
}
148+
});
149+
}
150+
#[cfg(target_os = "macos")]
151+
let _ = app_handle;
90152

91153
Some(state)
92154
}

0 commit comments

Comments
 (0)