Repository: electron/electron
Pull request: feat: enable more granular a11y feature management

#### Description of Change

It's been possible to enable accessibility settings on a more granular level for some months now - after https://github.com/electron/electron/pull/46118 our existing a11y support methods also became more confusing as it's no longer really correct or useful to only see whether accessibility support is enabled as it could be enabled partially and the user has no insight into which parts. This adds two new methods, `app.getAccessibilitySupportFeatures()` and `app.setAccessibilitySupportFeatures(features)` allowing the user to access more granular a11y feature management.

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Added methods to enable more granular accessibility support management.

Changed files:
filename: docs/api/app.md
patch: @@ -1398,7 +1398,75 @@ details. Disabled by default.
 This API must be called after the `ready` event is emitted.
 
 > [!NOTE]
-> Rendering accessibility tree can significantly affect the performance of your app. It should not be enabled by default.
+> Rendering accessibility tree can significantly affect the performance of your app. It should not be enabled by default. Calling this method will enable the following accessibility support features: `nativeAPIs`, `webContents`, `inlineTextBoxes`, and `extendedProperties`.
+
+### `app.getAccessibilitySupportFeatures()` _macOS_ _Windows_
+
+Returns `string[]` - Array of strings naming currently enabled accessibility support components. Possible values:
+
+* `nativeAPIs` - Native OS accessibility APIs integration enabled.
+* `webContents` - Web contents accessibility tree exposure enabled.
+* `inlineTextBoxes` - Inline text boxes (character bounding boxes) enabled.
+* `extendedProperties` - Extended accessibility properties enabled.
+* `screenReader` - Screen reader specific mode enabled.
+* `html` - HTML accessibility tree construction enabled.
+* `labelImages` - Accessibility support for automatic image annotations.
+* `pdfPrinting` - Accessibility support for PDF printing enabled.
+
+Notes:
+
+* The array may be empty if no accessibility modes are active.
+* Use `app.isAccessibilitySupportEnabled()` for the legacy boolean check;
+  prefer this method for granular diagnostics or telemetry.
+
+Example:
+
+```js
+const { app } = require('electron')
+
+app.whenReady().then(() => {
+  if (app.getAccessibilitySupportFeatures().includes('screenReader')) {
+    // Change some app UI to better work with Screen Readers.
+  }
+})
+```
+
+### `app.setAccessibilitySupportFeatures(features)` _macOS_ _Windows_
+
+* `features` string[] - An array of the accessibility features to enable.
+
+Possible values are:
+
+* `nativeAPIs` - Native OS accessibility APIs integration enabled.
+* `webContents` - Web contents accessibility tree exposure enabled.
+* `inlineTextBoxes` - Inline text boxes (character bounding boxes) enabled.
+* `extendedProperties` - Extended accessibility properties enabled.
+* `screenReader` - Screen reader specific mode enabled.
+* `html` - HTML accessibility tree construction enabled.
+* `labelImages` - Accessibility support for automatic image annotations.
+* `pdfPrinting` - Accessibility support for PDF printing enabled.
+
+To disable all supported features, pass an empty array `[]`.
+
+Example:
+
+```js
+const { app } = require('electron')
+
+app.whenReady().then(() => {
+  // Enable a subset of features:
+  app.setAccessibilitySupportFeatures([
+    'screenReader',
+    'pdfPrinting',
+    'webContents'
+  ])
+
+  // Other logic
+
+  // Some time later, disable all features:
+  app.setAccessibilitySupportFeatures([])
+})
+```
 
 ### `app.showAboutPanel()`
 

filename: shell/browser/api/electron_api_app.cc
patch: @@ -1168,6 +1168,86 @@ bool App::IsAccessibilitySupportEnabled() {
   return mode.has_mode(ui::kAXModeComplete.flags());
 }
 
