From aee367eac56e2ffea0c88ce178aef67bbc9cfd0a Mon Sep 17 00:00:00 2001 From: Pradeep Kancharla Date: Mon, 12 Oct 2026 01:06:10 +0200 Subject: [PATCH] fix: Don't reset the view's class on macOS when KVO has subclassed it Dropping the subclassing adapter while a key-value observer is registered on the view discarded KVO's dynamic subclass, so removing the observer later raised NSRangeException. AppKit registers such observers itself on the first responder, which made apps crash when closing a window. --- adapters/macos/Cargo.toml | 4 ++ adapters/macos/src/subclass.rs | 14 +++++ adapters/macos/tests/kvo_teardown.rs | 85 ++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 adapters/macos/tests/kvo_teardown.rs 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(); +}