gpui: Application::inaccessible() (#57954)

Provide a way to prevent GPUI from creating AccessKit adapters, and
enable this in Zed.

This will allow us to test AccessKit support in Zed without rolling it
out more broadly, while we gain confidence in the implementation in
GPUI.

I've also added a log statement

## Motivation (i.e. a mini post-mortem about the #56065 panics)

Merging #56065 caused some nasty panics in nightly. This was caused by a
bug in the logic for selecting a focus node for a `TreeUpdate`.
AccessKit panics when an invalid `TreeUpdate` is provided.

My assumption was that, since Zed uses no a11y APIs, and also that
essentially 0 zed users would have AT apps running, that merging this PR
would have no effect on the behaviour of Zed itself. However, two issues
combined to cause the panics:
- It seems like many people (everyone?) on mac gets the activation
callback called by accesskit_macos. A quick search suggests this might
be due to password managers searching for password fields, but not sure
how true that is.
- The bug in question related to *forgetting to check* whether a node
used a11y APIs, so we *were* pushing non-empty `TreeUpdate`s

As a (probably temporary) defensive measure, I added a function to try
to detect the bad cases and fix them. But it would be lovely if this
could live in AccessKit itself, since it would mean we wouldn't have to
do the check twice (once in GPUI, once in AccessKit). This would also
help prevent drift when updating accesskit versions if new invariants
are added.

We also cannot protect against this with `catch_unwind`, since we use
`panic=abort`. So our only option unfortunately is to temporarily
disable AccessKit until we know our implementation is stable.

Release Notes:

- N/A or Added/Fixed/Improved ...
This commit is contained in:
Cameron Mcloughlin 2026-05-28 14:36:24 +01:00 committed by GitHub
parent b8c853a63d
commit 4d6a3c7e11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 55 additions and 8 deletions

View file

@ -151,6 +151,21 @@ impl Application {
))
}
/// Builds an app with accessibility (AccessKit) integration forcibly
/// disabled.
///
/// In this mode, accessibility APIs (e.g.
/// [`div().role()`][crate::StatefulInteractiveElement::role]) silently
/// no-op.
///
/// See the [accessibility guide](crate::_accessibility) for an overview of
/// the features this disables.
pub fn new_inaccessible(platform: Rc<dyn Platform>) -> Self {
let this = Self::with_platform(platform);
this.0.borrow_mut().accessibility_force_disabled = true;
this
}
/// Assigns the source of assets for the application.
pub fn with_assets(self, asset_source: impl AssetSource) -> Self {
let mut context_lock = self.0.borrow_mut();
@ -666,6 +681,9 @@ pub struct App {
pub(crate) window_update_stack: Vec<WindowId>,
pub(crate) mode: GpuiMode,
pub(crate) cursor_hide_mode: CursorHideMode,
/// Whether the app was created by [`Application::new_inaccessible`]. No
/// accesskit APIs will be called when this flag is set.
pub(crate) accessibility_force_disabled: bool,
flushing_effects: bool,
pending_updates: usize,
quit_mode: QuitMode,
@ -755,6 +773,7 @@ impl App {
quit_mode: QuitMode::default(),
quitting: false,
cursor_hide_mode: CursorHideMode::default(),
accessibility_force_disabled: false,
#[cfg(any(test, feature = "test-support", debug_assertions))]
name: None,

View file

@ -1330,10 +1330,11 @@ impl Window {
WindowBounds::Windowed(_) => {}
}
let accessibility_force_disabled = cx.accessibility_force_disabled;
let a11y_active_flag = Arc::new(AtomicBool::new(false));
#[cfg(not(target_family = "wasm"))]
{
if !accessibility_force_disabled {
let initial_tree = accesskit::TreeUpdate {
nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))],
tree: Some(accesskit::Tree::new(ROOT_NODE_ID)),
@ -1717,7 +1718,7 @@ impl Window {
captured_hitbox: None,
#[cfg(any(feature = "inspector", debug_assertions))]
inspector: None,
a11y: A11y::new(a11y_active_flag),
a11y: A11y::new(a11y_active_flag, accessibility_force_disabled),
})
}

View file

@ -107,6 +107,10 @@ pub(crate) type A11yActionListener =
/// Manages the AccessKit tree that is built each frame and the mappings
/// needed to dispatch incoming action requests back to the right elements.
pub(crate) struct A11y {
/// Whether accessibility has been [forcibly disabled] for this window.
///
/// [forcibly disabled]: crate::Application::new_inaccessible
force_disabled: bool,
/// Whether a11y features have been requested by the system.
///
/// Updated by AccessKit using callbacks provided to the adapter. Can change
@ -131,8 +135,9 @@ pub(crate) struct A11y {
}
impl A11y {
pub(crate) fn new(active_flag: Arc<AtomicBool>) -> Self {
pub(crate) fn new(active_flag: Arc<AtomicBool>, force_disabled: bool) -> Self {
Self {
force_disabled,
active_flag,
active_this_frame: false,
nodes: A11yNodeBuilder::new(),
@ -147,7 +152,7 @@ impl A11y {
/// See the docs for [`Self::active_flag`] and [`Self::active_this_frame`]
/// for more commentary.
pub(crate) fn sync_active_flag(&mut self) {
self.active_this_frame = self.active_flag.load(Ordering::SeqCst);
self.active_this_frame = !self.force_disabled && self.active_flag.load(Ordering::SeqCst);
}
pub(crate) fn is_active(&self) -> bool {
@ -164,7 +169,21 @@ impl A11y {
/// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter.
pub(crate) fn end_frame(&mut self) -> TreeUpdate {
self.nodes.finalize()
let tree_update = self.nodes.finalize();
// Zed currently doesn't set any a11y APIs on *any* UI elements, so a
// tree with nodes other than the root indicates a bug in the
// `TreeUpdate`-producing logic.
//
// Remove this when adding aria attributes.
if tree_update.nodes.len() > 1 {
log::warn!(
"expected an empty a11y tree update (only the root node), but got {} nodes; Zed has no accessible UI elements yet",
tree_update.nodes.len()
);
}
tree_update
}
}

View file

@ -85,6 +85,15 @@ use crate::zed::{CrashHandler, OpenRequestKind, eager_load_active_theme_and_icon
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
fn build_application() -> Application {
let platform = gpui_platform::current_platform(false);
if std::env::var("ZED_EXPERIMENTAL_A11Y").as_deref() == Ok("1") {
Application::with_platform(platform)
} else {
Application::new_inaccessible(platform)
}
}
fn files_not_created_on_launch(errors: HashMap<io::ErrorKind, Vec<&Path>>) {
let message = "Zed failed to launch";
let error_details = errors
@ -113,7 +122,7 @@ fn files_not_created_on_launch(errors: HashMap<io::ErrorKind, Vec<&Path>>) {
.collect::<Vec<_>>().join("\n\n");
eprintln!("{message}: {error_details}");
Application::with_platform(gpui_platform::current_platform(false))
build_application()
.with_quit_mode(QuitMode::Explicit)
.run(move |cx| {
if let Ok(window) = cx.open_window(gpui::WindowOptions::default(), |_, cx| {
@ -338,8 +347,7 @@ fn main() {
#[cfg(windows)]
check_for_conpty_dll();
let app =
Application::with_platform(gpui_platform::current_platform(false)).with_assets(Assets);
let app = build_application().with_assets(Assets);
let app_db = db::AppDatabase::new();
let system_id = app.background_executor().spawn(system_id());