From 64e9a47ddaac64c9082214f472fa6fcafbc86430 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Sun, 18 Oct 2026 19:26:28 +0200 Subject: [PATCH] fix: Fully drop macOS SubclassingAdapter if KVO is detected --- Cargo.lock | 171 ++++++++++++++++- adapters/macos/Cargo.toml | 8 + adapters/macos/src/subclass.rs | 167 +++++++++++----- adapters/macos/tests/integration/harness.rs | 38 ++++ adapters/macos/tests/integration/main.rs | 12 ++ .../tests/integration/subclassing_adapter.rs | 178 ++++++++++++++++++ adapters/macos/tests/integration/util.rs | 49 +++++ 7 files changed, 574 insertions(+), 49 deletions(-) create mode 100644 adapters/macos/tests/integration/harness.rs create mode 100644 adapters/macos/tests/integration/main.rs create mode 100644 adapters/macos/tests/integration/subclassing_adapter.rs create mode 100644 adapters/macos/tests/integration/util.rs diff --git a/Cargo.lock b/Cargo.lock index cc978d725..37f9b0e41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ "accesskit", "accesskit_consumer", "hashbrown", + "libtest-mimic", "objc2 0.5.2", "objc2-app-kit", "objc2-foundation 0.2.2", @@ -191,6 +192,80 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "arrayref" version = "0.3.6" @@ -510,6 +585,52 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream 0.6.21", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.6" @@ -684,9 +805,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] +[[package]] +name = "escape8259" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f0d310082fd5de539d4f41601182241147498444a7695c520288a0cdbead6" + [[package]] name = "event-listener" version = "5.3.1" @@ -889,6 +1016,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.6" @@ -949,7 +1082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.1", + "windows-targets 0.52.6", ] [[package]] @@ -963,6 +1096,18 @@ dependencies = [ "redox_syscall 0.4.1", ] +[[package]] +name = "libtest-mimic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" +dependencies = [ + "anstream 1.0.0", + "anstyle", + "clap", + "escape8259", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1391,6 +1536,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "orbclient" version = "0.3.47" @@ -1732,7 +1883,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2002,6 +2153,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df65f20698aeed245efdde3628a6b559ea1239bbb871af1b6e3b58c413b2bd1" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.87" @@ -2040,7 +2197,7 @@ dependencies = [ "getrandom 0.3.2", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2228,6 +2385,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.18.1" diff --git a/adapters/macos/Cargo.toml b/adapters/macos/Cargo.toml index 8b424e535..17ec4a8e7 100644 --- a/adapters/macos/Cargo.toml +++ b/adapters/macos/Cargo.toml @@ -36,3 +36,11 @@ objc2-app-kit = { version = "0.2.0", features = [ "NSView", "NSWindow", ] } + +[dev-dependencies] +libtest-mimic = "0.8.2" + +# AppKit must be used from the main thread, where libtest doesn't run tests. +[[test]] +name = "integration" +harness = false diff --git a/adapters/macos/src/subclass.rs b/adapters/macos/src/subclass.rs index a873f7c85..f4ebc196a 100644 --- a/adapters/macos/src/subclass.rs +++ b/adapters/macos/src/subclass.rs @@ -12,10 +12,10 @@ use objc2::{ OBJC_ASSOCIATION_RETAIN_NONATOMIC, objc_getAssociatedObject, objc_setAssociatedObject, object_setClass, }, - msg_send_id, + msg_send, msg_send_id, mutability::InteriorMutable, rc::Id, - runtime::{AnyClass, Sel}, + runtime::{AnyClass, AnyObject, Sel}, sel, }; use objc2_app_kit::{NSView, NSWindow}; @@ -72,25 +72,56 @@ impl AssociatedObject { } } -fn associated_object(view: &NSView) -> &AssociatedObject { +fn associated_object(view: &NSView) -> Option<&AssociatedObject> { unsafe { (objc_getAssociatedObject(view as *const NSView as *const _, associated_object_key()) as *const AssociatedObject) .as_ref() } - .unwrap() +} + +// Unlike `[view class]`, this sees the subclass that key-value observing +// hides by overriding `class`. +fn runtime_class(view: &NSView) -> &'static AnyClass { + let object: &AnyObject = view; + object.class() +} + +// Key-value observing may have subclassed our subclass in turn. +fn installed_subclass(view: &NSView) -> Option<(&'static AnyClass, &'static AnyClass)> { + let subclasses = SUBCLASSES.lock().unwrap(); + let mut class: Option<&AnyClass> = Some(runtime_class(view)); + while let Some(current) = class { + if let Some(entry) = subclasses + .iter() + .find(|entry| std::ptr::eq(entry.1, current)) + { + return Some(*entry); + } + class = current.superclass(); + } + None +} + +fn prev_class(view: &NSView) -> &'static AnyClass { + installed_subclass(view).unwrap().0 } // Some view classes, like the one in winit 0.27, assume that they are the // lowest subclass, and call [self superclass] to get their superclass. // Give them the answer they need. unsafe extern "C" fn superclass(this: &NSView, _cmd: Sel) -> Option<&AnyClass> { - let associated = associated_object(this); - associated.ivars().prev_class.superclass() + let prev_class = match associated_object(this) { + Some(associated) => associated.ivars().prev_class, + None => prev_class(this), + }; + prev_class.superclass() } unsafe extern "C" fn children(this: &NSView, _cmd: Sel) -> *mut NSArray { - let associated = associated_object(this); + let Some(associated) = associated_object(this) else { + return unsafe { msg_send![super(this, prev_class(this)), accessibilityChildren] }; + }; let mut state = associated.ivars().state.borrow_mut(); let state_mut = &mut *state; state_mut @@ -99,14 +130,18 @@ unsafe extern "C" fn children(this: &NSView, _cmd: Sel) -> *mut NSArray *mut NSObject { - let associated = associated_object(this); + let Some(associated) = associated_object(this) else { + return unsafe { msg_send![super(this, prev_class(this)), accessibilityFocusedUIElement] }; + }; let mut state = associated.ivars().state.borrow_mut(); let state_mut = &mut *state; state_mut.adapter.focus(&mut *state_mut.activation_handler) } unsafe extern "C" fn hit_test(this: &NSView, _cmd: Sel, point: NSPoint) -> *mut NSObject { - let associated = associated_object(this); + let Some(associated) = associated_object(this) else { + return unsafe { msg_send![super(this, prev_class(this)), accessibilityHitTest: point] }; + }; let mut state = associated.ivars().state.borrow_mut(); let state_mut = &mut *state; state_mut @@ -114,6 +149,41 @@ unsafe extern "C" fn hit_test(this: &NSView, _cmd: Sel, point: NSPoint) -> *mut .hit_test(point, &mut *state_mut.activation_handler) } +fn subclass_of(prev_class: &'static AnyClass) -> &'static AnyClass { + let mut subclasses = SUBCLASSES.lock().unwrap(); + match subclasses + .iter() + .find(|entry| std::ptr::eq(entry.0, prev_class)) + { + Some(entry) => entry.1, + None => { + let name = format!("AccessKitSubclassOf{}", prev_class.name()); + let mut builder = ClassBuilder::new(&name, prev_class).unwrap(); + unsafe { + builder.add_method( + sel!(superclass), + superclass as unsafe extern "C" fn(_, _) -> _, + ); + builder.add_method( + sel!(accessibilityChildren), + children as unsafe extern "C" fn(_, _) -> _, + ); + builder.add_method( + sel!(accessibilityFocusedUIElement), + focus as unsafe extern "C" fn(_, _) -> _, + ); + builder.add_method( + sel!(accessibilityHitTest:), + hit_test as unsafe extern "C" fn(_, _, _) -> _, + ); + } + let class = builder.register(); + subclasses.push((prev_class, class)); + class + } + } +} + /// Uses dynamic Objective-C subclassing to implement the `NSView` /// accessibility methods when normal subclassing isn't an option. pub struct SubclassingAdapter { @@ -124,7 +194,8 @@ pub struct SubclassingAdapter { impl SubclassingAdapter { /// Create an adapter that dynamically subclasses the specified view. /// This must be done before the view is shown or focused for - /// the first time. + /// the first time, and before anything observes the view with + /// key-value observing. /// /// The action handler will always be called on the main thread. /// @@ -154,11 +225,27 @@ impl SubclassingAdapter { { panic!("subclassing adapter already instantiated on view {view:?}"); } + // A previous adapter may have left its subclass on the view, + // if key-value observing had subclassed it in turn. + let installed = installed_subclass(unsafe { &*view }); + if installed.is_none() { + // Key-value observing crashes when it adds or removes an observer + // while our subclass sits on top of its own. + let reported_class: &AnyClass = unsafe { msg_send![view, class] }; + let actual_class = runtime_class(unsafe { &*view }); + if !std::ptr::eq(reported_class, actual_class) { + panic!( + "view {view:?} is already observed with key-value observing (its class {} reports itself as {}); create the subclassing adapter before anything observes the view", + actual_class.name(), + reported_class.name() + ); + } + } let adapter = unsafe { Adapter::new(view as *mut c_void, false, action_handler) }; - // Cast to a pointer and back to force the lifetime to 'static - // SAFETY: We know the class will live as long as the instance, - // and we only use this reference while the instance is alive. - let prev_class = unsafe { &*((*view).class() as *const AnyClass) }; + let (prev_class, subclass) = installed.unwrap_or_else(|| { + let prev_class = runtime_class(unsafe { &*view }); + (prev_class, subclass_of(prev_class)) + }); let associated = AssociatedObject::new(adapter, activation_handler, prev_class); unsafe { objc_setAssociatedObject( @@ -168,39 +255,12 @@ impl SubclassingAdapter { OBJC_ASSOCIATION_RETAIN_NONATOMIC, ) }; - let mut subclasses = SUBCLASSES.lock().unwrap(); - let subclass = match subclasses.iter().find(|entry| entry.0 == prev_class) { - Some(entry) => entry.1, - None => { - let name = format!("AccessKitSubclassOf{}", prev_class.name()); - let mut builder = ClassBuilder::new(&name, prev_class).unwrap(); - unsafe { - builder.add_method( - sel!(superclass), - superclass as unsafe extern "C" fn(_, _) -> _, - ); - builder.add_method( - sel!(accessibilityChildren), - children as unsafe extern "C" fn(_, _) -> _, - ); - builder.add_method( - sel!(accessibilityFocusedUIElement), - focus as unsafe extern "C" fn(_, _) -> _, - ); - builder.add_method( - sel!(accessibilityHitTest:), - hit_test as unsafe extern "C" fn(_, _, _) -> _, - ); - } - let class = builder.register(); - subclasses.push((prev_class, class)); - class - } - }; // SAFETY: Changing the view's class is only safe because // the subclass doesn't add any instance variables; // it uses an associated object instead. - unsafe { object_setClass(view as *mut _, (subclass as *const AnyClass).cast()) }; + if installed.is_none() { + unsafe { object_setClass(view as *mut _, (subclass as *const AnyClass).cast()) }; + } Self { view: retained_view, associated, @@ -260,7 +320,6 @@ 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; - unsafe { object_setClass(view as *mut _, (prev_class as *const AnyClass).cast()) }; unsafe { objc_setAssociatedObject( view as *mut _, @@ -269,5 +328,23 @@ impl Drop for SubclassingAdapter { OBJC_ASSOCIATION_RETAIN_NONATOMIC, ) }; + // Key-value observing may have subclassed our subclass in turn + // (AppKit observes the first responder), and resetting the class + // would then break those observers. Our subclass is harmless + // without the associated object. + let view = unsafe { &*view }; + let is_our_subclass = SUBCLASSES + .lock() + .unwrap() + .iter() + .any(|entry| std::ptr::eq(entry.1, runtime_class(view))); + if is_our_subclass { + unsafe { + object_setClass( + view as *const _ as *mut _, + (prev_class as *const AnyClass).cast(), + ) + }; + } } } diff --git a/adapters/macos/tests/integration/harness.rs b/adapters/macos/tests/integration/harness.rs new file mode 100644 index 000000000..3672e95f5 --- /dev/null +++ b/adapters/macos/tests/integration/harness.rs @@ -0,0 +1,38 @@ +// 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. + +use libtest_mimic::{Arguments, Trial}; +use std::process::ExitCode; + +pub struct Test { + pub name: &'static str, + pub run: fn(), +} + +macro_rules! tests { + ($($name:ident),* $(,)?) => { + &[$($crate::harness::Test { name: stringify!($name), run: $name }),*] + }; +} +pub(crate) use tests; + +pub fn run(suites: &[(&str, &[Test])]) -> ExitCode { + let mut args = Arguments::from_args(); + // libtest-mimic only runs tests on the main thread when it uses a single thread. + args.test_threads = Some(1); + let trials = suites + .iter() + .flat_map(|(suite, tests)| { + tests.iter().map(move |test| { + let run = test.run; + Trial::test(format!("{suite}::{}", test.name), move || { + run(); + Ok(()) + }) + }) + }) + .collect(); + libtest_mimic::run(&args, trials).exit_code() +} diff --git a/adapters/macos/tests/integration/main.rs b/adapters/macos/tests/integration/main.rs new file mode 100644 index 000000000..c756fae50 --- /dev/null +++ b/adapters/macos/tests/integration/main.rs @@ -0,0 +1,12 @@ +// 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. + +mod harness; +mod subclassing_adapter; +mod util; + +fn main() -> std::process::ExitCode { + harness::run(&[("subclassing_adapter", subclassing_adapter::TESTS)]) +} diff --git a/adapters/macos/tests/integration/subclassing_adapter.rs b/adapters/macos/tests/integration/subclassing_adapter.rs new file mode 100644 index 000000000..46637dfb8 --- /dev/null +++ b/adapters/macos/tests/integration/subclassing_adapter.rs @@ -0,0 +1,178 @@ +// 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. + +use std::{ + ffi::c_void, + panic::{self, AssertUnwindSafe}, + rc::Rc, +}; + +use accesskit_macos::SubclassingAdapter; +use objc2::msg_send; +use objc2::rc::Id; +use objc2::runtime::{AnyClass, AnyObject}; +use objc2_app_kit::NSView; +use objc2_foundation::{NSArray, NSObject, NSPoint, NSString}; + +use crate::harness::{Test, tests}; +use crate::util::*; + +pub const TESTS: &[Test] = tests![ + the_view_is_subclassed, + querying_the_view_requests_the_tree, + dropping_restores_the_view_class, + dropping_releases_the_handlers, + a_view_can_get_a_new_adapter, + observers_can_be_removed_after_dropping, + an_observed_view_can_get_a_new_adapter, + a_view_observed_before_the_adapter_is_rejected, +]; + +fn adapter_for(view: &NSView) -> (SubclassingAdapter, Rc) { + let events = Rc::new(HandlerEvents::default()); + let adapter = unsafe { + SubclassingAdapter::new( + view as *const NSView as *mut c_void, + NoTree(Rc::clone(&events)), + NoActions, + ) + }; + (adapter, events) +} + +fn query_accessibility(view: &NSView) { + let _: *mut NSArray = unsafe { msg_send![view, accessibilityChildren] }; + let _: *mut AnyObject = unsafe { msg_send![view, accessibilityFocusedUIElement] }; + let superclass: &AnyClass = unsafe { msg_send![view, superclass] }; + assert_eq!(superclass.name(), "NSResponder"); +} + +// The adapter can only hit test a view that is in a window, but the +// fallback doesn't need one. +fn query_accessibility_without_adapter(view: &NSView) { + query_accessibility(view); + let _: *mut AnyObject = unsafe { msg_send![view, accessibilityHitTest: NSPoint::new(0., 0.)] }; +} + +struct Observation { + observer: Id, + key_path: Id, +} + +// AppKit observes the first responder this way. +fn observe(view: &NSView) -> Observation { + 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] + }; + Observation { observer, key_path } +} + +fn stop_observing(view: &NSView, observation: Observation) { + let _: () = unsafe { + msg_send![view, removeObserver: &*observation.observer, forKeyPath: &*observation.key_path] + }; +} + +fn the_view_is_subclassed() { + let view = new_view(); + let (_adapter, _) = adapter_for(&view); + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); +} + +fn querying_the_view_requests_the_tree() { + let view = new_view(); + let (_adapter, events) = adapter_for(&view); + assert!(!events.tree_requested.get()); + query_accessibility(&view); + assert!(events.tree_requested.get()); +} + +fn dropping_restores_the_view_class() { + let view = new_view(); + let (adapter, _) = adapter_for(&view); + drop(adapter); + assert_eq!(class_name(&view), "NSView"); + query_accessibility_without_adapter(&view); +} + +fn dropping_releases_the_handlers() { + let view = new_view(); + let (adapter, events) = adapter_for(&view); + drop(adapter); + assert!(events.dropped.get()); +} + +fn a_view_can_get_a_new_adapter() { + let view = new_view(); + drop(adapter_for(&view)); + let (adapter, events) = adapter_for(&view); + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); + query_accessibility(&view); + assert!(events.tree_requested.get()); + drop(adapter); + assert_eq!(class_name(&view), "NSView"); +} + +fn observers_can_be_removed_after_dropping() { + let view = new_view(); + let (adapter, events) = adapter_for(&view); + let observation = observe(&view); + assert_eq!( + class_name(&view), + "NSKVONotifying_AccessKitSubclassOfNSView" + ); + + drop(adapter); + assert!(events.dropped.get()); + assert_eq!( + class_name(&view), + "NSKVONotifying_AccessKitSubclassOfNSView" + ); + query_accessibility_without_adapter(&view); + + stop_observing(&view, observation); + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); + query_accessibility_without_adapter(&view); +} + +fn an_observed_view_can_get_a_new_adapter() { + let view = new_view(); + let (adapter, _) = adapter_for(&view); + let observation = observe(&view); + drop(adapter); + + let (adapter, events) = adapter_for(&view); + assert_eq!( + class_name(&view), + "NSKVONotifying_AccessKitSubclassOfNSView" + ); + query_accessibility(&view); + assert!(events.tree_requested.get()); + drop(adapter); + assert!(events.dropped.get()); + + stop_observing(&view, observation); + let (adapter, events) = adapter_for(&view); + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); + query_accessibility(&view); + assert!(events.tree_requested.get()); + drop(adapter); + assert_eq!(class_name(&view), "NSView"); +} + +fn a_view_observed_before_the_adapter_is_rejected() { + let view = new_view(); + let observation = observe(&view); + let result = panic::catch_unwind(AssertUnwindSafe(|| adapter_for(&view))); + assert!(result.is_err()); + assert_eq!(class_name(&view), "NSKVONotifying_NSView"); + + stop_observing(&view, observation); + assert_eq!(class_name(&view), "NSView"); + query_accessibility_without_adapter(&view); +} diff --git a/adapters/macos/tests/integration/util.rs b/adapters/macos/tests/integration/util.rs new file mode 100644 index 000000000..e5346b854 --- /dev/null +++ b/adapters/macos/tests/integration/util.rs @@ -0,0 +1,49 @@ +// 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. + +use std::{cell::Cell, rc::Rc}; + +use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate}; +use objc2::rc::Id; +use objc2::runtime::AnyObject; +use objc2::{class, msg_send_id}; +use objc2_app_kit::NSView; + +#[derive(Default)] +pub struct HandlerEvents { + pub tree_requested: Cell, + pub dropped: Cell, +} + +pub struct NoTree(pub Rc); + +impl ActivationHandler for NoTree { + fn request_initial_tree(&mut self) -> Option { + self.0.tree_requested.set(true); + None + } +} + +impl Drop for NoTree { + fn drop(&mut self) { + self.0.dropped.set(true); + } +} + +pub struct NoActions; + +impl ActionHandler for NoActions { + fn do_action(&mut self, _request: ActionRequest) {} +} + +pub fn new_view() -> Id { + unsafe { msg_send_id![class!(NSView), new] } +} + +// KVO overrides the `class` method to hide its own subclass. +pub fn class_name(view: &NSView) -> String { + let object: &AnyObject = view; + object.class().name().to_owned() +}