Skip to main content

ashlar_native/
mac_shell.rs

1//! Fixes and additions to the windowing library's macOS application shell (winit 0.30):
2//!
3//! - [`guard_appearance`]: the window delegate observes `effectiveAppearance` and casts the old
4//!   and new values to `NSAppearance` unchecked. While the screen is being captured
5//!   (ScreenCaptureKit, ReplayKit) AppKit can report a nil appearance, and the cast ends the
6//!   process. The guard checks the values first and drops such a change.
7//! - [`listen_reopen`]: clicking the Dock icon (or opening the app again in Finder) asks the
8//!   application delegate `applicationShouldHandleReopen:hasVisibleWindows:`, which winit doesn't
9//!   answer, so a hidden window stays hidden. The method is added and reported, so the app shows
10//!   and focuses its window.
11//! - [`quiet_requested`]: `<PREFIX>_QUIET=1` asks for a launch that doesn't take the focus, for
12//!   agents and scripts that rebuild and relaunch the app in a loop. The app then opens its window
13//!   without activating (an ashlar app does this itself).
14//!
15//! The first two return [`NativeError::Unsupported`] off macOS.
16
17use std::sync::mpsc::{Receiver, channel};
18
19use crate::{NativeError, Result, Waker};
20
21/// The Dock (or Finder) asked to reopen the app.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct Reopen {
24    /// AppKit's view: some window is visible (none: the app should show one).
25    pub has_visible_windows: bool,
26}
27
28/// `<env_prefix>_QUIET` is set to something other than empty, `0`, `false`, `no` or `off`.
29pub fn quiet_requested(env_prefix: &str) -> bool {
30    std::env::var(format!("{env_prefix}_QUIET")).is_ok_and(|v| quiet_value(&v))
31}
32
33fn quiet_value(v: &str) -> bool {
34    let v = v.trim();
35    !(v.is_empty() || v == "0" || ["false", "no", "off"].iter().any(|f| v.eq_ignore_ascii_case(f)))
36}
37
38/// Guard the window delegate's appearance observer (see the module docs). Call it on the main
39/// thread once a window exists (the delegate class is registered with the first window). Calling
40/// again does nothing.
41pub fn guard_appearance() -> Result<()> {
42    imp::guard_appearance()
43}
44
45/// Report Dock reopen requests: each one is queued on the returned receiver and `waker` is
46/// called. Main thread, once the event loop runs (winit's delegate must exist). Calling again
47/// moves the reports to the new receiver.
48pub fn listen_reopen(waker: Waker) -> Result<Receiver<Reopen>> {
49    let (tx, rx) = channel();
50    imp::listen_reopen(tx, waker)?;
51    Ok(rx)
52}
53
54#[cfg(target_os = "macos")]
55#[allow(unsafe_code)]
56mod imp {
57    use std::ffi::{CStr, c_void};
58    use std::sync::atomic::{AtomicUsize, Ordering};
59    use std::sync::mpsc::Sender;
60    use std::sync::{Mutex, PoisonError};
61
62    use objc2::runtime::{AnyClass, AnyObject, Bool, Imp, ProtocolObject, Sel};
63    use objc2::{MainThreadMarker, msg_send, sel};
64    use objc2_app_kit::NSApplication;
65    use objc2_foundation::{NSKeyValueChangeNewKey, NSKeyValueChangeOldKey, NSString};
66
67    use super::*;
68
69    // ----------------------------------------------------------------- appearance guard
70
71    type Observe = extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject, *mut AnyObject, *mut AnyObject, *mut c_void);
72
73    /// winit's own `observeValueForKeyPath:ofObject:change:context:`, once replaced.
74    static ORIGINAL: AtomicUsize = AtomicUsize::new(0);
75
76    /// `obj` is a non-nil `NSAppearance`.
77    fn is_appearance(obj: *mut AnyObject) -> bool {
78        let Some(cls) = AnyClass::get(c"NSAppearance") else { return false };
79        // SAFETY: a non-null `obj` is an object from the change dictionary; every object answers
80        // `isKindOfClass:`.
81        !obj.is_null() && unsafe { msg_send![obj, isKindOfClass: cls] }
82    }
83
84    /// Whether the change is one winit's observer can take: the appearance key path with an
85    /// `NSAppearance` on both sides. Anything else would end the process inside it.
86    fn safe_change(key_path: *mut AnyObject, change: *mut AnyObject) -> bool {
87        if key_path.is_null() || change.is_null() {
88            return false;
89        }
90        let ours = NSString::from_str("effectiveAppearance");
91        // SAFETY: `key_path` is the `NSString` KVO passes; `isEqual:` takes any object.
92        let same: bool = unsafe { msg_send![key_path, isEqual: &*ours] };
93        if !same {
94            return false;
95        }
96        // SAFETY: `change` is the KVO change `NSDictionary`; the keys are AppKit's constants.
97        let (old, new): (*mut AnyObject, *mut AnyObject) =
98            unsafe { (msg_send![change, objectForKey: NSKeyValueChangeOldKey], msg_send![change, objectForKey: NSKeyValueChangeNewKey]) };
99        is_appearance(old) && is_appearance(new)
100    }
101
102    extern "C-unwind" fn observe(
103        this: *mut AnyObject,
104        cmd: Sel,
105        key_path: *mut AnyObject,
106        object: *mut AnyObject,
107        change: *mut AnyObject,
108        context: *mut c_void,
109    ) {
110        let ok = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| safe_change(key_path, change))).unwrap_or(false);
111        let original = ORIGINAL.load(Ordering::SeqCst);
112        if !ok || original == 0 {
113            log::debug!("ignored an appearance change without an appearance (screen capture?)");
114            return;
115        }
116        // SAFETY: `original` is the implementation `method_setImplementation` handed back for
117        // this selector, whose signature is `Observe` (v@:@@@^v).
118        let f: Observe = unsafe { std::mem::transmute::<usize, Observe>(original) };
119        f(this, cmd, key_path, object, change, context);
120    }
121
122    pub fn guard_appearance() -> Result<()> {
123        if MainThreadMarker::new().is_none() {
124            return Err(NativeError::Os("guard the appearance observer on the main thread".into()));
125        }
126        let Some(cls) = AnyClass::get(c"WinitWindowDelegate") else {
127            return Err(NativeError::Os("no window yet: guard the appearance observer after the first window opens".into()));
128        };
129        let Some(method) = cls.instance_method(sel!(observeValueForKeyPath:ofObject:change:context:)) else {
130            return Err(NativeError::Unsupported);
131        };
132        let ours = observe as Observe;
133        if method.implementation() as usize == ours as usize {
134            return Ok(());
135        }
136        // SAFETY: `Imp` is the untyped method pointer; the runtime calls it with the selector's
137        // signature, which is `observe`'s.
138        let imp: Imp = unsafe { std::mem::transmute::<Observe, Imp>(ours) };
139        // SAFETY: `method` belongs to a registered class and `imp` matches its signature. The
140        // original goes into `ORIGINAL` before any call could reach `observe`: KVO calls come on
141        // the main thread, which is this one.
142        let before = unsafe { method.set_implementation(imp) };
143        ORIGINAL.store(before as usize, Ordering::SeqCst);
144        Ok(())
145    }
146
147    // ----------------------------------------------------------------- Dock reopen
148
149    static REOPEN: Mutex<Option<(Sender<Reopen>, Waker)>> = Mutex::new(None);
150
151    fn report(visible: bool) {
152        let sink = REOPEN.lock().unwrap_or_else(PoisonError::into_inner).clone();
153        if let Some((tx, wake)) = sink
154            && tx.send(Reopen { has_visible_windows: visible }).is_ok()
155        {
156            wake();
157        }
158    }
159
160    /// `-[delegate applicationShouldHandleReopen:hasVisibleWindows:]`: report it and let AppKit
161    /// do its usual part (un-minimize a window). Never unwinds into AppKit.
162    extern "C-unwind" fn should_reopen(_this: *mut AnyObject, _cmd: Sel, _app: *mut AnyObject, visible: Bool) -> Bool {
163        let _ = std::panic::catch_unwind(|| report(visible.as_bool()));
164        Bool::YES
165    }
166
167    pub fn listen_reopen(tx: Sender<Reopen>, waker: Waker) -> Result<()> {
168        let Some(mtm) = MainThreadMarker::new() else {
169            return Err(NativeError::Os("listen for Dock reopen on the main thread".into()));
170        };
171        let app = NSApplication::sharedApplication(mtm);
172        let Some(delegate) = app.delegate() else {
173            return Err(NativeError::Os("the application has no delegate yet; listen once the event loop runs".into()));
174        };
175        let obj: &AnyObject = ProtocolObject::as_ref(&*delegate);
176        let cls: &AnyClass = obj.class();
177        let name = sel!(applicationShouldHandleReopen:hasVisibleWindows:);
178        type Reopened = extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject, Bool) -> Bool;
179        let ours = should_reopen as Reopened;
180        match cls.instance_method(name) {
181            Some(m) if m.implementation() as usize == ours as usize => {}
182            Some(_) => return Err(NativeError::Os("the application delegate already answers Dock reopen".into())),
183            None => {
184                const TYPES: &CStr = c"c@:@c"; // BOOL (id self, SEL _cmd, NSApplication *app, BOOL flag)
185                // SAFETY: `Imp` is the untyped method pointer; the runtime calls it with the
186                // signature `TYPES` declares, which is `should_reopen`'s.
187                let imp: Imp = unsafe { std::mem::transmute::<Reopened, Imp>(ours) };
188                // SAFETY: `cls` is a registered class (the delegate's), `name` a valid selector
189                // and `TYPES` a NUL-terminated encoding matching `imp`; it only fails if the
190                // method exists, which was checked above.
191                let added = unsafe { objc2::ffi::class_addMethod(cls as *const AnyClass as *mut AnyClass, name, imp, TYPES.as_ptr()) };
192                if !added.as_bool() {
193                    return Err(NativeError::Os("could not add the reopen handler to the application delegate".into()));
194                }
195                // NSApplication may cache what its delegate answers: set it again.
196                app.setDelegate(Some(&delegate));
197            }
198        }
199        *REOPEN.lock().unwrap_or_else(PoisonError::into_inner) = Some((tx, waker));
200        Ok(())
201    }
202}
203
204#[cfg(not(target_os = "macos"))]
205mod imp {
206    use std::sync::mpsc::Sender;
207
208    use super::*;
209
210    pub fn guard_appearance() -> Result<()> {
211        Err(NativeError::Unsupported)
212    }
213
214    pub fn listen_reopen(_tx: Sender<Reopen>, _waker: Waker) -> Result<()> {
215        Err(NativeError::Unsupported)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn quiet_values() {
225        for on in ["1", "yes", "true", " on "] {
226            assert!(quiet_value(on), "{on:?}");
227        }
228        for off in ["", " ", "0", "false", "NO", "off"] {
229            assert!(!quiet_value(off), "{off:?}");
230        }
231        assert!(!quiet_requested("ASHLAR_TEST_SURELY_UNSET_PREFIX"));
232    }
233
234    #[test]
235    fn off_the_main_thread_is_an_error_not_a_crash() {
236        // the test harness runs tests on worker threads
237        assert!(guard_appearance().is_err());
238        assert!(listen_reopen(crate::no_waker()).is_err());
239    }
240}