diff --git a/adapters/macos/Cargo.toml b/adapters/macos/Cargo.toml index 8b424e535..578a4208c 100644 --- a/adapters/macos/Cargo.toml +++ b/adapters/macos/Cargo.toml @@ -36,3 +36,7 @@ objc2-app-kit = { version = "0.2.0", features = [ "NSView", "NSWindow", ] } + +[[test]] +name = "kvo_teardown" +harness = false diff --git a/adapters/macos/src/subclass.rs b/adapters/macos/src/subclass.rs index a873f7c85..28f0360f7 100644 --- a/adapters/macos/src/subclass.rs +++ b/adapters/macos/src/subclass.rs @@ -256,10 +256,24 @@ impl SubclassingAdapter { } } +fn has_our_subclass(view: &NSView, prev_class: &'static AnyClass) -> bool { + let current: *const AnyClass = view.class(); + let subclasses = SUBCLASSES.lock().unwrap(); + subclasses + .iter() + .any(|entry| std::ptr::eq(entry.0, prev_class) && std::ptr::eq(entry.1, current)) +} + impl Drop for SubclassingAdapter { fn drop(&mut self) { let prev_class = self.associated.ivars().prev_class; let view = Id::as_ptr(&self.view) as *mut NSView; + // KVO may have installed its own subclass on top of ours (AppKit + // observes the first responder). Resetting the class would break + // those observers, so leave the view as it is in that case. + if !has_our_subclass(unsafe { &*view }, prev_class) { + return; + } unsafe { object_setClass(view as *mut _, (prev_class as *const AnyClass).cast()) }; unsafe { objc_setAssociatedObject( diff --git a/adapters/macos/tests/kvo_teardown.rs b/adapters/macos/tests/kvo_teardown.rs new file mode 100644 index 000000000..51b9192f3 --- /dev/null +++ b/adapters/macos/tests/kvo_teardown.rs @@ -0,0 +1,85 @@ +// Copyright 2026 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +// This test doesn't use the default harness, because the adapter +// must be created on the main thread. + +use std::ffi::c_void; + +use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate}; +use accesskit_macos::SubclassingAdapter; +use objc2::rc::Id; +use objc2::runtime::AnyObject; +use objc2::{class, msg_send, msg_send_id}; +use objc2_app_kit::NSView; +use objc2_foundation::{NSArray, NSObject, NSString}; + +struct NoTree; + +impl ActivationHandler for NoTree { + fn request_initial_tree(&mut self) -> Option { + None + } +} + +struct NoActions; + +impl ActionHandler for NoActions { + fn do_action(&mut self, _request: ActionRequest) {} +} + +fn new_view() -> Id { + unsafe { msg_send_id![class!(NSView), new] } +} + +fn adapter_for(view: &NSView) -> SubclassingAdapter { + unsafe { SubclassingAdapter::new(view as *const NSView as *mut c_void, NoTree, NoActions) } +} + +fn class_name(view: &NSView) -> String { + let object: &AnyObject = view; + object.class().name().to_owned() +} + +fn an_unobserved_view_gets_its_class_back() { + let view = new_view(); + let adapter = adapter_for(&view); + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); + drop(adapter); + assert_eq!(class_name(&view), "NSView"); + drop(adapter_for(&view)); + assert_eq!(class_name(&view), "NSView"); +} + +// AppKit observes the first responder and can remove its observers +// after the adapter is dropped. +fn an_observed_view_keeps_its_observers() { + let view = new_view(); + let adapter = adapter_for(&view); + let observer = NSObject::new(); + let key_path = NSString::from_str("nextResponder"); + let no_context: *mut c_void = std::ptr::null_mut(); + let _: () = unsafe { + msg_send![&view, addObserver: &*observer, forKeyPath: &*key_path, options: 0usize, context: no_context] + }; + assert_eq!( + class_name(&view), + "NSKVONotifying_AccessKitSubclassOfNSView" + ); + + drop(adapter); + assert_eq!( + class_name(&view), + "NSKVONotifying_AccessKitSubclassOfNSView" + ); + let _children: *mut NSArray = unsafe { msg_send![&view, accessibilityChildren] }; + let _: () = unsafe { msg_send![&view, removeObserver: &*observer, forKeyPath: &*key_path] }; + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); +} + +fn main() { + an_unobserved_view_gets_its_class_back(); + an_observed_view_keeps_its_observers(); +}