use napi_derive::napi;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[napi(string_enum)]
pub enum MacOSAppearance {
#[napi(value = "dark")]
Dark,
#[napi(value = "light")]
Light,
}
#[cfg(target_os = "macos")]
mod platform {
use std::{
ffi::{CStr, CString, c_char, c_void},
ptr,
sync::{Arc, mpsc},
thread::{self, JoinHandle},
};
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
use parking_lot::Mutex;
use super::MacOSAppearance;
type CFStringRef = *const c_void;
type CFTypeRef = *const c_void;
type CFNotificationCenterRef = *const c_void;
type CFRunLoopRef = *const c_void;
type CFRunLoopTimerRef = *const c_void;
type CFIndex = isize;
const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
const CF_NOTIFICATION_SUSPEND_DELIVERY: isize = 4;
#[repr(C)]
struct TimerContext {
version: CFIndex,
info: *mut c_void,
retain: *const c_void,
release: *const c_void,
copy_description: *const c_void,
}
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
static kCFPreferencesAnyApplication: CFStringRef;
static kCFRunLoopDefaultMode: CFStringRef;
fn CFPreferencesCopyAppValue(key: CFStringRef, app: CFStringRef) -> CFTypeRef;
fn CFStringCreateWithCString(
alloc: *const c_void,
c_str: *const c_char,
encoding: u32,
) -> CFStringRef;
fn CFStringGetCStringPtr(s: CFStringRef, encoding: u32) -> *const c_char;
fn CFStringGetCString(
s: CFStringRef,
buf: *mut c_char,
buf_size: CFIndex,
encoding: u32,
) -> bool;
fn CFRelease(cf: CFTypeRef);
fn CFGetTypeID(cf: CFTypeRef) -> u64;
fn CFStringGetTypeID() -> u64;
fn CFNotificationCenterGetDistributedCenter() -> CFNotificationCenterRef;
fn CFNotificationCenterAddObserver(
center: CFNotificationCenterRef,
observer: *const c_void,
callback: unsafe extern "C" fn(
CFNotificationCenterRef,
*const c_void,
CFStringRef,
*const c_void,
*const c_void,
),
name: CFStringRef,
object: *const c_void,
suspension_behavior: isize,
);
fn CFNotificationCenterRemoveEveryObserver(
center: CFNotificationCenterRef,
observer: *const c_void,
);
fn CFRunLoopGetCurrent() -> CFRunLoopRef;
fn CFRunLoopRun();
fn CFRunLoopStop(rl: CFRunLoopRef);
fn CFAbsoluteTimeGetCurrent() -> f64;
fn CFRunLoopTimerCreate(
allocator: *const c_void,
fire_date: f64,
interval: f64,
flags: u64,
order: CFIndex,
callout: unsafe extern "C" fn(CFRunLoopTimerRef, *mut c_void),
context: *const TimerContext,
) -> CFRunLoopTimerRef;
fn CFRunLoopAddTimer(rl: CFRunLoopRef, timer: CFRunLoopTimerRef, mode: CFStringRef);
fn CFRunLoopTimerInvalidate(timer: CFRunLoopTimerRef);
}
#[link(name = "Foundation", kind = "framework")]
unsafe extern "C" {}
fn create_cf_string(s: &str) -> CFStringRef {
let Ok(c_str) = CString::new(s) else {
return ptr::null();
};
unsafe { CFStringCreateWithCString(ptr::null(), c_str.as_ptr(), K_CF_STRING_ENCODING_UTF8) }
}
fn cf_string_to_string(s: CFStringRef) -> String {
unsafe {
let ptr = CFStringGetCStringPtr(s, K_CF_STRING_ENCODING_UTF8);
if !ptr.is_null() {
return CStr::from_ptr(ptr).to_string_lossy().into_owned();
}
let mut buf = [0u8; 256];
if CFStringGetCString(s, buf.as_mut_ptr().cast::<c_char>(), 256, K_CF_STRING_ENCODING_UTF8)
{
let len = buf.iter().position(|&b| b == 0).unwrap_or(0);
String::from_utf8_lossy(&buf[..len]).into_owned()
} else {
String::new()
}
}
}
pub fn detect_appearance() -> MacOSAppearance {
unsafe {
let key = create_cf_string("AppleInterfaceStyle");
if key.is_null() {
return MacOSAppearance::Light;
}
let value = CFPreferencesCopyAppValue(key, kCFPreferencesAnyApplication);
CFRelease(key);
if value.is_null() {
return MacOSAppearance::Light;
}
if CFGetTypeID(value) != CFStringGetTypeID() {
CFRelease(value);
return MacOSAppearance::Light;
}
let result = cf_string_to_string(value);
CFRelease(value);
if result == "Dark" {
MacOSAppearance::Dark
} else {
MacOSAppearance::Light
}
}
}
struct SendableRunLoop(CFRunLoopRef);
unsafe impl Send for SendableRunLoop {}
unsafe impl Sync for SendableRunLoop {}
struct CallbackCtx {
tsfn: ThreadsafeFunction<MacOSAppearance>,
last: Mutex<Option<MacOSAppearance>>,
}
impl CallbackCtx {
fn report_if_changed(&self) {
let appearance = detect_appearance();
let mut last = self.last.lock();
if last.as_ref() != Some(&appearance) {
*last = Some(appearance);
self
.tsfn
.call(Ok(appearance), ThreadsafeFunctionCallMode::NonBlocking);
}
}
}
unsafe extern "C" fn on_notification(
_center: CFNotificationCenterRef,
observer: *const c_void,
_name: CFStringRef,
_object: *const c_void,
_user_info: *const c_void,
) {
let ctx = unsafe { &*observer.cast::<CallbackCtx>() };
ctx.report_if_changed();
}
unsafe extern "C" fn on_timer(_timer: CFRunLoopTimerRef, info: *mut c_void) {
let ctx = unsafe { &*(info as *const CallbackCtx) };
ctx.report_if_changed();
}
const POLL_INTERVAL_SECS: f64 = 2.0;
pub struct ObserverInner {
run_loop: Arc<Mutex<Option<SendableRunLoop>>>,
thread: Option<JoinHandle<()>>,
}
impl ObserverInner {
pub fn start(tsfn: ThreadsafeFunction<MacOSAppearance>) -> Self {
let run_loop: Arc<Mutex<Option<SendableRunLoop>>> = Arc::new(Mutex::new(None));
let rl_clone = run_loop.clone();
let (tx, rx) = mpsc::sync_channel::<()>(1);
let handle = thread::spawn(move || {
unsafe {
let rl = CFRunLoopGetCurrent();
*rl_clone.lock() = Some(SendableRunLoop(rl));
let _ = tx.send(());
let ctx = Box::new(CallbackCtx { tsfn, last: Mutex::new(None) });
let ctx_ptr = Box::into_raw(ctx);
let center = CFNotificationCenterGetDistributedCenter();
let name = create_cf_string("AppleInterfaceThemeChangedNotification");
CFNotificationCenterAddObserver(
center,
ctx_ptr.cast(),
on_notification,
name,
ptr::null(),
CF_NOTIFICATION_SUSPEND_DELIVERY,
);
if !name.is_null() {
CFRelease(name);
}
let timer_ctx = TimerContext {
version: 0,
info: ctx_ptr.cast::<c_void>(),
retain: ptr::null(),
release: ptr::null(),
copy_description: ptr::null(),
};
let timer = CFRunLoopTimerCreate(
ptr::null(),
CFAbsoluteTimeGetCurrent() + POLL_INTERVAL_SECS,
POLL_INTERVAL_SECS,
0,
0,
on_timer,
&raw const timer_ctx,
);
CFRunLoopAddTimer(rl, timer, kCFRunLoopDefaultMode);
(*ctx_ptr).report_if_changed();
CFRunLoopRun();
CFRunLoopTimerInvalidate(timer);
CFRelease(timer);
CFNotificationCenterRemoveEveryObserver(center, ctx_ptr.cast());
drop(Box::from_raw(ctx_ptr));
}
});
rx.recv()
.expect("observer startup channel stays alive until run loop is stored");
Self { run_loop, thread: Some(handle) }
}
pub fn stop(&mut self) {
let rl = self.run_loop.lock().take();
if let Some(rl) = rl {
unsafe {
CFRunLoopStop(rl.0);
}
}
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl Drop for ObserverInner {
fn drop(&mut self) {
self.stop();
}
}
}
#[napi(js_name = "detectMacOSAppearance")]
#[allow(clippy::missing_const_for_fn, reason = "napi macro is incompatible with const fn")]
pub fn detect_macos_appearance() -> Option<MacOSAppearance> {
#[cfg(target_os = "macos")]
{
Some(platform::detect_appearance())
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
#[napi]
pub struct MacAppearanceObserver {
#[cfg(target_os = "macos")]
inner: Option<platform::ObserverInner>,
}
#[napi]
impl MacAppearanceObserver {
#[napi(factory)]
pub fn start(
#[napi(ts_arg_type = "(err: null | Error, appearance: MacOSAppearance) => void")]
callback: napi::threadsafe_function::ThreadsafeFunction<MacOSAppearance>,
) -> napi::Result<Self> {
#[cfg(target_os = "macos")]
{
Ok(Self { inner: Some(platform::ObserverInner::start(callback)) })
}
#[cfg(not(target_os = "macos"))]
{
let _ = callback;
Ok(Self {})
}
}
#[napi]
#[allow(clippy::missing_const_for_fn, reason = "napi macro is incompatible with const fn")]
pub fn stop(&mut self) {
#[cfg(target_os = "macos")]
if let Some(inner) = &mut self.inner {
inner.stop();
}
}
}