+v8::Local<v8::Value> App::GetAccessibilitySupportFeatures() {
+  v8::Isolate* isolate = JavascriptEnvironment::GetIsolate();
+  v8::EscapableHandleScope handle_scope(isolate);
+  auto* ax_state = content::BrowserAccessibilityState::GetInstance();
+  ui::AXMode mode = ax_state->GetAccessibilityMode();
+
+  std::vector<v8::Local<v8::Value>> features;
+  auto push = [&](const char* name) {
+    features.push_back(v8::String::NewFromUtf8(isolate, name).ToLocalChecked());
+  };
+
+  if (mode.has_mode(ui::AXMode::kNativeAPIs))
+    push("nativeAPIs");
+  if (mode.has_mode(ui::AXMode::kWebContents))
+    push("webContents");
+  if (mode.has_mode(ui::AXMode::kInlineTextBoxes))
+    push("inlineTextBoxes");
+  if (mode.has_mode(ui::AXMode::kExtendedProperties))
+    push("extendedProperties");
+  if (mode.has_mode(ui::AXMode::kHTML))
+    push("html");
+  if (mode.has_mode(ui::AXMode::kLabelImages))
+    push("labelImages");
+  if (mode.has_mode(ui::AXMode::kPDFPrinting))
+    push("pdfPrinting");
+  if (mode.has_mode(ui::AXMode::kScreenReader))
+    push("screenReader");
+
+  v8::Local<v8::Array> arr = v8::Array::New(isolate, features.size());
+  for (uint32_t i = 0; i < features.size(); ++i) {
+    arr->Set(isolate->GetCurrentContext(), i, features[i]).Check();
+  }
+  return handle_scope.Escape(arr);
+}
+
+void App::SetAccessibilitySupportFeatures(
+    gin_helper::ErrorThrower thrower,
+    const std::vector<std::string>& features) {
+  if (!Browser::Get()->is_ready()) {
+    thrower.ThrowError(
+        "app.setAccessibilitySupportFeatures() can only be called after app "
+        "is ready");
+    return;
+  }
+
+  ui::AXMode mode;
+  for (const auto& f : features) {
+    if (f == "nativeAPIs") {
+      mode.set_mode(ui::AXMode::kNativeAPIs, true);
+    } else if (f == "webContents") {
+      mode.set_mode(ui::AXMode::kWebContents, true);
+    } else if (f == "inlineTextBoxes") {
+      mode.set_mode(ui::AXMode::kInlineTextBoxes, true);
+    } else if (f == "extendedProperties") {
+      mode.set_mode(ui::AXMode::kExtendedProperties, true);
+    } else if (f == "screenReader") {
+      mode.set_mode(ui::AXMode::kScreenReader, true);
+    } else if (f == "html") {
+      mode.set_mode(ui::AXMode::kHTML, true);
+    } else if (f == "labelImages") {
+      mode.set_mode(ui::AXMode::kLabelImages, true);
+    } else if (f == "pdfPrinting") {
+      mode.set_mode(ui::AXMode::kPDFPrinting, true);
+    } else {
+      thrower.ThrowError("Unknown accessibility feature: " + f);
+      return;
+    }
+  }
+
+  if (mode.is_mode_off()) {
+    scoped_accessibility_mode_.reset();
+  } else {
+    scoped_accessibility_mode_ =
+        content::BrowserAccessibilityState::GetInstance()
+            ->CreateScopedModeForProcess(mode);
+  }
+
+  Browser::Get()->OnAccessibilitySupportChanged();
+}
+
 void App::SetAccessibilitySupportEnabled(gin_helper::ErrorThrower thrower,
                                          bool enabled) {
   if (!Browser::Get()->is_ready()) {
@@ -1838,6 +1918,10 @@ gin::ObjectTemplateBuilder App::GetObjectTemplateBuilder(v8::Isolate* isolate) {
       .SetMethod("relaunch", &App::Relaunch)
       .SetMethod("isAccessibilitySupportEnabled",
                  &App::IsAccessibilitySupportEnabled)
+      .SetMethod("getAccessibilitySupportFeatures",
+                 &App::GetAccessibilitySupportFeatures)
+      .SetMethod("setAccessibilitySupportFeatures",
+                 &App::SetAccessibilitySupportFeatures)
       .SetMethod("setAccessibilitySupportEnabled",
                  &App::SetAccessibilitySupportEnabled)
       .SetMethod("disableHardwareAcceleration",

filename: shell/browser/api/electron_api_app.h
patch: @@ -215,6 +215,10 @@ class App final : public gin::Wrappable<App>,
   void DisableHardwareAcceleration(gin_helper::ErrorThrower thrower);
   void DisableDomainBlockingFor3DAPIs(gin_helper::ErrorThrower thrower);
   bool IsAccessibilitySupportEnabled();
+  v8::Local<v8::Value> GetAccessibilitySupportFeatures();
+  void SetAccessibilitySupportFeatures(
+      gin_helper::ErrorThrower thrower,
+      const std::vector<std::string>& features);
   void SetAccessibilitySupportEnabled(gin_helper::ErrorThrower thrower,
                                       bool enabled);
   v8::Local<v8::Value> GetLoginItemSettings(gin::Arguments* args);

filename: spec/api-app-spec.ts
patch: @@ -1019,7 +1019,7 @@ describe('app module', () => {
     });
   });
 
-  ifdescribe(process.platform !== 'linux')('accessibilitySupportEnabled property', () => {
+  ifdescribe(process.platform !== 'linux')('accessibility support functionality', () => {
     it('is mutable', () => {
       const values = [false, true, false];
       const setters: Array<(arg: boolean) => void> = [
@@ -1037,6 +1037,61 @@ describe('app module', () => {
         }
       }
     });
+
+    it('getAccessibilitySupportFeatures returns an array with accessibility properties', () => {
+      const values = [
+        'nativeAPIs',
+        'webContents',
+        'inlineTextBoxes',
+        'extendedProperties',
+        'screenReader',
+        'html',
+        'labelImages',
+        'pdfPrinting'
+      ];
+
+      app.setAccessibilitySupportEnabled(false);
+
+      const disabled = app.getAccessibilitySupportFeatures();
+      expect(disabled).to.be.an('array');
+      expect(disabled.includes('complete')).to.equal(false);
+
+      app.setAccessibilitySupportEnabled(true);
+      const enabled = app.getAccessibilitySupportFeatures();
+      expect(enabled).to.be.an('array').with.length.greaterThan(0);
+
+      const boolEnabled = app.isAccessibilitySupportEnabled();
+      if (boolEnabled) {
+        expect(enabled.some(f => values.includes(f))).to.equal(true);
+      }
+    });
+
+    it('setAccessibilitySupportFeatures can enable a subset of features', () => {
+      app.setAccessibilitySupportEnabled(false);
+      expect(app.isAccessibilitySupportEnabled()).to.equal(false);
+      expect(app.getAccessibilitySupportFeatures()).to.be.an('array').that.is.empty();
+
+      const subsetA = ['webContents', 'html'];
+      app.setAccessibilitySupportFeatures(subsetA);
+      const afterSubsetA = app.getAccessibilitySupportFeatures();
+      expect(afterSubsetA).to.deep.equal(subsetA);
+
+      const subsetB = [
+        'nativeAPIs',
+        'webContents',
+        'inlineTextBoxes',
+        'extendedProperties'
+      ];
+      app.setAccessibilitySupportFeatures(subsetB);
+      const afterSubsetB = app.getAccessibilitySupportFeatures();
+      expect(afterSubsetB).to.deep.equal(subsetB);
+    });
+
+    it('throws when an unknown accessibility feature is requested', () => {
+      expect(() => {
+        app.setAccessibilitySupportFeatures(['unknownFeature']);
+      }).to.throw('Unknown accessibility feature: unknownFeature');
+    });
   });
 
   ifdescribe(process.platform === 'win32')('setJumpList(categories)', () => {