11use std:: sync:: { Arc , Mutex } ;
2- #[ cfg( any ( target_os = "linux" , target_os = "macos" ) ) ]
2+ #[ cfg( target_os = "linux" ) ]
33use std:: time:: Duration ;
44
55#[ cfg( any( target_os = "linux" , target_os = "macos" ) ) ]
@@ -11,7 +11,7 @@ use tauri::{AppHandle, Runtime};
1111pub const INITIAL_APPEARANCE_GLOBAL : & str = "__YAAK_INITIAL_APPEARANCE__" ;
1212pub const SYSTEM_APPEARANCE_CHANGE_EVENT : & str = "system_appearance_change" ;
1313
14- #[ cfg( any ( target_os = "linux" , target_os = "macos" ) ) ]
14+ #[ cfg( target_os = "linux" ) ]
1515const 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" ) ]
5151pub 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" ) ) ) ]
70123pub 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" ) ) ]
75131pub 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