mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-26 05:14:11 +02:00
fix(appearance): detect the macOS system appearance via NSApp.effectiveAppearance (#603)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ce8cb5e7bc
commit
aa76d501f0
Generated
+3
@@ -11723,7 +11723,10 @@ name = "yaak-system-appearance"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dark-light",
|
"dark-light",
|
||||||
|
"dispatch2",
|
||||||
"log 0.4.29",
|
"log 0.4.29",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation 0.3.1",
|
||||||
"tauri",
|
"tauri",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1367,6 +1367,16 @@ pub fn run() {
|
|||||||
debug!("Launched Yaak {:?}", info);
|
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::SystemAppearanceState>()
|
||||||
|
{
|
||||||
|
yaak_system_appearance::emit_change(app_handle, &state);
|
||||||
|
}
|
||||||
|
}
|
||||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
if let Some(state) =
|
if let Some(state) =
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dark-light = "2.0.0"
|
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]
|
[dependencies]
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
tauri = { workspace = true }
|
tauri = { workspace = true }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(target_os = "linux")]
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[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 INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
||||||
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
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);
|
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[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
|
/// Detect the appearance the OS prefers, independent of any appearance that has
|
||||||
/// been forced onto app windows (which is what the webview itself reports).
|
/// 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<Appearance> {
|
pub fn system_appearance() -> Option<Appearance> {
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if let Some(appearance) = gsettings_system_appearance() {
|
if let Some(appearance) = gsettings_system_appearance() {
|
||||||
return Some(appearance);
|
return Some(appearance);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On macOS this reads AppleInterfaceStyle from the global user defaults
|
|
||||||
match dark_light::detect() {
|
match dark_light::detect() {
|
||||||
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
|
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
|
||||||
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
|
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
|
||||||
@@ -66,11 +64,69 @@ pub fn system_appearance() -> Option<Appearance> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Appearance> {
|
||||||
|
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<Appearance> {
|
||||||
|
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")))]
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
pub fn system_appearance() -> Option<Appearance> {
|
pub fn system_appearance() -> Option<Appearance> {
|
||||||
None
|
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"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
||||||
let last_appearance = system_appearance();
|
let last_appearance = system_appearance();
|
||||||
@@ -80,13 +136,19 @@ pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceSta
|
|||||||
}
|
}
|
||||||
|
|
||||||
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
|
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
|
||||||
let thread_state = state.clone();
|
|
||||||
let _ = std::thread::spawn(move || {
|
#[cfg(target_os = "linux")]
|
||||||
loop {
|
{
|
||||||
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
let thread_state = state.clone();
|
||||||
emit_change(&app_handle, &thread_state);
|
let _ = std::thread::spawn(move || {
|
||||||
}
|
loop {
|
||||||
});
|
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
||||||
|
emit_change(&app_handle, &thread_state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let _ = app_handle;
|
||||||
|
|
||||||
Some(state)
|
Some(state)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user